Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
- `throughput` write subcommands (`set`/`manual`/`autoscale`) now accept `--dry-run` to preview the change — reporting current vs. planned mode and RU/s as JSON (and a table interactively) — without applying it or prompting for confirmation. A first slice of dry-run mode (item G1). ([#164](https://github.com/Azure/CosmosDBShell/issues/164))
- **`--dry-run` for destructive delete commands.** `rm`, `rmcon`, `rmdb`, and `delete` accept `--dry-run` to preview the effect without deleting anything: `rm` reports how many items match the pattern, and `rmcon`/`rmdb` report the container or database that would be removed. No confirmation prompt is shown and no changes are made. ([#156](https://github.com/Azure/CosmosDBShell/issues/156))

### Improvements

- **Structured (JSON) tool results for MCP.** MCP tool results now carry the machine-readable JSON payload (`result`/`outputText`/`error` plus `currentLocation`) as first-class `structuredContent` in addition to the existing JSON text block, so agents can consume structured results directly. The two representations are kept byte-for-byte equivalent, and text-only clients are unaffected. ([#154](https://github.com/Azure/CosmosDBShell/issues/154))
- **Request charge in MCP structured results.** Data-plane commands (`query`, `print`, `mkitem`, `replace`, `patch`, `rm`, `import`, `export`, and `sproc exec`) now report the Cosmos DB request charge (in RUs) consumed by the operation as a uniform `requestCharge` field on the MCP tool result, so agents can track RU cost consistently across calls. ([#162](https://github.com/Azure/CosmosDBShell/issues/162))

## 1.1.115-preview — 2026-07-01

A large feature cycle on top of 1.1.4-preview. The shell gains a stack of new commands — configurable **color themes**, a native **`filter`** language, change-feed **`watch`**, **`index`** and **`throughput`** management, bulk **`import`/`export`**, and server-side **`sproc`/`udf`/`trigger`** programming — plus a reworked **`info`** command (formerly `settings`) with usage statistics and JSON output, first-class observability through **`--diagnostics`** and **`--otel`**, and hardening of the MCP server.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ public async Task ExecAsync_ReturnsResource()

var json = Assert.IsType<JsonElement>(state.Result!.ConvertShellObject(DataType.Json));
Assert.True(json.GetProperty("ok").GetBoolean());
Assert.Equal(2.0, state.RequestCharge);
}

[Fact]
Expand Down
72 changes: 72 additions & 0 deletions CosmosDBShell.Tests/McpResponseFactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,76 @@ public void CreateSuccess_WhenDisconnected_UsesNullCurrentLocation()
Assert.False(result.IsError);
Assert.Equal(JsonValueKind.Null, document.RootElement.GetProperty("currentLocation").ValueKind);
}

[Fact]
public void CreateSuccess_PopulatesStructuredContentMatchingTextBlock()
{
var commandState = new CommandState
{
Result = new ShellJson(JsonSerializer.SerializeToElement(new
{
connected = true,
})),
};

var result = McpResponseFactory.CreateSuccess(commandState, new ContainerState("TestContainer", "TestDatabase", null!));
var text = Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text;

Assert.NotNull(result.StructuredContent);
var structured = result.StructuredContent!.Value;

// The structured payload and the text rendering must stay in lockstep so clients
// that read either representation observe the same contract.
Assert.Equal(text, structured.GetRawText());
Assert.True(structured.GetProperty("result").GetProperty("connected").GetBoolean());
Assert.Equal("/TestDatabase/TestContainer", structured.GetProperty("currentLocation").GetString());
}

[Fact]
public void CreateError_PopulatesStructuredContentMatchingTextBlock()
{
var result = McpResponseFactory.CreateError("boom", new DatabaseState("TestDatabase", null!));
var text = Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text;

Assert.True(result.IsError);
Assert.NotNull(result.StructuredContent);
var structured = result.StructuredContent!.Value;

// The structured payload and the text rendering must stay in lockstep so clients
// that read either representation observe the same contract.
Assert.Equal(text, structured.GetRawText());
Assert.Equal("boom", structured.GetProperty("error").GetString());
Assert.Equal("/TestDatabase", structured.GetProperty("currentLocation").GetString());
}

[Fact]
public void CreateSuccess_IncludesRequestChargeWhenSet()
{
var commandState = new CommandState
{
Result = new ShellJson(JsonSerializer.SerializeToElement(new { result = "success" })),
RequestCharge = 4.25,
};

var result = McpResponseFactory.CreateSuccess(commandState, new ConnectedState(null!));

Assert.NotNull(result.StructuredContent);
var structured = result.StructuredContent!.Value;
Assert.True(structured.TryGetProperty("requestCharge", out var requestCharge));
Assert.Equal(4.25, requestCharge.GetDouble());
}

[Fact]
public void CreateSuccess_OmitsRequestChargeWhenNotSet()
{
var commandState = new CommandState
{
Result = new ShellJson(JsonSerializer.SerializeToElement(new { result = "success" })),
};

var result = McpResponseFactory.CreateSuccess(commandState, new ConnectedState(null!));

Assert.NotNull(result.StructuredContent);
Assert.False(result.StructuredContent!.Value.TryGetProperty("requestCharge", out _));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ public override async Task<CommandState> ExecuteAsync(ShellInterpreter shell, Co
return new CommandState
{
Result = new ShellJson(SuccessDocument.RootElement.Clone()),
RequestCharge = charge,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,7 @@ public override async Task<CommandState> ExecuteAsync(ShellInterpreter shell, Co
return new CommandState
{
Result = new ShellJson(SuccessDocument.RootElement.Clone()),
RequestCharge = charge,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public async override Task<CommandState> ExecuteAsync(ShellInterpreter shell, Co

var returnState = new CommandState();
returnState.Result = new ShellJson(SuccessDocument.RootElement.Clone());
returnState.RequestCharge = commandState.RequestCharge;
return returnState;
}

Expand Down Expand Up @@ -130,6 +131,7 @@ private static async Task WriteItemAsync(Container container, CommandState comma
{
if (!string.IsNullOrEmpty(jsonOpt))
{
double totalCharge = 0;
try
{
using var doc = JsonDocument.Parse(jsonOpt);
Expand All @@ -149,6 +151,7 @@ private static async Task WriteItemAsync(Container container, CommandState comma
? await container.UpsertItemAsync(element, cancellationToken: token)
: await container.CreateItemAsync(element, cancellationToken: token);
charge += result.RequestCharge;
totalCharge += result.RequestCharge;

if (result.StatusCode == System.Net.HttpStatusCode.Created)
{
Expand Down Expand Up @@ -273,6 +276,8 @@ private static async Task WriteItemAsync(Container container, CommandState comma
? await container.UpsertItemAsync(root, cancellationToken: token)
: await container.CreateItemAsync(root, cancellationToken: token);

totalCharge += result.RequestCharge;

if (result.StatusCode == System.Net.HttpStatusCode.Created)
{
var key = force ? "command-mkitem-upserted-created" : "command-mkitem-created-success";
Expand Down Expand Up @@ -314,6 +319,8 @@ private static async Task WriteItemAsync(Container container, CommandState comma
{
throw new CommandException("mkitem", MessageService.GetArgsString("json_error_parsing_arg", "message", ex.Message), ex);
}

commandState.RequestCharge = totalCharge;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ public override async Task<CommandState> ExecuteAsync(ShellInterpreter shell, Co
return new CommandState
{
Result = new ShellJson(SuccessDocument.RootElement.Clone()),
RequestCharge = response.RequestCharge,
};
}
catch (CosmosException ce) when (ce.StatusCode == System.Net.HttpStatusCode.NotFound)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ private async Task<CommandState> PrintItemAsync(Container container, Cancellatio

if (response.IsSuccessStatusCode)
{
commandState.RequestCharge = response.Headers.RequestCharge;
using var reader = new StreamReader(response.Content);
var content = await reader.ReadToEndAsync();

Expand Down
17 changes: 11 additions & 6 deletions CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ private async Task<CommandState> ExecuteQueryAsync(Container container, ShellInt
var returnState = new CommandState();
returnState.SetFormat(this.OutputFormat ?? Environment.GetEnvironmentVariable("COSMOSDB_SHELL_FORMAT"));
var aggregatedDocuments = new List<JsonElement>();
double totalRequestCharge = 0;

try
{
Expand Down Expand Up @@ -364,11 +365,14 @@ private async Task<CommandState> ExecuteQueryAsync(Container container, ShellInt

using var queryDocument = JsonDocument.Parse(responseContent);
ShellInterpreter.WriteLine(MessageService.GetString("command-query-fetched", new Dictionary<string, object> { { "count", queryDocument.RootElement.GetProperty("_count").ToString() } }));
var queryMetrics = response.Diagnostics.GetQueryMetrics();
if (queryMetrics != null)
{
AnsiConsole.MarkupLine(MessageService.GetString("command-query-request_charge", new Dictionary<string, object> { { "charge", queryMetrics.TotalRequestCharge.ToString() } }));
}

// Cosmos always returns the RU cost in the response headers, whereas query
// metrics (and their TotalRequestCharge) can be null when diagnostics are
// unavailable. Accumulate and report from the headers so the charge is always
// correct; the detailed metrics payload is built separately from the response.
var pageRequestCharge = response.Headers.RequestCharge;
totalRequestCharge += pageRequestCharge;
AnsiConsole.MarkupLine(MessageService.GetString("command-query-request_charge", new Dictionary<string, object> { { "charge", pageRequestCharge.ToString() } }));

var pageDocuments = queryDocument.RootElement.GetProperty("Documents");
var pageExceedsLimit = PageExceedsLimit(aggregatedDocuments.Count, pageDocuments, effectiveMaxItemCount);
Expand All @@ -387,7 +391,7 @@ private async Task<CommandState> ExecuteQueryAsync(Container container, ShellInt
new Dictionary<string, object>()
{
{ "documents", aggregatedDocuments },
{ "requestCharge", queryMetrics?.TotalRequestCharge ?? 0 },
{ "requestCharge", pageRequestCharge },
{ "queryMetrics", metricProperty },
{ "indexMetrics", parsedIndexMetrics ?? new Dictionary<string, object>() },
});
Expand Down Expand Up @@ -524,6 +528,7 @@ private async Task<CommandState> ExecuteQueryAsync(Container container, ShellInt
AnsiConsole.MarkupLine(MessageService.GetString("command-results-limit_reached", new Dictionary<string, object> { { "count", effectiveMaxItemCount.Value } }));
}

returnState.RequestCharge = totalRequestCharge;
return returnState;
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
Expand Down
14 changes: 8 additions & 6 deletions CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,16 @@ public override async Task<CommandState> ExecuteAsync(ShellInterpreter shell, Co

var partitionKeyPaths = await CosmosResourceFacade.GetPartitionKeyPathsAsync(connectedState, databaseName!, containerName!, token);

await ReplaceItemsAsync(container, partitionKeyPaths, jsonOpt, this.ETag, token);
var totalCharge = await ReplaceItemsAsync(container, partitionKeyPaths, jsonOpt, this.ETag, token);

return new CommandState
{
Result = new ShellJson(SuccessDocument.RootElement.Clone()),
RequestCharge = totalCharge,
};
}

private static async Task ReplaceItemsAsync(Container container, IReadOnlyList<string> partitionKeyPaths, string jsonInput, string? etag, CancellationToken token)
private static async Task<double> ReplaceItemsAsync(Container container, IReadOnlyList<string> partitionKeyPaths, string jsonInput, string? etag, CancellationToken token)
{
try
{
Expand All @@ -76,19 +77,18 @@ private static async Task ReplaceItemsAsync(Container container, IReadOnlyList<s
throw new CommandException("replace", MessageService.GetString("command-replace-error-etag_array_not_supported"));
}

await ReplaceArrayAsync(container, partitionKeyPaths, root, token);
return;
return await ReplaceArrayAsync(container, partitionKeyPaths, root, token);
}

await ReplaceOneAsync(container, partitionKeyPaths, root, etag, token, printSuccess: true);
return await ReplaceOneAsync(container, partitionKeyPaths, root, etag, token, printSuccess: true);
}
catch (JsonException ex)
{
throw new CommandException("replace", MessageService.GetArgsString("json_error_parsing_arg", "message", ex.Message), ex);
}
}

private static async Task ReplaceArrayAsync(Container container, IReadOnlyList<string> partitionKeyPaths, JsonElement arrayRoot, CancellationToken token)
private static async Task<double> ReplaceArrayAsync(Container container, IReadOnlyList<string> partitionKeyPaths, JsonElement arrayRoot, CancellationToken token)
{
int successCount = 0;
int failCount = 0;
Expand Down Expand Up @@ -133,6 +133,8 @@ private static async Task ReplaceArrayAsync(Container container, IReadOnlyList<s
"total",
successCount + failCount));
}

return charge;
}

private static async Task<double> ReplaceOneAsync(Container container, IReadOnlyList<string> partitionKeyPaths, JsonElement item, string? etag, CancellationToken token, bool printSuccess)
Expand Down
30 changes: 22 additions & 8 deletions CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,25 +118,26 @@ private async Task<ExitCode> RemoveItemsFromContainerAsync(ConnectedState state,
var matchKeyPropertyNames = string.IsNullOrEmpty(this.Key) ? partitionKeyPropertyNames : [this.Key];

var totalCount = 0;
double totalCharge = 0;
bool dryRun = this.DryRun == true;

// In dry-run mode, count what would be deleted without issuing any delete.
async Task<bool> TryDeleteAsync(string id, PartitionKey partitionKey)
async Task<(bool Deleted, double RequestCharge)> TryDeleteAsync(string id, PartitionKey partitionKey)
{
if (dryRun)
{
return true;
return (true, 0);
}

try
{
await container.DeleteItemAsync<object>(id, partitionKey, cancellationToken: token);
return true;
var deleteResponse = await container.DeleteItemAsync<object>(id, partitionKey, cancellationToken: token);
return (true, deleteResponse.RequestCharge);
}
catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Item was already deleted, skip
return false;
return (false, 0);
}
}

Expand Down Expand Up @@ -183,8 +184,10 @@ async Task<bool> TryDeleteAsync(string id, PartitionKey partitionKey)

if (id != null && shouldDelete)
{
if (await TryDeleteAsync(id, CreatePartitionKey(pkElements)))
var deleteResult = await TryDeleteAsync(id, CreatePartitionKey(pkElements));
if (deleteResult.Deleted)
{
totalCharge += deleteResult.RequestCharge;
totalCount++;
}
}
Expand Down Expand Up @@ -214,8 +217,10 @@ async Task<bool> TryDeleteAsync(string id, PartitionKey partitionKey)
var id = idElement.GetString();
if (id != null)
{
if (await TryDeleteAsync(id, CreatePartitionKey(pkElements)))
var deleteResult = await TryDeleteAsync(id, CreatePartitionKey(pkElements));
if (deleteResult.Deleted)
{
totalCharge += deleteResult.RequestCharge;
totalCount++;
}
}
Expand All @@ -237,6 +242,12 @@ async Task<bool> TryDeleteAsync(string id, PartitionKey partitionKey)
}

var response = await feedIterator.ReadNextAsync(token);

// The scan pages that locate matching items consume RUs regardless of whether
// any item is ultimately deleted (including in --dry-run), so include each
// page's request charge from the response headers.
totalCharge += response.Headers.RequestCharge;

using var streamReader = new StreamReader(response.Content);
var queryDocument = JsonDocument.Parse(await streamReader.ReadToEndAsync());

Expand Down Expand Up @@ -272,8 +283,10 @@ async Task<bool> TryDeleteAsync(string id, PartitionKey partitionKey)

if (shouldDelete)
{
if (await TryDeleteAsync(id, CreatePartitionKey(pkElements)))
var deleteResult = await TryDeleteAsync(id, CreatePartitionKey(pkElements));
if (deleteResult.Deleted)
{
totalCharge += deleteResult.RequestCharge;
totalCount++;
}
Comment thread
mkrueger marked this conversation as resolved.
}
Expand All @@ -300,6 +313,7 @@ async Task<bool> TryDeleteAsync(string id, PartitionKey partitionKey)
}));
}

commandState.RequestCharge = totalCharge;
return new ExitCode(0);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ internal async Task<CommandState> ExecAsync(Container container, CommandState co
response.RequestCharge.ToString("F2")));

commandState.Result = new ShellJson(response.Resource.Clone());
commandState.RequestCharge = response.RequestCharge;
return commandState;
}
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
Expand Down
6 changes: 6 additions & 0 deletions CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ public partial class CommandState

internal bool IsPrinted { get; set; }

/// <summary>
/// Gets or sets the Cosmos DB request charge (in RUs) consumed by the command, when applicable.
/// Data-plane commands set this so consumers such as the MCP structured payload can report cost uniformly.
/// </summary>
internal double? RequestCharge { get; set; }

internal bool BreakBlock { get; set; } = false;

internal bool ContinueBlock { get; set; } = false;
Expand Down
Loading
Loading