diff --git a/CHANGELOG.md b/CHANGELOG.md index c37448f..90ca113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CosmosDBShell.Tests/CommandTests/SprocCommandExecutionTests.cs b/CosmosDBShell.Tests/CommandTests/SprocCommandExecutionTests.cs index 2e6a6c3..e497075 100644 --- a/CosmosDBShell.Tests/CommandTests/SprocCommandExecutionTests.cs +++ b/CosmosDBShell.Tests/CommandTests/SprocCommandExecutionTests.cs @@ -204,6 +204,7 @@ public async Task ExecAsync_ReturnsResource() var json = Assert.IsType(state.Result!.ConvertShellObject(DataType.Json)); Assert.True(json.GetProperty("ok").GetBoolean()); + Assert.Equal(2.0, state.RequestCharge); } [Fact] diff --git a/CosmosDBShell.Tests/McpResponseFactoryTests.cs b/CosmosDBShell.Tests/McpResponseFactoryTests.cs index 99f92a2..490a22d 100644 --- a/CosmosDBShell.Tests/McpResponseFactoryTests.cs +++ b/CosmosDBShell.Tests/McpResponseFactoryTests.cs @@ -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(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(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 _)); + } } \ No newline at end of file diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ExportCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ExportCommand.cs index e78daf8..2fbd7f0 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ExportCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ExportCommand.cs @@ -107,6 +107,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co return new CommandState { Result = new ShellJson(SuccessDocument.RootElement.Clone()), + RequestCharge = charge, }; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs index 4074588..9269514 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs @@ -542,6 +542,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co return new CommandState { Result = new ShellJson(SuccessDocument.RootElement.Clone()), + RequestCharge = charge, }; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs index 923f33d..c535911 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs @@ -65,6 +65,7 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co var returnState = new CommandState(); returnState.Result = new ShellJson(SuccessDocument.RootElement.Clone()); + returnState.RequestCharge = commandState.RequestCharge; return returnState; } @@ -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); @@ -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) { @@ -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"; @@ -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; } } } \ No newline at end of file diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PatchCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PatchCommand.cs index d9d6578..c5d892b 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PatchCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PatchCommand.cs @@ -130,6 +130,7 @@ public override async Task 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) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PrintCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PrintCommand.cs index b988799..9ed343d 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PrintCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PrintCommand.cs @@ -57,6 +57,7 @@ private async Task PrintItemAsync(Container container, Cancellatio if (response.IsSuccessStatusCode) { + commandState.RequestCharge = response.Headers.RequestCharge; using var reader = new StreamReader(response.Content); var content = await reader.ReadToEndAsync(); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs index 72a6d8e..17b224d 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs @@ -284,6 +284,7 @@ private async Task ExecuteQueryAsync(Container container, ShellInt var returnState = new CommandState(); returnState.SetFormat(this.OutputFormat ?? Environment.GetEnvironmentVariable("COSMOSDB_SHELL_FORMAT")); var aggregatedDocuments = new List(); + double totalRequestCharge = 0; try { @@ -364,11 +365,14 @@ private async Task ExecuteQueryAsync(Container container, ShellInt using var queryDocument = JsonDocument.Parse(responseContent); ShellInterpreter.WriteLine(MessageService.GetString("command-query-fetched", new Dictionary { { "count", queryDocument.RootElement.GetProperty("_count").ToString() } })); - var queryMetrics = response.Diagnostics.GetQueryMetrics(); - if (queryMetrics != null) - { - AnsiConsole.MarkupLine(MessageService.GetString("command-query-request_charge", new Dictionary { { "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 { { "charge", pageRequestCharge.ToString() } })); var pageDocuments = queryDocument.RootElement.GetProperty("Documents"); var pageExceedsLimit = PageExceedsLimit(aggregatedDocuments.Count, pageDocuments, effectiveMaxItemCount); @@ -387,7 +391,7 @@ private async Task ExecuteQueryAsync(Container container, ShellInt new Dictionary() { { "documents", aggregatedDocuments }, - { "requestCharge", queryMetrics?.TotalRequestCharge ?? 0 }, + { "requestCharge", pageRequestCharge }, { "queryMetrics", metricProperty }, { "indexMetrics", parsedIndexMetrics ?? new Dictionary() }, }); @@ -524,6 +528,7 @@ private async Task ExecuteQueryAsync(Container container, ShellInt AnsiConsole.MarkupLine(MessageService.GetString("command-results-limit_reached", new Dictionary { { "count", effectiveMaxItemCount.Value } })); } + returnState.RequestCharge = totalRequestCharge; return returnState; } catch (OperationCanceledException) when (token.IsCancellationRequested) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs index fc38f8a..fd0a4fd 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs @@ -54,15 +54,16 @@ public override async Task 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 partitionKeyPaths, string jsonInput, string? etag, CancellationToken token) + private static async Task ReplaceItemsAsync(Container container, IReadOnlyList partitionKeyPaths, string jsonInput, string? etag, CancellationToken token) { try { @@ -76,11 +77,10 @@ private static async Task ReplaceItemsAsync(Container container, IReadOnlyList partitionKeyPaths, JsonElement arrayRoot, CancellationToken token) + private static async Task ReplaceArrayAsync(Container container, IReadOnlyList partitionKeyPaths, JsonElement arrayRoot, CancellationToken token) { int successCount = 0; int failCount = 0; @@ -133,6 +133,8 @@ private static async Task ReplaceArrayAsync(Container container, IReadOnlyList ReplaceOneAsync(Container container, IReadOnlyList partitionKeyPaths, JsonElement item, string? etag, CancellationToken token, bool printSuccess) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs index 19360f2..72e6161 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs @@ -118,25 +118,26 @@ private async Task 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 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(id, partitionKey, cancellationToken: token); - return true; + var deleteResponse = await container.DeleteItemAsync(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); } } @@ -183,8 +184,10 @@ async Task 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++; } } @@ -214,8 +217,10 @@ async Task 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++; } } @@ -237,6 +242,12 @@ async Task 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()); @@ -272,8 +283,10 @@ async Task 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++; } } @@ -300,6 +313,7 @@ async Task TryDeleteAsync(string id, PartitionKey partitionKey) })); } + commandState.RequestCharge = totalCharge; return new ExitCode(0); } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs index 04f41bc..c5f3105 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs @@ -446,6 +446,7 @@ internal async Task 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) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs index 9872011..884a600 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs @@ -28,6 +28,12 @@ public partial class CommandState internal bool IsPrinted { get; set; } + /// + /// 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. + /// + internal double? RequestCharge { get; set; } + internal bool BreakBlock { get; set; } = false; internal bool ContinueBlock { get; set; } = false; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpResponseFactory.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpResponseFactory.cs index 698cdc7..ab9e52f 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpResponseFactory.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpResponseFactory.cs @@ -57,13 +57,22 @@ private static CallToolResult CreateResponse(JsonObject payload, State shellStat { payload["currentLocation"] = GetCurrentLocation(shellState); + // Serialize the payload exactly once. The text content block reuses the structured + // element's raw JSON so the two representations are inherently byte-for-byte identical + // and no second serializer can drift out of sync. + var structuredContent = JsonSerializer.SerializeToElement(payload, JsonOptions); + return new CallToolResult { + // Emit the machine-readable payload as first-class structured content while + // retaining an equivalent text rendering so that clients that only read text + // content blocks continue to work. + StructuredContent = structuredContent, Content = [ new TextContentBlock { - Text = payload.ToJsonString(JsonOptions), + Text = structuredContent.GetRawText(), } ], IsError = isError, @@ -87,6 +96,11 @@ private static JsonObject CreateSuccessPayload(CommandState commandState) payload["result"] = resultNode; } + if (commandState.RequestCharge.HasValue) + { + payload["requestCharge"] = commandState.RequestCharge.Value; + } + if (commandState.OutputFormat == OutputFormat.CSV) { var outputText = commandState.GenerateOutputText(); diff --git a/docs/mcp.md b/docs/mcp.md index d29fc40..d05acfa 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -70,3 +70,25 @@ Your MCP client may use a remote LLM. Command outputs, query results, and file c - [ ] Review and approve destructive operations manually - [ ] Don't share secrets (keys, PII) in prompts or outputs - [ ] Disable MCP mode when not actively using it + +## Tool Results + +Every tool result carries the same machine-readable JSON payload in two places: + +- **`structuredContent`** — the payload as first-class structured content, for clients that consume MCP structured results. +- **A text content block** — the identical payload serialized as JSON text, so clients that only read text content blocks continue to work. + +Both representations are always byte-for-byte equivalent. + +### Payload shape + +| Field | When present | Description | +| ----- | ------------ | ----------- | +| `result` | Successful commands that produce output | The command result as JSON (objects, arrays, or a scalar). Text-only results are represented as a JSON string. | +| `outputText` | CSV output commands with non-empty text | The CSV rendering of the result. Omitted when the CSV output is empty or whitespace. | +| `requestCharge` | Data-plane commands that consume request units | The Cosmos DB request charge (in RUs) consumed by the command, as a number. | +| `error` | Failed commands | The error message. | +| `currentLocation` | Always | The shell's current navigation path (for example `/MyDatabase/MyContainer`), or `null` when disconnected. | + +Successful results set `result` (and optionally `outputText`); failed results set `error` and mark the tool result as an error. `currentLocation` is always included so a client can track navigation state across calls. Data-plane commands (`query`, `print`, `mkitem`, `replace`, `patch`, `rm`, `import`, `export`, and `sproc exec`) additionally set `requestCharge` so a client can track RU cost across calls. +