Skip to content
Merged
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
40 changes: 40 additions & 0 deletions CosmosDBShell.Tests/McpLocationSubscriptionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,19 @@ public async Task SubscribedClient_ReceivesInteractiveLocationChange()
try
{
await using var client = await ConnectAsync(host, timeout.Token);
Assert.NotNull(client.ServerCapabilities.Resources);
Assert.True(client.ServerCapabilities.Resources.Subscribe);
Assert.False(client.ServerCapabilities.Resources.ListChanged);
var resources = await client.ListResourcesAsync(cancellationToken: timeout.Token);
Assert.Equal(3, resources.Count);
Assert.Contains(resources, resource => resource.Uri == ResourceOperations.CurrentLocationUri);
foreach (var documentation in resources.Where(resource => resource.Uri.StartsWith("cosmos://docs/", StringComparison.Ordinal)))
{
var result = await client.ReadResourceAsync(documentation.Uri, cancellationToken: timeout.Token);
Assert.NotEmpty(Assert.IsType<TextResourceContents>(Assert.Single(result.Contents)).Text);
}

Assert.Empty(await client.ListResourceTemplatesAsync(cancellationToken: timeout.Token));

var invalid = await Assert.ThrowsAsync<McpProtocolException>(
() => client.SubscribeToResourceAsync("cosmos://docs/scripting", cancellationToken: timeout.Token));
Expand Down Expand Up @@ -105,6 +116,28 @@ public async Task EndedSession_RemovesLocationSubscription()
}
}

[Theory]
[InlineData("2025-11-25", McpErrorCode.ResourceNotFound)]
[InlineData(null, McpErrorCode.InvalidParams)]
public async Task UnknownResource_PreservesProtocolError(string? protocolVersion, McpErrorCode errorCode)
{
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
{
await using var client = await ConnectAsync(host, timeout.Token, protocolVersion);
var error = await Assert.ThrowsAsync<McpProtocolException>(
async () => await client.ReadResourceAsync("cosmos://docs/missing", cancellationToken: timeout.Token));
Assert.Equal(errorCode, error.ErrorCode);
}
finally
{
await host.StopAsync(TestContext.Current.CancellationToken);
}
}

[Fact]
public async Task ListeningClient_ReceivesLocationChangeOnListenStream()
{
Expand All @@ -117,6 +150,9 @@ public async Task ListeningClient_ReceivesLocationChangeOnListenStream()
var subscriptions = host.Services.GetRequiredService<LocationResourceSubscriptions>();
await using var client = await ConnectAsync(host, timeout.Token, protocolVersion: null);
Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion);
Assert.NotNull(client.ServerCapabilities.Resources);
Assert.True(client.ServerCapabilities.Resources.Subscribe);
Assert.False(client.ServerCapabilities.Resources.ListChanged);

var acknowledged = new TaskCompletionSource<JsonRpcNotification>(TaskCreationOptions.RunContinuationsAsynchronously);
var updated = new TaskCompletionSource<JsonRpcNotification>(TaskCreationOptions.RunContinuationsAsynchronously);
Expand Down Expand Up @@ -147,6 +183,7 @@ public async Task ListeningClient_ReceivesLocationChangeOnListenStream()
{
ResourceSubscriptions = [ResourceOperations.CurrentLocationUri, "cosmos://docs/scripting"],
ToolsListChanged = true,
ResourcesListChanged = true,
},
}),
},
Expand All @@ -157,6 +194,7 @@ public async Task ListeningClient_ReceivesLocationChangeOnListenStream()
var granted = acknowledgement.Params!["notifications"]!.AsObject();
Assert.Equal(ResourceOperations.CurrentLocationUri, Assert.Single(granted["resourceSubscriptions"]!.AsArray())!.GetValue<string>());
Assert.False(granted.ContainsKey("toolsListChanged"));
Assert.False(granted.ContainsKey("resourcesListChanged"));
Assert.Equal("location-listen", acknowledgement.Params["_meta"]![MetaKeys.SubscriptionId]!.GetValue<string>());
while (subscriptions.ListenerCount != 1)
{
Expand Down Expand Up @@ -235,6 +273,7 @@ public async Task ListenWithOnlyUnsupportedFilters_AcknowledgesNothingAndComplet
{
ResourceSubscriptions = ["cosmos://docs/scripting"],
ToolsListChanged = true,
ResourcesListChanged = true,
},
}),
},
Expand All @@ -245,6 +284,7 @@ public async Task ListenWithOnlyUnsupportedFilters_AcknowledgesNothingAndComplet
var granted = acknowledgement.Params!["notifications"]!.AsObject();
Assert.False(granted.ContainsKey("resourceSubscriptions"));
Assert.False(granted.ContainsKey("toolsListChanged"));
Assert.False(granted.ContainsKey("resourcesListChanged"));
Assert.Equal("unsupported-listen", acknowledgement.Params["_meta"]![MetaKeys.SubscriptionId]!.GetValue<string>());
Assert.Equal(0, subscriptions.ListenerCount);
}
Expand Down
7 changes: 4 additions & 3 deletions CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,15 @@ private static void ConfigureMcpServer(IServiceCollection services)
mcpServerOptions.Capabilities = new ServerCapabilities
{
Tools = new ToolsCapability(),
Resources = new ResourcesCapability { Subscribe = true },
Resources = new ResourcesCapability { Subscribe = true, ListChanged = false },
};

mcpServerOptions.Handlers = new McpServerHandlers
{
CallToolHandler = toolOperations.CallToolHandler,
ListToolsHandler = toolOperations.ListToolsHandler,
ListResourcesHandler = ResourceOperations.ListResourcesAsync,
ReadResourceHandler = ResourceOperations.ReadResourceAsync,
SubscribeToResourcesHandler = toolOperations.SubscribeToResourcesHandler,
UnsubscribeFromResourcesHandler = toolOperations.UnsubscribeFromResourcesHandler,
SubscriptionsListenHandler = locationSubscriptions.ListenAsync,
Expand All @@ -86,9 +88,8 @@ private static void ConfigureMcpServer(IServiceCollection services)
mcpServerOptions.ServerInstructions = LoadServerInstructions();
});

// SDK resource collections advertise list changes even when configured otherwise.
var mcpServerBuilder = services.AddMcpServer();
mcpServerBuilder.WithResources<ResourceOperations>();

mcpServerBuilder.WithHttpTransport(ConfigureHttpTransport);
}

Expand Down
31 changes: 31 additions & 0 deletions CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ResourceOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ namespace Azure.Data.Cosmos.Shell.Mcp;
using Azure.Data.Cosmos.Shell.States;
using Azure.Data.Cosmos.Shell.Util;

using ModelContextProtocol;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;

/// <summary>
Expand All @@ -23,6 +25,35 @@ internal class ResourceOperations
private const string ScriptingUri = "cosmos://docs/scripting";
private const string QueryLanguageUri = "cosmos://docs/nosql-query-language";

private static readonly IReadOnlyDictionary<string, McpServerResource> Resources =
new Dictionary<string, McpServerResource>(StringComparer.Ordinal)
{
[CurrentLocationUri] = McpServerResource.Create((Func<string>)GetCurrentLocation),
[ScriptingUri] = McpServerResource.Create((Func<string>)GetScriptingGuide),
[QueryLanguageUri] = McpServerResource.Create((Func<string>)GetQueryLanguageReference),
};

internal static ValueTask<ListResourcesResult> ListResourcesAsync(RequestContext<ListResourcesRequestParams> context, CancellationToken cancellationToken)
{
return ValueTask.FromResult(new ListResourcesResult
{
Resources = context.Params?.Cursor is null ? Resources.Values.Select(resource => resource.ProtocolResource!).ToList() : [],
});
}

internal static ValueTask<ReadResourceResult> ReadResourceAsync(RequestContext<ReadResourceRequestParams> context, CancellationToken cancellationToken)
{
if (context.Params?.Uri is { } uri && Resources.TryGetValue(uri, out var resource))
{
return resource.ReadAsync(context, cancellationToken);
}

var errorCode = StringComparer.Ordinal.Compare(context.Server.NegotiatedProtocolVersion, "2026-07-28") >= 0
? McpErrorCode.InvalidParams
: McpErrorCode.ResourceNotFound;
throw new McpProtocolException($"Unknown resource URI: '{context.Params?.Uri}'", errorCode);
}

[McpServerResource(
UriTemplate = CurrentLocationUri,
Name = "cosmos-shell-current-location",
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Exports replace their destination only after successful completion, preserving a

MCP command execution is serialized with the shell, and destructive confirmations are invalidated by connection or navigation changes. Ordinary explicit null MCP arguments are omitted; null continuation tokens and null `rm` partition-key/ETag safety options are rejected. MCP invocations are echoed in the shell so their activity stays visible, and they are recorded in history alongside interactive commands. Concurrent shells merge history under a shared lock and publish complete replacements instead of truncating the saved file. History remains fully replayable, including connection strings; treat its file as sensitive. See [MCP security](docs/mcp.md#security) and [history](docs/navigation.md#history).

MCP clients supporting resource subscriptions can watch `cosmos://shell/current-location` for interactive navigation and connection changes; the resource includes the current account endpoint separately from the location. See [MCP location updates](docs/mcp.md#shell-location-updates).
MCP clients supporting resource subscriptions can watch `cosmos://shell/current-location` for interactive navigation and connection changes; the resource includes the current account endpoint separately from the location. The resource list is static, so the server does not advertise `resources.listChanged`. See [MCP location updates](docs/mcp.md#shell-location-updates).

## Quick Start

Expand Down
2 changes: 2 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ For deterministic ARM routing in multi-subscription environments, start the shel

Clients can read the `cosmos://shell/current-location` MCP resource. Its JSON content has a `currentLocation` field (`null` when disconnected, `/` at the account root, or `/database[/container]`) and a separate `currentAccountEndpoint` field (the connected Cosmos DB account URL, or `null` when disconnected). For example: `{"currentLocation":"/myDb/myContainer","currentAccountEndpoint":"https://myaccount.documents.azure.com/"}`. Clients that support resource subscriptions receive `notifications/resources/updated` when the shared shell location or connection changes, including changes made interactively. On notification, read the resource again for the new values; the notification itself contains only the URI. Rapid consecutive changes may be coalesced into a single notification.

The resource list is static, so the server does not advertise `resources.listChanged` or honor resource-list-change subscriptions. Resource-content subscriptions for `cosmos://shell/current-location` remain supported.

How a client subscribes depends on its protocol revision:

- **`2026-07-28` clients** send `subscriptions/listen` with `resourceSubscriptions` containing `cosmos://shell/current-location`. The server first sends `notifications/subscriptions/acknowledged` listing the subscriptions it honors, then streams updates on the same response. Every notification on the stream carries the listen request ID in `_meta["io.modelcontextprotocol/subscriptionId"]`. Only `cosmos://shell/current-location` is honored; other resource URIs and list-changed filters are left out of the acknowledgement. If nothing is honored, the listen request completes right after the acknowledgement. Otherwise the subscription lasts until the client cancels the request or closes the connection.
Expand Down
Loading