From b0480df91125c0db4cac5bf339e4d8e33bb13e31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Tue, 29 Sep 2026 10:37:16 +0200 Subject: [PATCH 1/4] Notify MCP clients about shell location changes Expose the shared shell navigation state as the MCP resource cosmos://shell/current-location and support resources/subscribe for it. Subscribed clients receive notifications/resources/updated whenever the location or connection changes, including interactive cd, connect, and disconnect, and can re-read the resource for the new value. - Raise ShellInterpreter.LocationChanged only when the location or the underlying CosmosClient actually changes. - Coalesce pending notifications through a single-slot channel. - Reject subscriptions to other URIs with an InvalidParams protocol error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../McpLocationSubscriptionTests.cs | 77 ++++++++++++ .../ResourceOperationsTests.cs | 22 ++++ .../ShellLocationChangedTests.cs | 37 ++++++ .../ToolOperationsCallToolTests.cs | 14 ++- .../ShellInterpreter.cs | 9 ++ .../LocationResourceSubscriptions.cs | 119 ++++++++++++++++++ .../Azure.Data.Cosmos.Shell.Mcp/McpServer.cs | 6 +- .../ResourceOperations.cs | 24 ++++ .../ServerInstructions.md | 1 + .../ToolOperations.cs | 20 ++- README.md | 2 + docs/mcp.md | 8 ++ 12 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 CosmosDBShell.Tests/McpLocationSubscriptionTests.cs create mode 100644 CosmosDBShell.Tests/ShellLocationChangedTests.cs create mode 100644 CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/LocationResourceSubscriptions.cs diff --git a/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs b/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs new file mode 100644 index 00000000..8f6b726b --- /dev/null +++ b/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs @@ -0,0 +1,77 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests; + +using System.Net; +using System.Net.Sockets; +using System.Text.Json; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Mcp; +using Azure.Data.Cosmos.Shell.States; +using ModelContextProtocol; +using ModelContextProtocol.Client; + +[Collection(CosmosShell.Tests.Shell.ThemeStateTestCollection.Name)] +public class McpLocationSubscriptionTests +{ + [Fact] + public async Task SubscribedClient_ReceivesInteractiveLocationChange() + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(10)); + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + + using var host = McpServer.CreateHost(new Program.CosmosShellOptions { McpPort = port }); + await host.StartAsync(timeout.Token); + try + { + var transport = new HttpClientTransport(new HttpClientTransportOptions + { + Endpoint = new Uri($"http://127.0.0.1:{port}/"), + }); + await using var client = await McpClient.CreateAsync(transport, cancellationToken: timeout.Token); + var resources = await client.ListResourcesAsync(cancellationToken: timeout.Token); + Assert.Contains(resources, resource => resource.Uri == ResourceOperations.CurrentLocationUri); + + var invalid = await Assert.ThrowsAsync( + () => client.SubscribeToResourceAsync("cosmos://docs/scripting", cancellationToken: timeout.Token)); + Assert.Equal(McpErrorCode.InvalidParams, invalid.ErrorCode); + Assert.Contains(ResourceOperations.CurrentLocationUri, invalid.Message); + + var updated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var subscription = await client.SubscribeToResourceAsync( + ResourceOperations.CurrentLocationUri, + (notification, _) => + { + updated.TrySetResult(notification.Uri); + return ValueTask.CompletedTask; + }, + cancellationToken: timeout.Token); + + var originalState = ShellInterpreter.Instance.State; + try + { + ShellInterpreter.Instance.State = new DatabaseState("McpNotificationTest", null!); + Assert.Equal(ResourceOperations.CurrentLocationUri, await updated.Task.WaitAsync(timeout.Token)); + + var resource = await client.ReadResourceAsync(ResourceOperations.CurrentLocationUri, cancellationToken: timeout.Token); + var content = Assert.Single(resource.Contents); + using var json = JsonDocument.Parse(Assert.IsType(content).Text); + Assert.Equal("/McpNotificationTest", json.RootElement.GetProperty("currentLocation").GetString()); + } + finally + { + ShellInterpreter.Instance.State = originalState; + } + } + finally + { + await host.StopAsync(TestContext.Current.CancellationToken); + } + } +} diff --git a/CosmosDBShell.Tests/ResourceOperationsTests.cs b/CosmosDBShell.Tests/ResourceOperationsTests.cs index b432e602..d82d83cd 100644 --- a/CosmosDBShell.Tests/ResourceOperationsTests.cs +++ b/CosmosDBShell.Tests/ResourceOperationsTests.cs @@ -4,10 +4,32 @@ namespace CosmosShell.Tests; +using System.Text.Json; using Azure.Data.Cosmos.Shell.Mcp; +using Azure.Data.Cosmos.Shell.States; public class ResourceOperationsTests { + [Theory] + [InlineData(null, null, null)] + [InlineData("", null, "/")] + [InlineData("db", null, "/db")] + [InlineData("db", "container", "/db/container")] + public void GetCurrentLocation_ReturnsSharedLocationAsJson(string? database, string? container, string? expected) + { + var state = database is null + ? (State)new DisconnectedState() + : database.Length == 0 + ? new ConnectedState(null!) + : container is null + ? new DatabaseState(database, null!) + : new ContainerState(container, database, null!); + + using var document = JsonDocument.Parse(ResourceOperations.GetCurrentLocation(state)); + var location = document.RootElement.GetProperty("currentLocation"); + Assert.Equal(expected, location.ValueKind == JsonValueKind.Null ? null : location.GetString()); + } + [Fact] public void GetScriptingGuide_ReturnsEmbeddedProgrammingMarkdown() { diff --git a/CosmosDBShell.Tests/ShellLocationChangedTests.cs b/CosmosDBShell.Tests/ShellLocationChangedTests.cs new file mode 100644 index 00000000..e3bd10ce --- /dev/null +++ b/CosmosDBShell.Tests/ShellLocationChangedTests.cs @@ -0,0 +1,37 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests; + +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.States; + +public class ShellLocationChangedTests +{ + [Fact] + public void State_NotifiesOnlyWhenLocationChanges() + { + using var shell = new ShellInterpreter(); + var changes = 0; + shell.LocationChanged += () => changes++; + + shell.State = new DisconnectedState(); + Assert.Equal(0, changes); + + shell.State = new ConnectedState(null!); + Assert.Equal(1, changes); + + shell.State = new DatabaseState("db", null!); + Assert.Equal(2, changes); + + shell.State = new DatabaseState("db", null!); + Assert.Equal(2, changes); + + shell.State = new ContainerState("container", "db", null!); + Assert.Equal(3, changes); + + shell.State = new DisconnectedState(); + Assert.Equal(4, changes); + } +} diff --git a/CosmosDBShell.Tests/ToolOperationsCallToolTests.cs b/CosmosDBShell.Tests/ToolOperationsCallToolTests.cs index 531e6836..42173c90 100644 --- a/CosmosDBShell.Tests/ToolOperationsCallToolTests.cs +++ b/CosmosDBShell.Tests/ToolOperationsCallToolTests.cs @@ -28,11 +28,19 @@ namespace CosmosShell.Tests; // success path (which writes the highlighted command line through AnsiConsole) does // not race with other tests that swap the global console or theme. [Collection(CosmosShell.Tests.Shell.ThemeStateTestCollection.Name)] -public class ToolOperationsCallToolTests +public class ToolOperationsCallToolTests : IDisposable { - private static ToolOperations CreateToolOperations() + private readonly LocationResourceSubscriptions locationSubscriptions = + new(NullLogger.Instance); + + public void Dispose() + { + this.locationSubscriptions.Dispose(); + } + + private ToolOperations CreateToolOperations() { - return new ToolOperations(NullLogger.Instance); + return new ToolOperations(NullLogger.Instance, this.locationSubscriptions); } private static RequestContext CallContext(string? name, Dictionary? arguments = null) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs index 50e56321..47a377cc 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs @@ -133,6 +133,8 @@ internal ShellInterpreter(string? configPath = null) this.editorCancelTokenSource = new CancellationTokenSource(); } + internal event Action? LocationChanged; + /// /// Gets the line editor instance used by the shell, or null if not available. /// @@ -288,8 +290,15 @@ internal State State get; set { + var oldState = field; field = value; Interlocked.Increment(ref this.stateVersion); + if (oldState != null + && (ShellLocation.GetCurrentLocation(oldState) != ShellLocation.GetCurrentLocation(value) + || (oldState as ConnectedState)?.Client != (value as ConnectedState)?.Client)) + { + this.LocationChanged?.Invoke(); + } } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/LocationResourceSubscriptions.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/LocationResourceSubscriptions.cs new file mode 100644 index 00000000..1d68164c --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/LocationResourceSubscriptions.cs @@ -0,0 +1,119 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Mcp; + +using System.Threading.Channels; +using Azure.Data.Cosmos.Shell.Core; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +internal sealed class LocationResourceSubscriptions : BackgroundService +{ + private readonly object sync = new(); + + private readonly List> subscribers = []; + + // Notifications carry only the URI, so pending changes are coalesced into one. + private readonly Channel changes = Channel.CreateBounded( + new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite, SingleReader = true }); + + private readonly ILogger logger; + + public LocationResourceSubscriptions(ILogger logger) + { + this.logger = logger; + ShellInterpreter.Instance.LocationChanged += this.OnLocationChanged; + } + + public void Subscribe(ModelContextProtocol.Server.McpServer server, string uri) + { + ValidateUri(uri); + lock (this.sync) + { + this.PruneSubscribers(); + if (!this.subscribers.Any(reference => reference.TryGetTarget(out var target) && ReferenceEquals(target, server))) + { + this.subscribers.Add(new WeakReference(server)); + } + } + } + + public void Unsubscribe(ModelContextProtocol.Server.McpServer server, string uri) + { + ValidateUri(uri); + lock (this.sync) + { + this.subscribers.RemoveAll(reference => !reference.TryGetTarget(out var target) || ReferenceEquals(target, server)); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await foreach (var change in this.changes.Reader.ReadAllAsync(stoppingToken)) + { + ModelContextProtocol.Server.McpServer[] servers; + lock (this.sync) + { + this.PruneSubscribers(); + servers = this.subscribers + .Select(reference => reference.TryGetTarget(out var server) ? server : null) + .OfType() + .ToArray(); + } + + foreach (var server in servers) + { + try + { + await server.SendNotificationAsync( + NotificationMethods.ResourceUpdatedNotification, + new ResourceUpdatedNotificationParams { Uri = ResourceOperations.CurrentLocationUri }, + cancellationToken: stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Could not notify an MCP client about the shell location change."); + lock (this.sync) + { + this.subscribers.RemoveAll(reference => !reference.TryGetTarget(out var target) || ReferenceEquals(target, server)); + } + } + } + } + } + + public override void Dispose() + { + ShellInterpreter.Instance.LocationChanged -= this.OnLocationChanged; + base.Dispose(); + } + + internal static void ValidateUri(string uri) + { + if (!string.Equals(uri, ResourceOperations.CurrentLocationUri, StringComparison.Ordinal)) + { + throw new McpProtocolException( + $"Resource '{uri}' does not support subscriptions. Only '{ResourceOperations.CurrentLocationUri}' can be subscribed to.", + McpErrorCode.InvalidParams); + } + } + + private void OnLocationChanged() + { + this.changes.Writer.TryWrite(true); + } + + private void PruneSubscribers() + { + this.subscribers.RemoveAll(reference => !reference.TryGetTarget(out _)); + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpServer.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpServer.cs index 1193cea1..87f464e3 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpServer.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpServer.cs @@ -50,6 +50,8 @@ public static IHost CreateHost(CosmosShellOptions serverArguments) private static void ConfigureMcpServer(IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(services => services.GetRequiredService()); services.AddOptions() .Configure((mcpServerOptions, toolOperations) => { @@ -66,13 +68,15 @@ private static void ConfigureMcpServer(IServiceCollection services) mcpServerOptions.Capabilities = new ServerCapabilities { Tools = new ToolsCapability(), - Resources = new ResourcesCapability(), + Resources = new ResourcesCapability { Subscribe = true }, }; mcpServerOptions.Handlers = new McpServerHandlers { CallToolHandler = toolOperations.CallToolHandler, ListToolsHandler = toolOperations.ListToolsHandler, + SubscribeToResourcesHandler = toolOperations.SubscribeToResourcesHandler, + UnsubscribeFromResourcesHandler = toolOperations.UnsubscribeFromResourcesHandler, }; mcpServerOptions.ServerInstructions = LoadServerInstructions(); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ResourceOperations.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ResourceOperations.cs index 499f2ff2..ad276bbb 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ResourceOperations.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ResourceOperations.cs @@ -6,6 +6,10 @@ namespace Azure.Data.Cosmos.Shell.Mcp; using System.ComponentModel; using System.Reflection; +using System.Text.Json; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.States; +using Azure.Data.Cosmos.Shell.Util; using ModelContextProtocol.Server; @@ -15,9 +19,29 @@ namespace Azure.Data.Cosmos.Shell.Mcp; [McpServerResourceType] internal class ResourceOperations { + internal const string CurrentLocationUri = "cosmos://shell/current-location"; private const string ScriptingUri = "cosmos://docs/scripting"; private const string QueryLanguageUri = "cosmos://docs/nosql-query-language"; + [McpServerResource( + UriTemplate = CurrentLocationUri, + Name = "cosmos-shell-current-location", + Title = "Current Cosmos Shell Location", + MimeType = "application/json")] + [Description("Current shared shell navigation location. Subscribe to this resource for changes made in the interactive shell or by MCP clients. Read it again after an update notification.")] + public static string GetCurrentLocation() + { + return GetCurrentLocation(ShellInterpreter.Instance.State); + } + + internal static string GetCurrentLocation(State state) + { + return JsonSerializer.Serialize(new + { + currentLocation = ShellLocation.GetCurrentLocation(state), + }); + } + /// /// Returns the Cosmos Shell scripting / programming guide so the LLM can /// help users author .csh scripts. diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ServerInstructions.md b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ServerInstructions.md index 52ce2785..1c742a55 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ServerInstructions.md +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ServerInstructions.md @@ -21,6 +21,7 @@ NAVIGATION: - The shell models Cosmos DB as a folder-like hierarchy: Account → Databases → Containers → Items. - Treat navigation state as convenience only. When a command supports --db and --con, prefer passing them explicitly instead of relying on prior 'cd' calls. - Reuse the currentLocation field returned by MCP responses for awareness, but still prefer explicit --db and --con on follow-up tool calls. +- Clients that support resource subscriptions can subscribe to `cosmos://shell/current-location`. Read it again after an update notification to learn about interactive shell navigation, connection, or MCP navigation changes. - Use `cd [name]` to enter a database or container, `cd ..` to go up one level, and `cd` to return to the root. - Path chaining is supported: 'cd MyDatabase/MyContainer' navigates multiple levels at once. - Use 'ls' at any level to list resources (databases, containers, or items depending on context). diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ToolOperations.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ToolOperations.cs index ba049def..612779e3 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ToolOperations.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ToolOperations.cs @@ -29,11 +29,13 @@ internal class ToolOperations "Non-null continuation token returned by a previous call to this tool. Pass it back to fetch the next page, or omit this argument to start from the beginning. A null output token means the result is exhausted and no further call should be made. The value is opaque; do not modify it."; private readonly ILogger logger; + private readonly LocationResourceSubscriptions locationSubscriptions; private readonly Lazy> cachedTools; - public ToolOperations(ILogger logger) + public ToolOperations(ILogger logger, LocationResourceSubscriptions locationSubscriptions) { this.logger = logger; + this.locationSubscriptions = locationSubscriptions; this.cachedTools = new Lazy>( () => ShellInterpreter.Instance.App.Commands.Values .DistinctBy(c => c.CommandName) @@ -46,6 +48,22 @@ public ToolOperations(ILogger logger) public McpRequestHandler CallToolHandler => this.OnCallToolsAsync; + public McpRequestHandler SubscribeToResourcesHandler => this.SubscribeToResourcesAsync; + + public McpRequestHandler UnsubscribeFromResourcesHandler => this.UnsubscribeFromResourcesAsync; + + private ValueTask SubscribeToResourcesAsync(RequestContext context, CancellationToken cancellationToken) + { + this.locationSubscriptions.Subscribe(context.Server, context.Params?.Uri ?? string.Empty); + return ValueTask.FromResult(new EmptyResult()); + } + + private ValueTask UnsubscribeFromResourcesAsync(RequestContext context, CancellationToken cancellationToken) + { + this.locationSubscriptions.Unsubscribe(context.Server, context.Params?.Uri ?? string.Empty); + return ValueTask.FromResult(new EmptyResult()); + } + internal static Tool GetTool(CommandFactory command) { var descriptionParts = new[] { command.Description, command.McpDescription } diff --git a/README.md b/README.md index 719a1346..cee535eb 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ 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. MCP invocations are echoed in the shell so their activity stays visible, and they are recorded in history alongside interactive commands. 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; see [MCP location updates](docs/mcp.md#shell-location-updates). + ## Quick Start **Requirements:** .NET SDK 10.0+. diff --git a/docs/mcp.md b/docs/mcp.md index 94126d2e..4c9b87e2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -91,6 +91,14 @@ Database and container resource actions are executed through Azure Resource Mana For deterministic ARM routing in multi-subscription environments, start the shell with `--connect-subscription` and `--connect-resource-group`. +### Shell Location Updates + +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]`). Clients that support resource subscriptions can subscribe to this URI with `resources/subscribe` and 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 value; the notification itself contains only the URI. Rapid consecutive changes may be coalesced into a single notification. Unsubscribe with `resources/unsubscribe` when no longer needed. + +Only `cosmos://shell/current-location` supports subscriptions; subscribing to any other URI, including the documentation resources, returns an invalid-params error. + +This server uses the subscription protocol supported by its MCP SDK; clients must support subscriptions and server-to-client notifications over the HTTP connection. A notification does not guarantee that a client refreshes the model's context. Every tool response also includes `currentLocation`, and explicit `database` / `container` arguments remain the reliable way to target independent operations. + ### Data Exposure MCP tool invocations are echoed as command lines in the shell window, so anyone watching the terminal can see what a connected client is doing. They are also recorded in the shell history. History entries are complete and replayable, including any supplied connection strings. Protect the history file accordingly. On Linux and macOS, the shell restricts the history file to its owner. From 2a7424b815fe5329f77bfe6b23dd4313cc20ad82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Tue, 29 Sep 2026 13:48:47 +0200 Subject: [PATCH 2/4] Address review feedback on MCP location notifications - Dispose the port-probe TcpListener in the subscription test. - Limit the per-subscriber catch to the running state via an exception filter so a failed notification only drops that subscriber. - Cover client-identity change detection: a new client at the same location notifies; a changed ARM context with the same client does not. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../McpLocationSubscriptionTests.cs | 2 +- .../ShellLocationChangedTests.cs | 42 +++++++++++++++++++ .../LocationResourceSubscriptions.cs | 2 +- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs b/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs index 8f6b726b..124ae24a 100644 --- a/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs +++ b/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs @@ -21,7 +21,7 @@ public async Task SubscribedClient_ReceivesInteractiveLocationChange() { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); timeout.CancelAfter(TimeSpan.FromSeconds(10)); - var listener = new TcpListener(IPAddress.Loopback, 0); + using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); diff --git a/CosmosDBShell.Tests/ShellLocationChangedTests.cs b/CosmosDBShell.Tests/ShellLocationChangedTests.cs index e3bd10ce..4bafbb2c 100644 --- a/CosmosDBShell.Tests/ShellLocationChangedTests.cs +++ b/CosmosDBShell.Tests/ShellLocationChangedTests.cs @@ -4,8 +4,10 @@ namespace CosmosShell.Tests; +using System.Runtime.CompilerServices; using Azure.Data.Cosmos.Shell.Core; using Azure.Data.Cosmos.Shell.States; +using Microsoft.Azure.Cosmos; public class ShellLocationChangedTests { @@ -34,4 +36,44 @@ public void State_NotifiesOnlyWhenLocationChanges() shell.State = new DisconnectedState(); Assert.Equal(4, changes); } + + [Fact] + public void State_NotifiesWhenClientChangesAtSameLocation() + { + using var shell = new ShellInterpreter(); + using var firstClient = CreateTestClient(); + using var secondClient = CreateTestClient(); + shell.State = new DatabaseState("db", firstClient); + var changes = 0; + shell.LocationChanged += () => changes++; + + shell.State = new DatabaseState("db", secondClient); + Assert.Equal(1, changes); + + shell.State = new DisconnectedState(); + } + + [Fact] + public void State_DoesNotNotifyWhenOnlyArmContextChanges() + { + using var shell = new ShellInterpreter(); + using var client = CreateTestClient(); + shell.State = new ConnectedState(client); + var changes = 0; + shell.LocationChanged += () => changes++; + + var armContext = (ArmCosmosContext)RuntimeHelpers.GetUninitializedObject(typeof(ArmCosmosContext)); + shell.State = new ConnectedState(client, armContext); + Assert.Equal(0, changes); + + shell.State = new DisconnectedState(); + } + + private static CosmosClient CreateTestClient() + { + return new CosmosClient( + "https://localhost:8081", + Convert.ToBase64String(new byte[64]), + new CosmosClientOptions { ConnectionMode = ConnectionMode.Gateway }); + } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/LocationResourceSubscriptions.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/LocationResourceSubscriptions.cs index 1d68164c..5759d729 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/LocationResourceSubscriptions.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/LocationResourceSubscriptions.cs @@ -79,7 +79,7 @@ await server.SendNotificationAsync( { return; } - catch (Exception ex) + catch (Exception ex) when (!stoppingToken.IsCancellationRequested) { this.logger.LogWarning(ex, "Could not notify an MCP client about the shell location change."); lock (this.sync) From e276d52de3772f3a53618271bf293f82ee20e00f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Wed, 30 Sep 2026 15:08:39 +0200 Subject: [PATCH 3/4] Include account endpoint in MCP location resource Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../McpLocationSubscriptionTests.cs | 8 +++- .../ResourceOperationsTests.cs | 37 +++++++++++++++++-- .../ResourceOperations.cs | 3 +- .../ServerInstructions.md | 2 +- README.md | 2 +- docs/mcp.md | 2 +- 6 files changed, 46 insertions(+), 8 deletions(-) diff --git a/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs b/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs index 124ae24a..2115838c 100644 --- a/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs +++ b/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs @@ -10,6 +10,7 @@ namespace CosmosShell.Tests; using Azure.Data.Cosmos.Shell.Core; using Azure.Data.Cosmos.Shell.Mcp; using Azure.Data.Cosmos.Shell.States; +using Microsoft.Azure.Cosmos; using ModelContextProtocol; using ModelContextProtocol.Client; @@ -54,15 +55,20 @@ public async Task SubscribedClient_ReceivesInteractiveLocationChange() cancellationToken: timeout.Token); var originalState = ShellInterpreter.Instance.State; + using var cosmosClient = new CosmosClient( + "https://localhost:8081", + Convert.ToBase64String(new byte[64]), + new CosmosClientOptions { ConnectionMode = ConnectionMode.Gateway }); try { - ShellInterpreter.Instance.State = new DatabaseState("McpNotificationTest", null!); + ShellInterpreter.Instance.State = new DatabaseState("McpNotificationTest", cosmosClient); Assert.Equal(ResourceOperations.CurrentLocationUri, await updated.Task.WaitAsync(timeout.Token)); var resource = await client.ReadResourceAsync(ResourceOperations.CurrentLocationUri, cancellationToken: timeout.Token); var content = Assert.Single(resource.Contents); using var json = JsonDocument.Parse(Assert.IsType(content).Text); Assert.Equal("/McpNotificationTest", json.RootElement.GetProperty("currentLocation").GetString()); + Assert.Equal(cosmosClient.Endpoint.ToString(), json.RootElement.GetProperty("currentAccountEndpoint").GetString()); } finally { diff --git a/CosmosDBShell.Tests/ResourceOperationsTests.cs b/CosmosDBShell.Tests/ResourceOperationsTests.cs index d82d83cd..0445c060 100644 --- a/CosmosDBShell.Tests/ResourceOperationsTests.cs +++ b/CosmosDBShell.Tests/ResourceOperationsTests.cs @@ -7,6 +7,7 @@ namespace CosmosShell.Tests; using System.Text.Json; using Azure.Data.Cosmos.Shell.Mcp; using Azure.Data.Cosmos.Shell.States; +using Microsoft.Azure.Cosmos; public class ResourceOperationsTests { @@ -17,17 +18,47 @@ public class ResourceOperationsTests [InlineData("db", "container", "/db/container")] public void GetCurrentLocation_ReturnsSharedLocationAsJson(string? database, string? container, string? expected) { + using var client = database is null + ? null + : new CosmosClient( + "https://localhost:8081", + Convert.ToBase64String(new byte[64]), + new CosmosClientOptions { ConnectionMode = ConnectionMode.Gateway }); var state = database is null ? (State)new DisconnectedState() : database.Length == 0 - ? new ConnectedState(null!) + ? new ConnectedState(client!) : container is null - ? new DatabaseState(database, null!) - : new ContainerState(container, database, null!); + ? new DatabaseState(database, client!) + : new ContainerState(container, database, client!); using var document = JsonDocument.Parse(ResourceOperations.GetCurrentLocation(state)); var location = document.RootElement.GetProperty("currentLocation"); Assert.Equal(expected, location.ValueKind == JsonValueKind.Null ? null : location.GetString()); + var endpoint = document.RootElement.GetProperty("currentAccountEndpoint"); + Assert.Equal(client?.Endpoint.ToString(), endpoint.ValueKind == JsonValueKind.Null ? null : endpoint.GetString()); + } + + [Fact] + public void GetCurrentLocation_ReflectsAccountChangeWithoutNavigationChange() + { + var key = Convert.ToBase64String(new byte[64]); + using var first = new CosmosClient( + "https://first.documents.azure.com:443", + key, + new CosmosClientOptions { ConnectionMode = ConnectionMode.Gateway }); + using var second = new CosmosClient( + "https://second.documents.azure.com:443", + key, + new CosmosClientOptions { ConnectionMode = ConnectionMode.Gateway }); + + using var before = JsonDocument.Parse(ResourceOperations.GetCurrentLocation(new DatabaseState("db", first))); + using var after = JsonDocument.Parse(ResourceOperations.GetCurrentLocation(new DatabaseState("db", second))); + + Assert.Equal("/db", before.RootElement.GetProperty("currentLocation").GetString()); + Assert.Equal("/db", after.RootElement.GetProperty("currentLocation").GetString()); + Assert.Equal(first.Endpoint.ToString(), before.RootElement.GetProperty("currentAccountEndpoint").GetString()); + Assert.Equal(second.Endpoint.ToString(), after.RootElement.GetProperty("currentAccountEndpoint").GetString()); } [Fact] diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ResourceOperations.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ResourceOperations.cs index ad276bbb..e28ee4e5 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ResourceOperations.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ResourceOperations.cs @@ -28,7 +28,7 @@ internal class ResourceOperations Name = "cosmos-shell-current-location", Title = "Current Cosmos Shell Location", MimeType = "application/json")] - [Description("Current shared shell navigation location. Subscribe to this resource for changes made in the interactive shell or by MCP clients. Read it again after an update notification.")] + [Description("Current shared shell navigation location and account endpoint. Subscribe to this resource for changes made in the interactive shell or by MCP clients. Read it again after an update notification.")] public static string GetCurrentLocation() { return GetCurrentLocation(ShellInterpreter.Instance.State); @@ -39,6 +39,7 @@ internal static string GetCurrentLocation(State state) return JsonSerializer.Serialize(new { currentLocation = ShellLocation.GetCurrentLocation(state), + currentAccountEndpoint = state is ConnectedState connected ? connected.Client.Endpoint.ToString() : null, }); } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ServerInstructions.md b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ServerInstructions.md index 1c742a55..e5603ba9 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ServerInstructions.md +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ServerInstructions.md @@ -21,7 +21,7 @@ NAVIGATION: - The shell models Cosmos DB as a folder-like hierarchy: Account → Databases → Containers → Items. - Treat navigation state as convenience only. When a command supports --db and --con, prefer passing them explicitly instead of relying on prior 'cd' calls. - Reuse the currentLocation field returned by MCP responses for awareness, but still prefer explicit --db and --con on follow-up tool calls. -- Clients that support resource subscriptions can subscribe to `cosmos://shell/current-location`. Read it again after an update notification to learn about interactive shell navigation, connection, or MCP navigation changes. +- Clients that support resource subscriptions can subscribe to `cosmos://shell/current-location`. Read its `currentLocation` and `currentAccountEndpoint` fields again after an update notification to learn about interactive shell navigation, connection, or MCP navigation changes. - Use `cd [name]` to enter a database or container, `cd ..` to go up one level, and `cd` to return to the root. - Path chaining is supported: 'cd MyDatabase/MyContainer' navigates multiple levels at once. - Use 'ls' at any level to list resources (databases, containers, or items depending on context). diff --git a/README.md b/README.md index cee535eb..8cbea8e3 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,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. MCP invocations are echoed in the shell so their activity stays visible, and they are recorded in history alongside interactive commands. 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; 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. See [MCP location updates](docs/mcp.md#shell-location-updates). ## Quick Start diff --git a/docs/mcp.md b/docs/mcp.md index 4c9b87e2..6befdce4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -93,7 +93,7 @@ For deterministic ARM routing in multi-subscription environments, start the shel ### Shell Location Updates -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]`). Clients that support resource subscriptions can subscribe to this URI with `resources/subscribe` and 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 value; the notification itself contains only the URI. Rapid consecutive changes may be coalesced into a single notification. Unsubscribe with `resources/unsubscribe` when no longer needed. +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 can subscribe to this URI with `resources/subscribe` and 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. Unsubscribe with `resources/unsubscribe` when no longer needed. Only `cosmos://shell/current-location` supports subscriptions; subscribing to any other URI, including the documentation resources, returns an invalid-params error. From 132f7e4fe03c2b4b35bb5f5817af5379ba727381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Wed, 30 Sep 2026 15:53:28 +0200 Subject: [PATCH 4/4] Remove location subscriptions when the notification stream ends MCP SDK 1.1.0 accepts a single notification (GET) stream per session and swallows write failures on it, so a failed notification never surfaced and stale subscribers were kept until the session idled out. Once that stream ends, the session cannot receive notifications again, so drop its subscriptions when the GET request completes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../McpLocationSubscriptionTests.cs | 88 ++++++++++++++++++- .../LocationResourceSubscriptions.cs | 21 +++++ .../Azure.Data.Cosmos.Shell.Mcp/McpServer.cs | 24 +++++ docs/mcp.md | 2 +- 4 files changed, 130 insertions(+), 5 deletions(-) diff --git a/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs b/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs index 2115838c..8e8ac0ea 100644 --- a/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs +++ b/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs @@ -11,6 +11,7 @@ namespace CosmosShell.Tests; using Azure.Data.Cosmos.Shell.Mcp; using Azure.Data.Cosmos.Shell.States; using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol; using ModelContextProtocol.Client; @@ -22,10 +23,7 @@ public async Task SubscribedClient_ReceivesInteractiveLocationChange() { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); timeout.CancelAfter(TimeSpan.FromSeconds(10)); - using var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - listener.Stop(); + var port = GetFreePort(); using var host = McpServer.CreateHost(new Program.CosmosShellOptions { McpPort = port }); await host.StartAsync(timeout.Token); @@ -80,4 +78,86 @@ public async Task SubscribedClient_ReceivesInteractiveLocationChange() await host.StopAsync(TestContext.Current.CancellationToken); } } + + [Fact] + public async Task ClosedNotificationStream_RemovesSubscriptionWhileSessionStaysOpen() + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(10)); + var port = GetFreePort(); + + using var host = McpServer.CreateHost(new Program.CosmosShellOptions { McpPort = port }); + await host.StartAsync(timeout.Token); + try + { + var subscriptions = host.Services.GetRequiredService(); + using var http = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}/") }; + + using var initialize = await PostAsync( + http, + sessionId: null, + """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}""", + timeout.Token); + var sessionId = Assert.Single(initialize.Headers.GetValues("Mcp-Session-Id")); + using (await PostAsync(http, sessionId, """{"jsonrpc":"2.0","method":"notifications/initialized"}""", timeout.Token)) + { + } + + using var streamRequest = new HttpRequestMessage(HttpMethod.Get, string.Empty); + streamRequest.Headers.Add("Accept", "text/event-stream"); + streamRequest.Headers.Add("Mcp-Session-Id", sessionId); + var notificationStream = await http.SendAsync(streamRequest, HttpCompletionOption.ResponseHeadersRead, timeout.Token); + Assert.True(notificationStream.IsSuccessStatusCode); + + using (var subscribe = await PostAsync( + http, + sessionId, + $$$"""{"jsonrpc":"2.0","id":2,"method":"resources/subscribe","params":{"uri":"{{{ResourceOperations.CurrentLocationUri}}}"}}""", + timeout.Token)) + { + Assert.True(subscribe.IsSuccessStatusCode); + } + + Assert.Equal(1, subscriptions.SubscriberCount); + + notificationStream.Dispose(); + while (subscriptions.SubscriberCount != 0) + { + await Task.Delay(50, timeout.Token); + } + + using var ping = await PostAsync(http, sessionId, """{"jsonrpc":"2.0","id":3,"method":"ping"}""", timeout.Token); + Assert.True(ping.IsSuccessStatusCode); + } + finally + { + await host.StopAsync(TestContext.Current.CancellationToken); + } + } + + private static int GetFreePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private static async Task PostAsync(HttpClient http, string? sessionId, string json, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Post, string.Empty) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"), + }; + request.Headers.Add("Accept", "application/json, text/event-stream"); + if (sessionId != null) + { + request.Headers.Add("Mcp-Session-Id", sessionId); + } + + var response = await http.SendAsync(request, cancellationToken); + await response.Content.LoadIntoBufferAsync(cancellationToken); + return response; + } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/LocationResourceSubscriptions.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/LocationResourceSubscriptions.cs index 5759d729..bdb91d3d 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/LocationResourceSubscriptions.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/LocationResourceSubscriptions.cs @@ -30,6 +30,18 @@ public LocationResourceSubscriptions(ILogger logg ShellInterpreter.Instance.LocationChanged += this.OnLocationChanged; } + internal int SubscriberCount + { + get + { + lock (this.sync) + { + this.PruneSubscribers(); + return this.subscribers.Count; + } + } + } + public void Subscribe(ModelContextProtocol.Server.McpServer server, string uri) { ValidateUri(uri); @@ -52,6 +64,15 @@ public void Unsubscribe(ModelContextProtocol.Server.McpServer server, string uri } } + public void RemoveSession(string sessionId) + { + lock (this.sync) + { + this.subscribers.RemoveAll(reference => + !reference.TryGetTarget(out var target) || string.Equals(target.SessionId, sessionId, StringComparison.Ordinal)); + } + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await foreach (var change in this.changes.Reader.ReadAllAsync(stoppingToken)) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpServer.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpServer.cs index 87f464e3..f9dfeee5 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpServer.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpServer.cs @@ -10,6 +10,7 @@ namespace Azure.Data.Cosmos.Shell.Mcp; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -26,6 +27,8 @@ namespace Azure.Data.Cosmos.Shell.Mcp; /// internal class McpServer { + private const string McpSessionIdHeaderName = "Mcp-Session-Id"; + public static IHost CreateHost(CosmosShellOptions serverArguments) { var builder = WebApplication.CreateBuilder([]); @@ -43,10 +46,31 @@ public static IHost CreateHost(CosmosShellOptions serverArguments) }); var application = builder.Build(); application.UseOriginValidation(); + application.Use(RemoveLocationSubscriptionsWhenNotificationStreamEndsAsync); application.MapMcp(); return application; } + // The SDK accepts one notification stream (GET) per session and swallows write failures on it, + // so once that request ends the session can no longer receive location updates. + private static async Task RemoveLocationSubscriptionsWhenNotificationStreamEndsAsync(HttpContext context, RequestDelegate next) + { + var sessionId = HttpMethods.IsGet(context.Request.Method) + ? context.Request.Headers[McpSessionIdHeaderName].ToString() + : string.Empty; + try + { + await next(context); + } + finally + { + if (!string.IsNullOrEmpty(sessionId)) + { + context.RequestServices.GetRequiredService().RemoveSession(sessionId); + } + } + } + private static void ConfigureMcpServer(IServiceCollection services) { services.AddSingleton(); diff --git a/docs/mcp.md b/docs/mcp.md index 6befdce4..46ce7c1e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -93,7 +93,7 @@ For deterministic ARM routing in multi-subscription environments, start the shel ### Shell Location Updates -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 can subscribe to this URI with `resources/subscribe` and 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. Unsubscribe with `resources/unsubscribe` when no longer needed. +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 can subscribe to this URI with `resources/subscribe` and 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. Unsubscribe with `resources/unsubscribe` when no longer needed. A subscription also ends when the client's notification stream (the HTTP `GET` stream) closes or the session ends; subscribe again in a new session. Only `cosmos://shell/current-location` supports subscriptions; subscribing to any other URI, including the documentation resources, returns an invalid-params error.