From 6555c8af61c36f7a51eb917928e7ee4208d1e0c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Thu, 8 Oct 2026 11:01:44 +0200 Subject: [PATCH] Stop advertising static MCP resource list changes Register static resource handlers while preserving location subscriptions and resource metadata. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../McpLocationSubscriptionTests.cs | 40 +++++++++++++++++++ .../Azure.Data.Cosmos.Shell.Mcp/McpServer.cs | 7 ++-- .../ResourceOperations.cs | 31 ++++++++++++++ README.md | 2 +- docs/mcp.md | 2 + 5 files changed, 78 insertions(+), 4 deletions(-) diff --git a/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs b/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs index 6196cc75..2df82edb 100644 --- a/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs +++ b/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs @@ -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(Assert.Single(result.Contents)).Text); + } + + Assert.Empty(await client.ListResourceTemplatesAsync(cancellationToken: timeout.Token)); var invalid = await Assert.ThrowsAsync( () => client.SubscribeToResourceAsync("cosmos://docs/scripting", cancellationToken: timeout.Token)); @@ -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( + 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() { @@ -117,6 +150,9 @@ public async Task ListeningClient_ReceivesLocationChangeOnListenStream() var subscriptions = host.Services.GetRequiredService(); 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(TaskCreationOptions.RunContinuationsAsynchronously); var updated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -147,6 +183,7 @@ public async Task ListeningClient_ReceivesLocationChangeOnListenStream() { ResourceSubscriptions = [ResourceOperations.CurrentLocationUri, "cosmos://docs/scripting"], ToolsListChanged = true, + ResourcesListChanged = true, }, }), }, @@ -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()); Assert.False(granted.ContainsKey("toolsListChanged")); + Assert.False(granted.ContainsKey("resourcesListChanged")); Assert.Equal("location-listen", acknowledgement.Params["_meta"]![MetaKeys.SubscriptionId]!.GetValue()); while (subscriptions.ListenerCount != 1) { @@ -235,6 +273,7 @@ public async Task ListenWithOnlyUnsupportedFilters_AcknowledgesNothingAndComplet { ResourceSubscriptions = ["cosmos://docs/scripting"], ToolsListChanged = true, + ResourcesListChanged = true, }, }), }, @@ -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()); Assert.Equal(0, subscriptions.ListenerCount); } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpServer.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpServer.cs index d07021d1..5a8a8e6b 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpServer.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpServer.cs @@ -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, @@ -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(); - mcpServerBuilder.WithHttpTransport(ConfigureHttpTransport); } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ResourceOperations.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ResourceOperations.cs index e28ee4e5..17e92409 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ResourceOperations.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ResourceOperations.cs @@ -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; /// @@ -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 Resources = + new Dictionary(StringComparer.Ordinal) + { + [CurrentLocationUri] = McpServerResource.Create((Func)GetCurrentLocation), + [ScriptingUri] = McpServerResource.Create((Func)GetScriptingGuide), + [QueryLanguageUri] = McpServerResource.Create((Func)GetQueryLanguageReference), + }; + + internal static ValueTask ListResourcesAsync(RequestContext context, CancellationToken cancellationToken) + { + return ValueTask.FromResult(new ListResourcesResult + { + Resources = context.Params?.Cursor is null ? Resources.Values.Select(resource => resource.ProtocolResource!).ToList() : [], + }); + } + + internal static ValueTask ReadResourceAsync(RequestContext 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", diff --git a/README.md b/README.md index c29368c3..dbe18f92 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/mcp.md b/docs/mcp.md index a5c7d9fc..a8960c81 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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.