Skip to content
Draft
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### New features

- **Non-transactional `bulk` command.** Run unlimited independent `create`, `upsert`, `replace`, `delete`, and `patch` operations across partition keys with bounded concurrency (default 16). It uses the batch operation schema, plus optional per-operation `partitionKey` and `ifMatch`, and the same `run`/`begin`/`add`/`execute`/`cancel`/`status`/`show` subcommands. `bulk patch --where` and `bulk delete --where` select items by predicate, including complete hierarchical partition keys. Add `--save` to write a reviewable plan for `bulk run`. Safeguards include dry-run, confirmation, item and observed-RU limits, and ETag checks. Journals skip succeeded writes on rerun, retry failed writes, and hold back writes with unknown outcomes. MCP exposes `run`, `patch`, and `delete`. ([#107](https://github.com/Azure/CosmosDBShell/issues/107))

### Fixes

- Parse `exec` options and shell words like direct commands. Bind built-in options normally and pass option-shaped words to functions and script files as positional text.
Expand Down
863 changes: 863 additions & 0 deletions CosmosDBShell.Tests/CommandTests/BulkCommandTests.cs

Large diffs are not rendered by default.

54 changes: 54 additions & 0 deletions CosmosDBShell.Tests/McpConfirmationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,60 @@ namespace CosmosShell.Tests;
[Collection(CosmosShell.Tests.Shell.ThemeStateTestCollection.Name)]
public class McpConfirmationTests
{
[Theory]
[InlineData("run", false, 1, "was not approved")]
[InlineData("run", true, 0, "not connected")]
[InlineData("begin", false, 0, "MCP supports only the stateless 'bulk run'")]
[InlineData("execute", false, 0, "MCP supports only the stateless 'bulk run'")]
public async Task Bulk_McpRequiresConfirmationExceptDryRunAndRejectsStatefulSubcommands(string subcommand, bool dryRun, int expectedPrompts, string expectedText)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(10));
using var host = McpServer.CreateHost(new Program.CosmosShellOptions { McpPort = 0 });
await host.StartAsync(timeout.Token);
try
{
var prompts = 0;
var address = host.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.Single();
var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri(address.TrimEnd('/') + "/") });
await using var client = await McpClient.CreateAsync(
transport,
new McpClientOptions
{
Handlers = new McpClientHandlers
{
ElicitationHandler = (_, _) =>
{
prompts++;
return ValueTask.FromResult(new ElicitResult { Action = "decline" });
},
},
},
cancellationToken: timeout.Token);
var arguments = new Dictionary<string, object?> { ["subcommand"] = subcommand, ["yes"] = true };
if (subcommand == "run")
{
arguments["data"] = "[{\"op\":\"delete\",\"id\":\"1\",\"partitionKey\":\"a\"}]";
}

if (dryRun)
{
arguments["dry-run"] = true;
}

var result = await client.CallToolAsync("bulk", arguments, cancellationToken: timeout.Token);
Assert.Equal(expectedPrompts, prompts);
Assert.True(result.IsError);
var text = Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text;
using var document = JsonDocument.Parse(text);
Assert.Contains(expectedText, document.RootElement.GetProperty("error").GetString(), StringComparison.OrdinalIgnoreCase);
}
finally
{
await host.StopAsync(TestContext.Current.CancellationToken);
}
}

[Theory]
[InlineData(null, "2026-07-28")] // Stateless request; confirmation uses native multi-round-trip requests.
[InlineData("2025-11-25", "2025-11-25")] // Initialize handshake; confirmation is sent over the session.
Expand Down
13 changes: 13 additions & 0 deletions CosmosDBShell.Tests/Shell/CosmosShellPromptTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,17 @@ public void GetPromptString_WithActiveBatch_EscapesIndicatorOnce()
Assert.Contains(Markup.Escape("[batch:0]"), prompt);
Assert.DoesNotContain(Markup.Escape(Markup.Escape("[batch:0]")), prompt);
}

[Fact]
public void GetPromptString_WithActiveBulk_ShowsOperationCount()
{
var shell = ShellInterpreter.CreateInstance();
shell.State = new DisconnectedState();
shell.CurrentBulk = new PendingBulkState("TestDatabase", "TestContainer", "rid", ["/pk"], null, null);
shell.CurrentBulk.Operations.Add(new Azure.Data.Cosmos.Shell.Commands.BulkOperation(0, "delete", "1", "[\"a\"]", "{}"));

var prompt = new CosmosShellPrompt(shell).GetPromptString();

Assert.Contains(Markup.Escape("[bulk:1]"), prompt);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public static List<BatchOperationSpec> Parse(string commandName, string json)
}
}

private static BatchOperationSpec ParseOne(string commandName, JsonElement element)
internal static BatchOperationSpec ParseOne(string commandName, JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object)
{
Expand Down Expand Up @@ -157,7 +157,7 @@ private static JsonElement RequireItem(string commandName, JsonElement element,
return null;
}

private static List<PatchOperation> ParsePatchOperations(string commandName, JsonElement element)
internal static List<PatchOperation> ParsePatchOperations(string commandName, JsonElement element)
{
if (!element.TryGetProperty("operations", out var operationsElement) || operationsElement.ValueKind != JsonValueKind.Array)
{
Expand Down
Loading
Loading