diff --git a/docs/for-coding-agents.md b/docs/for-coding-agents.md index 57964c1e..087d07da 100644 --- a/docs/for-coding-agents.md +++ b/docs/for-coding-agents.md @@ -142,7 +142,7 @@ Use these annotations to help agents make safer decisions: | `.Destructive()` | May delete or mutate important state; ask for confirmation. | | `.Idempotent()` | Safe to retry. | | `.OpenWorld()` | Talks to external systems; expect latency and failures. | -| `.LongRunning()` | May take time; use call-now / poll-later patterns. | +| `.LongRunning()` | May take time. Documentation hint for now — no protocol-level task advertisement until Repl integrates the SDK Tasks extension. | | `.AutomationHidden()` | Do not expose this command to MCP automation. | Unannotated tools force agents to assume the worst. Annotate every command that will be visible through MCP. diff --git a/docs/mcp-advanced.md b/docs/mcp-advanced.md index f82ebdd9..90ea3f62 100644 --- a/docs/mcp-advanced.md +++ b/docs/mcp-advanced.md @@ -20,6 +20,13 @@ If your tool list is static, stay with the default setup from [mcp-overview.md]( ## Client roots +> **⚠️ Deprecation notice (SEP-2577):** the MCP specification (2026-07-28) deprecates the +> Roots feature, and the SDK may remove it in a future version. Repl keeps supporting it +> **for existing hosts and applications only** — new applications should not build on +> native MCP roots and can use [soft roots](#soft-roots-fallback) or explicit command +> parameters instead. See +> [mcp-reference.md](mcp-reference.md#sdk-and-protocol-versions) for the version posture. + A **root** is a URI the client declares as being in scope for the session — typically an opened project folder, a working directory, or a boundary for what the agent should inspect or modify. Roots give the server session-specific workspace context without inventing a custom protocol. When the client supports native MCP roots, `Repl.Mcp` exposes them through `IMcpClientRoots`. diff --git a/docs/mcp-agent-capabilities.md b/docs/mcp-agent-capabilities.md index cac9ce4e..f4a1e8c4 100644 --- a/docs/mcp-agent-capabilities.md +++ b/docs/mcp-agent-capabilities.md @@ -8,6 +8,14 @@ See also: [sample 08-mcp-server](../samples/08-mcp-server/) for a working example that uses all three in a CSV import and feedback workflow. +> **⚠️ Deprecation notice (SEP-2577):** the MCP specification (2026-07-28) deprecates the +> Sampling and Logging features that `IMcpSampling` and `IMcpFeedback` build on, and the +> SDK may remove them in a future version. Repl keeps supporting them **for existing hosts +> and applications only** — new applications should not adopt these interfaces directly and +> should prefer the portable `IReplInteractionChannel`, which degrades gracefully across +> CLI, REPL, hosted sessions, and MCP. See +> [mcp-reference.md](mcp-reference.md#sdk-and-protocol-versions) for the version posture. + ## Overview Repl provides three MCP-oriented injectable interfaces: diff --git a/docs/mcp-overview.md b/docs/mcp-overview.md index 3cdee8f6..0982bcdd 100644 --- a/docs/mcp-overview.md +++ b/docs/mcp-overview.md @@ -63,7 +63,7 @@ app.Map("deploy", handler).Destructive().LongRunning().OpenWorld(); | `.Destructive()` | Ask user for confirmation, sequential | | `.Idempotent()` | Safe to retry, can parallelize | | `.OpenWorld()` | Reaches external systems — expect latency and transient failures | -| `.LongRunning()` | Enables call-now/poll-later pattern | +| `.LongRunning()` | Slow-operation hint (protocol-level task advertisement returns once Repl integrates the SDK Tasks extension — see [mcp-reference.md](mcp-reference.md#sdk-and-protocol-versions)) | | `.AutomationHidden()` | Not visible to agents | **Annotate every command exposed to agents.** Unannotated tools force agents to assume the worst: confirm everything, no parallelism, no retries. diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md index 5190316b..a4f33024 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -510,6 +510,12 @@ side-channel command output and are not included in `resources/read` bodies. Feature support varies across agents. Check [mcp-availability.com](https://mcp-availability.com/) for current data. +### SDK and protocol versions + +- Repl.Mcp builds on the official C# SDK (`ModelContextProtocol`), currently on the **2.0 line** (`2.0.0-preview.3`). The SDK negotiates the protocol version with each client, including fallback to the legacy `initialize` handshake for older hosts. +- **Roots, Sampling, and Logging** are deprecated by MCP specification 2026-07-28 (SEP-2577). Repl.Mcp keeps supporting them **for existing hosts and applications only** — new applications should not adopt these features (the SDK may remove them) and should prefer Repl's portable abstractions such as `IReplInteractionChannel`. The designated successor for server-initiated flows (SEP-2322, multi-round-trip requests) ships experimentally in the SDK 2.0 line; Repl has not adopted it yet. +- **MCP Tasks**: the SDK reorganized Tasks into `ModelContextProtocol.Extensions.Tasks` and dropped the per-tool execution augmentation (`Tool.Execution`) from the protocol surface, so `.LongRunning()` commands no longer advertise task support at the protocol level. The annotation stays in Repl's own model (help/docs); protocol-level task support can return once Repl integrates the Tasks extension, store, and get/update/cancel lifecycle (tracked in issue #72). + | Feature | Claude Desktop | Claude Code | Codex | VS Code Copilot | Cursor | Continue | |---|---|---|---|---|---|---| | Tools | Yes | Yes | Yes | Yes | Yes | Yes | diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 1aa0edeb..77d7b240 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -14,7 +14,7 @@ - + diff --git a/src/Repl.Core/CommandAnnotations.cs b/src/Repl.Core/CommandAnnotations.cs index 9b7a5dbf..8c66dad0 100644 --- a/src/Repl.Core/CommandAnnotations.cs +++ b/src/Repl.Core/CommandAnnotations.cs @@ -31,8 +31,9 @@ public sealed record CommandAnnotations public bool OpenWorld { get; init; } /// - /// Indicates the command may take a long time to complete. - /// Enables task-based execution in programmatic clients. + /// Indicates the command may take a long time to complete, so programmatic clients + /// should expect a slow call. Protocol-level task-based execution (MCP Tasks) is not + /// advertised until Repl integrates the SDK's Tasks extension. /// public bool LongRunning { get; init; } diff --git a/src/Repl.Mcp/IMcpFeedback.cs b/src/Repl.Mcp/IMcpFeedback.cs index 3fff6a16..2deea2e0 100644 --- a/src/Repl.Mcp/IMcpFeedback.cs +++ b/src/Repl.Mcp/IMcpFeedback.cs @@ -1,6 +1,13 @@ using ModelContextProtocol.Protocol; using Repl.Interaction; +// Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK +// diagnostic MCP9005); the designated successor for server-initiated flows (SEP-2322, +// multi-round-trip requests, shipped experimentally in SDK 2.0 as MrtrContext/MrtrExchange) +// is not adopted by Repl yet, and hosts still rely on these features, so Repl keeps +// supporting them until the SDK removes the surface (#51). +#pragma warning disable MCP9005 + namespace Repl.Mcp; /// diff --git a/src/Repl.Mcp/McpClientRootsService.cs b/src/Repl.Mcp/McpClientRootsService.cs index fa53d4e3..d0d404e3 100644 --- a/src/Repl.Mcp/McpClientRootsService.cs +++ b/src/Repl.Mcp/McpClientRootsService.cs @@ -1,24 +1,38 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +// Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK +// diagnostic MCP9005); the designated successor for server-initiated flows (SEP-2322, +// multi-round-trip requests, shipped experimentally in SDK 2.0 as MrtrContext/MrtrExchange) +// is not adopted by Repl yet, and hosts still rely on these features, so Repl keeps +// supporting them until the SDK removes the surface (#51). +#pragma warning disable MCP9005 + namespace Repl.Mcp; +// One instance PER SESSION (owned by McpSessionContext): hard roots, soft roots, and +// their cache/version state are session state — one handler can serve several sessions, +// and serving session A's roots to session B would expose A's workspace URIs and build +// B's root-dependent snapshot from the wrong workspace. Outbound transport still goes +// through the request-bound accessor (the destination is per request, finer than the +// session). internal sealed class McpClientRootsService : IMcpClientRoots { private readonly ICoreReplApp _app; + private readonly McpRequestServerAccessor _servers; private readonly Lock _syncRoot = new(); - private McpServer? _server; private McpClientRoot[] _hardRoots = []; private McpClientRoot[] _softRoots = []; private bool _hardRootsLoaded; private long _hardRootsVersion; - public McpClientRootsService(ICoreReplApp app) + public McpClientRootsService(ICoreReplApp app, McpRequestServerAccessor servers) { _app = app; + _servers = servers; } - public bool IsSupported => _server?.ClientCapabilities?.Roots is not null; + public bool IsSupported => _servers.Effective?.ClientCapabilities?.Roots is not null; public bool HasSoftRoots { @@ -42,15 +56,11 @@ public IReadOnlyList Current } } - public void AttachServer(McpServer server) - { - ArgumentNullException.ThrowIfNull(server); - _server = server; - } - public async ValueTask> GetAsync(CancellationToken cancellationToken = default) { - var server = _server; + // Single read: the effective server must not change between the support check and + // the roots request (a concurrent request re-binding the accessor must not be observed). + var server = _servers.Effective; if (server?.ClientCapabilities?.Roots is null) { return Current; diff --git a/src/Repl.Mcp/McpElicitationService.cs b/src/Repl.Mcp/McpElicitationService.cs index 12de5219..40a57af9 100644 --- a/src/Repl.Mcp/McpElicitationService.cs +++ b/src/Repl.Mcp/McpElicitationService.cs @@ -15,13 +15,11 @@ namespace Repl.Mcp; /// a multi-field variant would build the with /// multiple properties instead of one. /// -internal sealed class McpElicitationService : IMcpElicitation +internal sealed class McpElicitationService(McpRequestServerAccessor servers) : IMcpElicitation { private const string FieldName = "value"; - private McpServer? _server; - - public bool IsSupported => _server?.ClientCapabilities?.Elicitation is not null; + public bool IsSupported => servers.Effective?.ClientCapabilities?.Elicitation is not null; public async ValueTask ElicitTextAsync( string message, @@ -99,19 +97,19 @@ internal sealed class McpElicitationService : IMcpElicitation : null; } - internal void AttachServer(McpServer server) => _server = server; - private async ValueTask ElicitSingleFieldAsync( string message, ElicitRequestParams.PrimitiveSchemaDefinition schema, CancellationToken cancellationToken) { - if (!IsSupported) + // Single read: the effective server must not change between the support check and + // the call (a concurrent request re-binding the accessor must not be observed). + if (servers.Effective is not { ClientCapabilities.Elicitation: not null } server) { return null; } - var result = await _server!.ElicitAsync( + var result = await server.ElicitAsync( new ElicitRequestParams { Message = message, diff --git a/src/Repl.Mcp/McpFeedbackService.cs b/src/Repl.Mcp/McpFeedbackService.cs index 92a3a2ac..9486d742 100644 --- a/src/Repl.Mcp/McpFeedbackService.cs +++ b/src/Repl.Mcp/McpFeedbackService.cs @@ -5,24 +5,27 @@ using ModelContextProtocol.Server; using Repl.Interaction; +// Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK +// diagnostic MCP9005); the designated successor for server-initiated flows (SEP-2322, +// multi-round-trip requests, shipped experimentally in SDK 2.0 as MrtrContext/MrtrExchange) +// is not adopted by Repl yet, and hosts still rely on these features, so Repl keeps +// supporting them until the SDK removes the surface (#51). +#pragma warning disable MCP9005 + namespace Repl.Mcp; /// /// Internal implementation of backed by a live session. /// -internal sealed class McpFeedbackService : IMcpFeedback +internal sealed class McpFeedbackService(McpRequestServerAccessor servers) : IMcpFeedback { private const string LoggerName = "repl.interaction"; private readonly AsyncLocal _progressToken = new(); - // This service is created per McpServerHandler/session and overlaid into the - // per-connection service provider, so the attached server reference is not shared - // across concurrent MCP connections. - private McpServer? _server; - public bool IsProgressSupported => _server is not null && _progressToken.Value is not null; + public bool IsProgressSupported => servers.Effective is not null && _progressToken.Value is not null; - public bool IsLoggingSupported => _server is not null; + public bool IsLoggingSupported => servers.Effective is not null; public async ValueTask ReportProgressAsync( ReplProgressEvent progress, @@ -30,13 +33,17 @@ public async ValueTask ReportProgressAsync( { ArgumentNullException.ThrowIfNull(progress); - if (!IsProgressSupported || progress.State == ReplProgressState.Clear || _progressToken.Value is not { } progressToken) + // Single read: the effective server must not change between the support check and + // the send (a concurrent request re-binding the accessor must not be observed). + if (servers.Effective is not { } server + || progress.State == ReplProgressState.Clear + || _progressToken.Value is not { } progressToken) { return; } var percent = progress.ResolvePercent(); - await _server!.NotifyProgressAsync( + await server.NotifyProgressAsync( progressToken, new ProgressNotificationValue { @@ -52,12 +59,12 @@ public async ValueTask SendMessageAsync( object? data, CancellationToken cancellationToken = default) { - if (!IsLoggingSupported) + if (servers.Effective is not { } server) { return; } - await _server!.SendNotificationAsync( + await server.SendNotificationAsync( NotificationMethods.LoggingMessageNotification, new LoggingMessageNotificationParams { @@ -68,7 +75,6 @@ public async ValueTask SendMessageAsync( cancellationToken: cancellationToken).ConfigureAwait(false); } - internal void AttachServer(McpServer server) => _server = server; internal IDisposable PushProgressToken(ProgressToken? progressToken) => new ProgressTokenScope(_progressToken, progressToken); diff --git a/src/Repl.Mcp/McpInteractionChannel.cs b/src/Repl.Mcp/McpInteractionChannel.cs index 1d8ea662..5e8fc528 100644 --- a/src/Repl.Mcp/McpInteractionChannel.cs +++ b/src/Repl.Mcp/McpInteractionChannel.cs @@ -5,6 +5,13 @@ using ModelContextProtocol.Server; using Repl.Interaction; +// Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK +// diagnostic MCP9005); the designated successor for server-initiated flows (SEP-2322, +// multi-round-trip requests, shipped experimentally in SDK 2.0 as MrtrContext/MrtrExchange) +// is not adopted by Repl yet, and hosts still rely on these features, so Repl keeps +// these features, so Repl keeps supporting them until the SDK removes the surface (#51). +#pragma warning disable MCP9005 + namespace Repl.Mcp; /// diff --git a/src/Repl.Mcp/McpRequestServerAccessor.cs b/src/Repl.Mcp/McpRequestServerAccessor.cs new file mode 100644 index 00000000..be138772 --- /dev/null +++ b/src/Repl.Mcp/McpRequestServerAccessor.cs @@ -0,0 +1,31 @@ +using ModelContextProtocol.Server; + +namespace Repl.Mcp; + +/// +/// Resolves the a capability call must target. +/// +/// +/// SDK 2.0's 2026-07-28 protocol path hands each request a destination-bound +/// , and one handler can serve several sessions. The capability +/// services are singletons (exposed through DI to command handlers), so the effective +/// server must be the one bound to the FLOWING request — a shared mutable field would be +/// overwritten by whichever request attached last, cross-wiring capabilities between +/// concurrent calls. flows with the invocation and cannot +/// leak across requests; the session-level server remains the fallback for code running +/// outside a request (e.g. routing-change notifications). +/// +internal sealed class McpRequestServerAccessor +{ + private readonly AsyncLocal _current = new(); + private McpServer? _session; + + /// Server for the flowing request, falling back to the session server. + public McpServer? Effective => _current.Value ?? _session; + + /// Binds the flowing async context to the request's destination server. + public void BindRequest(McpServer server) => _current.Value = server; + + /// Records the session-level server used outside request flows (null when the last session ends). + public void AttachSession(McpServer? server) => _session = server; +} diff --git a/src/Repl.Mcp/McpSamplingService.cs b/src/Repl.Mcp/McpSamplingService.cs index 5e6a09e0..8ec4e238 100644 --- a/src/Repl.Mcp/McpSamplingService.cs +++ b/src/Repl.Mcp/McpSamplingService.cs @@ -1,28 +1,35 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +// Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK +// diagnostic MCP9005); the designated successor for server-initiated flows (SEP-2322, +// multi-round-trip requests, shipped experimentally in SDK 2.0 as MrtrContext/MrtrExchange) +// is not adopted by Repl yet, and hosts still rely on these features, so Repl keeps +// these features, so Repl keeps supporting them until the SDK removes the surface (#51). +#pragma warning disable MCP9005 + namespace Repl.Mcp; /// /// Internal implementation of backed by a live session. /// -internal sealed class McpSamplingService : IMcpSampling +internal sealed class McpSamplingService(McpRequestServerAccessor servers) : IMcpSampling { - private McpServer? _server; - - public bool IsSupported => _server?.ClientCapabilities?.Sampling is not null; + public bool IsSupported => servers.Effective?.ClientCapabilities?.Sampling is not null; public async ValueTask SampleAsync( string prompt, int maxTokens = 1024, CancellationToken cancellationToken = default) { - if (!IsSupported) + // Single read: the effective server must not change between the support check and + // the call (a concurrent request re-binding the accessor must not be observed). + if (servers.Effective is not { ClientCapabilities.Sampling: not null } server) { return null; } - var result = await _server!.SampleAsync( + var result = await server.SampleAsync( new CreateMessageRequestParams { Messages = @@ -40,5 +47,4 @@ internal sealed class McpSamplingService : IMcpSampling return result.Content?.OfType().FirstOrDefault()?.Text; } - internal void AttachServer(McpServer server) => _server = server; } diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 2ff84ebe..c80a0133 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -25,23 +25,27 @@ internal sealed class McpServerHandler private readonly IServiceProvider _services; private readonly TimeProvider _timeProvider; private readonly char _separator; - private readonly McpClientRootsService _roots; + private readonly McpRequestServerAccessor _requestServers = new(); private readonly McpSamplingService _sampling; private readonly McpElicitationService _elicitation; private readonly McpFeedbackService _feedback; - private readonly IServiceProvider _sessionServices; - private readonly SemaphoreSlim _snapshotGate = new(initialCount: 1, maxCount: 1); private readonly Lock _refreshLock = new(); private readonly Lock _attachLock = new(); - private McpGeneratedSnapshot? _snapshot; + // Global routing version: bumped by InvalidateRouting for every session; each session's + // context caches the snapshot it built at a given version. private long _snapshotVersion = 1; - private long _builtSnapshotVersion; - private McpServer? _server; + // One handler can serve several concurrent sessions; everything session-owned lives in + // McpSessionContext, and this list (guarded by _attachLock) tracks every ACTIVE session + // for server-initiated notifications and subscription lifetime. + private readonly List _sessions = []; + // Lazy single context for externally hosted servers (options built via + // BuildDynamicServerOptions and run by the host without RunAsync): those servers carry + // the HOST's provider, so requests cannot recover a per-session context from it — they + // share one explicit fallback context instead of racing a last-attached field. + private McpSessionContext? _externalContext; private EventHandler? _routingChangedHandler; private ITimer? _debounceTimer; - private int _rootsNotificationRegistered; - private int _compatibilityIntroServed; private static readonly TimeSpan DebounceDelay = TimeSpan.FromMilliseconds(100); public McpServerHandler( @@ -54,19 +58,61 @@ public McpServerHandler( _services = services; _timeProvider = services.GetService(typeof(TimeProvider)) as TimeProvider ?? TimeProvider.System; _separator = McpToolNameFlattener.ResolveSeparator(options.ToolNamingSeparator); - _roots = new McpClientRootsService(app); - _sampling = new McpSamplingService(); - _elicitation = new McpElicitationService(); - _feedback = new McpFeedbackService(); - _sessionServices = new McpServiceProviderOverlay( - services, - new Dictionary - { - [typeof(IMcpClientRoots)] = _roots, - [typeof(IMcpSampling)] = _sampling, - [typeof(IMcpElicitation)] = _elicitation, - [typeof(IMcpFeedback)] = _feedback, - }); + // Sampling/elicitation/feedback are stateless (they resolve the request-bound server + // through the accessor) and safely shared; roots and everything else session-owned + // is created per session in CreateSessionContext. + _sampling = new McpSamplingService(_requestServers); + _elicitation = new McpElicitationService(_requestServers); + _feedback = new McpFeedbackService(_requestServers); + } + + private McpSessionContext CreateSessionContext() + { + var roots = new McpClientRootsService(_app, _requestServers); + var overlayServices = new Dictionary + { + [typeof(IMcpClientRoots)] = roots, + [typeof(IMcpSampling)] = _sampling, + [typeof(IMcpElicitation)] = _elicitation, + [typeof(IMcpFeedback)] = _feedback, + }; + var context = new McpSessionContext(roots, new McpServiceProviderOverlay(_services, overlayServices)); + // The context rides in its own overlay so request handlers can recover their + // originating session through the server's provider (the dictionary is captured by + // reference, making this two-phase registration safe). + overlayServices[typeof(McpSessionContext)] = context; + return context; + } + + // Requests recover their session through the provider handed to McpServer.Create — + // even a destination-bound per-request server exposes its session's services. Servers + // created by an external host (BuildDynamicServerOptions) carry the host's provider + // instead and share the explicit fallback context. + private McpSessionContext ResolveContext(McpServer? requestServer) + { + if (requestServer?.Services?.GetService(typeof(McpSessionContext)) is McpSessionContext context) + { + return context; + } + + lock (_attachLock) + { + if (_externalContext is null) + { + _externalContext = CreateSessionContext(); + _sessions.Add(_externalContext); + EnsureRoutingSubscription(); + } + + if (_externalContext.SessionServer is null && requestServer is not null) + { + _externalContext.SessionServer = requestServer; + _requestServers.AttachSession(requestServer); + EnsureRootsNotificationHandler(requestServer, _externalContext.Roots); + } + + return _externalContext; + } } [UnconditionalSuppressMessage( @@ -82,8 +128,10 @@ public async Task RunAsync(IReplIoContext io, CancellationToken ct) : new StdioServerTransport(serverName); try { - var server = McpServer.Create(transport, serverOptions, serviceProvider: _sessionServices); - AttachServer(server); + var context = CreateSessionContext(); + var server = McpServer.Create(transport, serverOptions, serviceProvider: context.Services); + context.SessionServer = server; + AttachSession(context, server); try { @@ -91,7 +139,7 @@ public async Task RunAsync(IReplIoContext io, CancellationToken ct) } finally { - UnsubscribeFromRoutingChanges(); + DetachSession(context); await server.DisposeAsync().ConfigureAwait(false); } } @@ -127,7 +175,7 @@ internal McpServerOptions BuildStaticServerOptions() { var serverName = _options.ServerName ?? ResolveAppName() ?? "repl-mcp-server"; var serverVersion = _options.ServerVersion ?? "1.0.0"; - var snapshot = BuildSnapshotCore(); + var snapshot = BuildSnapshotCore(CreateSessionContext()); return new McpServerOptions { @@ -139,10 +187,10 @@ internal McpServerOptions BuildStaticServerOptions() }; } - internal McpGeneratedSnapshot BuildSnapshotForTests() => BuildSnapshotCore(); + internal McpGeneratedSnapshot BuildSnapshotForTests() => BuildSnapshotCore(CreateSessionContext()); internal async Task BuildSnapshotForTestsAsync(CancellationToken cancellationToken = default) => - await GetSnapshotAsync(server: null, cancellationToken).ConfigureAwait(false); + await GetSnapshotAsync(CreateSessionContext(), cancellationToken).ConfigureAwait(false); private string? ResolveAppName() { @@ -150,7 +198,8 @@ internal async Task BuildSnapshotForTestsAsync(Cancellatio ReplSessionIO.IsProgrammatic = true; try { - var model = CreateDocumentationModel(); + // App name is session-independent; a throwaway capability-less context suffices. + var model = CreateDocumentationModel(CreateSessionContext().Services); return model.App.Name; } finally @@ -163,11 +212,12 @@ private async ValueTask ListToolsAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + BindRequestServer(request.Server); + var context = ResolveContext(request.Server); + var snapshot = await GetSnapshotAsync(context, cancellationToken).ConfigureAwait(false); if (_options.DynamicToolCompatibility == DynamicToolCompatibilityMode.DiscoverAndCallShim - && Interlocked.CompareExchange(ref _compatibilityIntroServed, 1, 0) == 0) + && Interlocked.CompareExchange(ref context.CompatibilityIntroServed, 1, 0) == 0) { _ = SendNotificationSafeAsync(NotificationMethods.ToolListChangedNotification); return new ListToolsResult @@ -190,8 +240,9 @@ private async ValueTask CallToolAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + BindRequestServer(request.Server); + var context = ResolveContext(request.Server); + var snapshot = await GetSnapshotAsync(context, cancellationToken).ConfigureAwait(false); IDictionary arguments = request.Params.Arguments ?? EmptyArguments; var toolName = request.Params.Name ?? string.Empty; var progressToken = request.Params.ProgressToken; @@ -222,8 +273,9 @@ private async ValueTask ListResourcesAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + BindRequestServer(request.Server); + var context = ResolveContext(request.Server); + var snapshot = await GetSnapshotAsync(context, cancellationToken).ConfigureAwait(false); return new ListResourcesResult { Resources = @@ -239,8 +291,9 @@ private async ValueTask ListResourceTemplatesAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + BindRequestServer(request.Server); + var context = ResolveContext(request.Server); + var snapshot = await GetSnapshotAsync(context, cancellationToken).ConfigureAwait(false); return new ListResourceTemplatesResult { ResourceTemplates = @@ -256,8 +309,9 @@ private async ValueTask ReadResourceAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + BindRequestServer(request.Server); + var context = ResolveContext(request.Server); + var snapshot = await GetSnapshotAsync(context, cancellationToken).ConfigureAwait(false); var uri = request.Params.Uri ?? string.Empty; var resource = snapshot.Resources.FirstOrDefault(candidate => candidate.IsMatch(uri)); if (resource is null) @@ -272,8 +326,9 @@ private async ValueTask ListPromptsAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + BindRequestServer(request.Server); + var context = ResolveContext(request.Server); + var snapshot = await GetSnapshotAsync(context, cancellationToken).ConfigureAwait(false); return new ListPromptsResult { Prompts = [.. snapshot.Prompts.Select(static prompt => prompt.ProtocolPrompt)], @@ -284,8 +339,9 @@ private async ValueTask GetPromptAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + BindRequestServer(request.Server); + var context = ResolveContext(request.Server); + var snapshot = await GetSnapshotAsync(context, cancellationToken).ConfigureAwait(false); var promptName = request.Params.Name ?? string.Empty; var prompt = snapshot.Prompts.FirstOrDefault(candidate => string.Equals(candidate.ProtocolPrompt.Name, promptName, StringComparison.OrdinalIgnoreCase)); @@ -297,38 +353,39 @@ private async ValueTask GetPromptAsync( return await prompt.GetAsync(request, cancellationToken).ConfigureAwait(false); } + // The snapshot is SESSION state: the tool graph can be gated on session capabilities + // (roots, module presence predicates), so each context caches its own build against + // the handler-global routing version. private async ValueTask GetSnapshotAsync( - McpServer? server, + McpSessionContext context, CancellationToken cancellationToken) { - AttachServer(server); - var snapshotVersion = Volatile.Read(ref _snapshotVersion); - if (Volatile.Read(ref _builtSnapshotVersion) == snapshotVersion - && _snapshot is { } cached) + if (context.BuiltSnapshotVersion == snapshotVersion + && context.Snapshot is { } cached) { return cached; } - await _snapshotGate.WaitAsync(cancellationToken).ConfigureAwait(false); + await context.SnapshotGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { snapshotVersion = Volatile.Read(ref _snapshotVersion); - if (Volatile.Read(ref _builtSnapshotVersion) == snapshotVersion - && _snapshot is { } refreshed) + if (context.BuiltSnapshotVersion == snapshotVersion + && context.Snapshot is { } refreshed) { return refreshed; } - var previousSnapshot = _snapshot; + var previousSnapshot = context.Snapshot; try { - await _roots.GetAsync(cancellationToken).ConfigureAwait(false); - var built = BuildSnapshotCore(); - _snapshot = built; + await context.Roots.GetAsync(cancellationToken).ConfigureAwait(false); + var built = BuildSnapshotCore(context); + context.Snapshot = built; if (Volatile.Read(ref _snapshotVersion) == snapshotVersion) { - Volatile.Write(ref _builtSnapshotVersion, snapshotVersion); + context.BuiltSnapshotVersion = snapshotVersion; } return built; } @@ -338,36 +395,39 @@ private async ValueTask GetSnapshotAsync( } catch (Exception) when (previousSnapshot is not null) { - _snapshot = previousSnapshot; + context.Snapshot = previousSnapshot; if (Volatile.Read(ref _snapshotVersion) == snapshotVersion) { - Volatile.Write(ref _builtSnapshotVersion, snapshotVersion); + context.BuiltSnapshotVersion = snapshotVersion; } return previousSnapshot; } } finally { - _snapshotGate.Release(); + context.SnapshotGate.Release(); } } - private McpGeneratedSnapshot BuildSnapshotCore() + private McpGeneratedSnapshot BuildSnapshotCore(McpSessionContext context) { - var model = CreateDocumentationModel(); - var adapter = new McpToolAdapter(_app, _options, _sessionServices); + var model = CreateDocumentationModel(context.Services); + var adapter = new McpToolAdapter(_app, _options, context.Services); var commandsByPath = model.Commands.ToDictionary( command => command.Path, command => command, StringComparer.OrdinalIgnoreCase); var tools = GenerateAllTools(model, adapter, _separator, commandsByPath); ValidateCompatibilityToolNames(tools); - var resources = GenerateResources(model, adapter, _separator, commandsByPath); + var resources = GenerateResources(model, adapter, _separator, commandsByPath, context.Services); var prompts = CollectPrompts(model, adapter, _separator); return new McpGeneratedSnapshot(adapter, tools, resources, prompts); } - private ReplDocumentationModel CreateDocumentationModel() + // The documentation model resolves module-presence predicates against the SESSION's + // services (e.g. IMcpClientRoots), so the model — and everything generated from it — + // reflects the capabilities of the session it is built for. + private ReplDocumentationModel CreateDocumentationModel(IServiceProvider sessionServices) { var coreApp = _app as CoreReplApp ?? throw new InvalidOperationException("MCP server handler requires CoreReplApp."); @@ -376,7 +436,7 @@ private ReplDocumentationModel CreateDocumentationModel() ReplSessionIO.IsProgrammatic = true; try { - return coreApp.CreateDocumentationModel(_sessionServices); + return coreApp.CreateDocumentationModel(sessionServices); } finally { @@ -403,27 +463,32 @@ private void ValidateCompatibilityToolNames(IReadOnlyList tools) } } - private void AttachServer(McpServer? server) + // Request-level binding: capability services resolve the flowing request's + // destination-bound server through the AsyncLocal accessor, so concurrent requests + // (SDK 2.0 creates one destination-bound McpServer per request) cannot cross-wire + // each other's client capabilities. Session-level concerns are handled by + // AttachSession (RunAsync) or the external fallback context (ResolveContext). + private void BindRequestServer(McpServer? server) { if (server is null) { return; } + _requestServers.BindRequest(server); + } + + // Session-level attach: routing-change notifications and the roots list-changed + // handler belong to the session servers, registered once per session — never to the + // per-request destination wrappers. + private void AttachSession(McpSessionContext context, McpServer server) + { lock (_attachLock) { - if (ReferenceEquals(_server, server)) - { - return; - } - - _server = server; - _roots.AttachServer(server); - _sampling.AttachServer(server); - _elicitation.AttachServer(server); - _feedback.AttachServer(server); + _sessions.Add(context); + _requestServers.AttachSession(server); EnsureRoutingSubscription(); - EnsureRootsNotificationHandler(server); + EnsureRootsNotificationHandler(server, context.Roots); } } @@ -451,25 +516,40 @@ private void EnsureRoutingSubscription() coreApp.RoutingInvalidated += handler; } - private void EnsureRootsNotificationHandler(McpServer server) + // Removes a closing session and repairs the shared state: the accessor's session + // fallback moves to a surviving session, and the routing subscription is dropped only + // when the LAST session ends — a first-session close must not silence the others. + private void DetachSession(McpSessionContext context) { - if (Interlocked.Exchange(ref _rootsNotificationRegistered, 1) != 0) + lock (_attachLock) { - return; + _sessions.Remove(context); + _requestServers.AttachSession(_sessions.Count > 0 ? _sessions[^1].SessionServer : null); + if (_sessions.Count == 0) + { + UnsubscribeFromRoutingChanges(); + } } + } - var weakSelf = new WeakReference(this); + private static void EnsureRootsNotificationHandler(McpServer server, McpClientRootsService roots) + { + var weakSelf = new WeakReference(roots); + // Roots is deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005) but hosts still send + // this notification; Repl keeps supporting it until the SDK removes the surface (#51). +#pragma warning disable MCP9005 _ = server.RegisterNotificationHandler( NotificationMethods.RootsListChangedNotification, (_, _) => { if (weakSelf.TryGetTarget(out var target)) { - target._roots.HandleRootsListChanged(); + target.HandleRootsListChanged(); } return ValueTask.CompletedTask; }); +#pragma warning restore MCP9005 } private void OnRoutingInvalidated() @@ -477,7 +557,14 @@ private void OnRoutingInvalidated() Interlocked.Increment(ref _snapshotVersion); if (_options.DynamicToolCompatibility == DynamicToolCompatibilityMode.DiscoverAndCallShim) { - Interlocked.Exchange(ref _compatibilityIntroServed, 0); + // Every active session re-serves its compatibility intro after a routing change. + lock (_attachLock) + { + foreach (var session in _sessions) + { + Interlocked.Exchange(ref session.CompatibilityIntroServed, 0); + } + } } lock (_refreshLock) @@ -500,23 +587,31 @@ private async Task SendDiscoveryNotificationsSafeAsync() private async Task SendNotificationSafeAsync(string method) { - try - { - var server = _server; - if (server is null) - { - return; - } - - await server.SendNotificationAsync(method, CancellationToken.None).ConfigureAwait(false); - } - catch (OperationCanceledException) + McpServer[] sessions; + lock (_attachLock) { - // Notifications are best-effort. Cancellation is not actionable here. + sessions = [ + .. _sessions + .Select(static session => session.SessionServer) + .OfType(), + ]; } - catch (Exception) + + foreach (var server in sessions) { - // Notifications are best-effort. The next list/read request will rebuild on demand. + try + { + await server.SendNotificationAsync(method, CancellationToken.None).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Notifications are best-effort. Cancellation is not actionable here. + } + catch (Exception) + { + // Notifications are best-effort per session. The next list/read request + // will rebuild on demand. + } } } @@ -537,6 +632,10 @@ private void UnsubscribeFromRoutingChanges() private ServerCapabilities BuildCapabilities() { + // Logging is deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005) but the feedback + // bridge still routes through logging notifications for current hosts; Repl keeps + // advertising it until the SDK removes the surface (#51). +#pragma warning disable MCP9005 var capabilities = new ServerCapabilities { Logging = new LoggingCapability(), @@ -544,6 +643,7 @@ private ServerCapabilities BuildCapabilities() Resources = new ResourcesCapability { ListChanged = true }, Prompts = new PromptsCapability { ListChanged = true }, }; +#pragma warning restore MCP9005 if (_options.EnableApps || HasMcpAppResources()) { @@ -570,7 +670,9 @@ private bool HasMcpAppResources() return true; } - var model = CreateDocumentationModel(); + // Capability advertisement is computed before any session exists: build against a + // throwaway capability-less context (same result as a session with no roots). + var model = CreateDocumentationModel(CreateSessionContext().Services); return model.Commands.Any(static command => command.Metadata?.ContainsKey(McpAppMetadata.ResourceMetadataKey) == true || command.Metadata?.ContainsKey(McpAppMetadata.CommandMetadataKey) == true); @@ -819,7 +921,8 @@ private List GenerateResources( ReplDocumentationModel model, McpToolAdapter adapter, char separator, - Dictionary commandsByPath) + Dictionary commandsByPath, + IServiceProvider sessionServices) { var resources = new List(); var resourceMimeType = adapter.ForcedOutputMimeType; @@ -872,7 +975,7 @@ private List GenerateResources( foreach (var uiResource in _options.UiResources) { - resources.Add(new McpAppResource(uiResource, _sessionServices)); + resources.Add(new McpAppResource(uiResource, sessionServices)); } return resources; diff --git a/src/Repl.Mcp/McpSessionContext.cs b/src/Repl.Mcp/McpSessionContext.cs new file mode 100644 index 00000000..e175d7cf --- /dev/null +++ b/src/Repl.Mcp/McpSessionContext.cs @@ -0,0 +1,49 @@ +using ModelContextProtocol.Server; +using Repl.Documentation; + +namespace Repl.Mcp; + +/// +/// State owned by ONE MCP transport session. +/// +/// +/// One can serve several concurrent sessions, so anything +/// that varies per client lives here instead of on the handler: hard/soft roots, the +/// generated snapshot cache (the tool graph can be gated on session capabilities), the +/// compatibility-shim intro state, and the session's service overlay. The context is +/// registered in the provider passed to McpServer.Create, so request handlers +/// recover their originating session through request.Server.Services — never +/// through a destination-bound per-request server used as a surrogate session key. +/// Request-bound OUTBOUND capabilities (sampling, elicitation, progress) keep flowing +/// through the per-request binding, which is +/// finer-grained than the session. +/// +internal sealed class McpSessionContext +{ + public McpSessionContext(McpClientRootsService roots, IServiceProvider services) + { + Roots = roots; + Services = services; + } + + /// Session-owned hard/soft roots. + public McpClientRootsService Roots { get; } + + /// Per-session service overlay handed to McpServer.Create. + public IServiceProvider Services { get; } + + /// Session server used for server-initiated notifications. + public McpServer? SessionServer { get; set; } + + /// Serializes snapshot builds for this session. + public SemaphoreSlim SnapshotGate { get; } = new(initialCount: 1, maxCount: 1); + + /// Cached generated snapshot for this session. + public McpServerHandler.McpGeneratedSnapshot? Snapshot { get; set; } + + /// Routing version the cached snapshot was built at. + public long BuiltSnapshotVersion { get; set; } + + /// Whether this session already received the compatibility-shim intro list. + public int CompatibilityIntroServed; +} diff --git a/src/Repl.Mcp/README.md b/src/Repl.Mcp/README.md index 06ee966b..09dfb2eb 100644 --- a/src/Repl.Mcp/README.md +++ b/src/Repl.Mcp/README.md @@ -104,7 +104,7 @@ app.Map("debug reset", handler) .AutomationHidden(); ``` -Unannotated tools force agents to assume the worst. Use `.ReadOnly()` for safe queries, `.Destructive()` for important mutations, `.OpenWorld()` for external systems, `.LongRunning()` for operations that should use call-now / poll-later patterns, and `.AutomationHidden()` for commands that should stay available to humans but invisible to MCP automation. +Unannotated tools force agents to assume the worst. Use `.ReadOnly()` for safe queries, `.Destructive()` for important mutations, `.OpenWorld()` for external systems, `.LongRunning()` for slow operations (a documentation hint today — protocol-level MCP task advertisement returns once Repl integrates the SDK Tasks extension), and `.AutomationHidden()` for commands that should stay available to humans but invisible to MCP automation. Prefer returning JSON-friendly objects instead of writing prose-only output. Structured results are easier for agents to inspect, retry, test, and summarize. diff --git a/src/Repl.Mcp/ReplMcpServerTool.cs b/src/Repl.Mcp/ReplMcpServerTool.cs index cb4b1c0f..a6376770 100644 --- a/src/Repl.Mcp/ReplMcpServerTool.cs +++ b/src/Repl.Mcp/ReplMcpServerTool.cs @@ -14,10 +14,11 @@ internal sealed class ReplMcpServerTool : McpServerTool private readonly McpToolAdapter _adapter; private readonly Tool _protocolTool; - // MCP Tasks are experimental in the SDK (MCPEXP001) but part of the MCP spec. - // LongRunning commands advertise optional task support so agents can use - // the call-now/poll-later pattern instead of blocking on slow operations. -#pragma warning disable MCPEXP001 + // SDK 2.0 extracted MCP Tasks into ModelContextProtocol.Extensions.Tasks (store, task + // results, client polling) and dropped the per-tool Tool.Execution / ToolTaskSupport + // augmentation from the protocol surface. Repl keeps .LongRunning() in its own model + // (help/docs) and deliberately does not advertise task support until Repl integrates + // the Tasks extension end-to-end (tasks/get|update|cancel) — tracked in issue #72. public ReplMcpServerTool( ReplDocCommand command, string toolName, @@ -31,15 +32,11 @@ public ReplMcpServerTool( InputSchema = McpSchemaGenerator.BuildInputSchema(command), OutputSchema = McpSchemaGenerator.BuildOutputSchema(command), Annotations = McpSchemaGenerator.MapAnnotations(command.Annotations), - Execution = command.Annotations?.LongRunning == true - ? new ToolExecution { TaskSupport = ToolTaskSupport.Optional } - : null, Meta = TryGetAppOptions(command, out var appOptions) ? McpAppMetadata.BuildToolMeta(appOptions) : null, }; } -#pragma warning restore MCPEXP001 /// public override Tool ProtocolTool => _protocolTool; diff --git a/src/Repl.McpTests/Given_McpAgentCapabilities.cs b/src/Repl.McpTests/Given_McpAgentCapabilities.cs index b87ef8d3..c1440bcb 100644 --- a/src/Repl.McpTests/Given_McpAgentCapabilities.cs +++ b/src/Repl.McpTests/Given_McpAgentCapabilities.cs @@ -4,6 +4,11 @@ using ModelContextProtocol.Protocol; using Repl.Mcp; +// These tests exercise Roots/Sampling/Logging, deprecated by MCP spec 2026-07-28 +// (SEP-2577, MCP9005) but still supported by Repl.Mcp until the SDK removes them. +// Tracked in issue #51. +#pragma warning disable MCP9005 + namespace Repl.McpTests; [TestClass] diff --git a/src/Repl.McpTests/Given_McpConcurrentSessions.cs b/src/Repl.McpTests/Given_McpConcurrentSessions.cs index 6bfadfec..3485029d 100644 --- a/src/Repl.McpTests/Given_McpConcurrentSessions.cs +++ b/src/Repl.McpTests/Given_McpConcurrentSessions.cs @@ -1,10 +1,288 @@ +using System.IO.Pipelines; +using ModelContextProtocol; +using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Repl.Mcp; + +// One test exercises Sampling, deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005) +// but still supported by Repl.Mcp until the SDK removes the surface (#51). +#pragma warning disable MCP9005 namespace Repl.McpTests; [TestClass] public sealed class Given_McpConcurrentSessions { + [TestMethod] + [Description("Guards capability binding against cross-session interference: with one handler serving two sessions (SDK 2.0 binds a destination server per request), a paused call from a sampling-capable client must still observe ITS OWN client's capabilities after a request from a sampling-less client has been served — the capability services must bind to the flowing request, not to a shared last-attached server.")] + public async Task When_TwoClientsWithDifferentCapabilitiesShareHandler_Then_CapabilityBindingIsPerRequest() + { + using var entered = new SemaphoreSlim(0, 1); + using var gate = new SemaphoreSlim(0, 1); + + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("probe", async (IMcpSampling sampling) => + { + var before = sampling.IsSupported; + entered.Release(); + await gate.WaitAsync().ConfigureAwait(false); + var after = sampling.IsSupported; + return $"{before}|{after}"; + }); + app.Map("poke", () => "ok"); + + var options = new ReplMcpServerOptions + { + TransportFactory = static (serverName, io) => new StreamServerTransport( + ((McpTestFixture.PipeIoContext)io).InputStream, + ((McpTestFixture.PipeIoContext)io).OutputStream, + serverName), + }; + var handler = new McpServerHandler(app.Core, options, McpTestFixture.EmptyServices); + using var cts = new CancellationTokenSource(); + + var (clientA, serverTaskA) = await StartSessionAsync(handler, BuildSamplingClientOptions(), cts.Token).ConfigureAwait(false); + var (clientB, serverTaskB) = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + + // Session A enters "probe" (sampling supported) and pauses on the gate; session B is + // then served in full; A resumes and must STILL see its own sampling capability. + var probeTask = clientA.CallToolAsync( + "probe", new Dictionary(StringComparer.Ordinal), cancellationToken: cts.Token); + (await entered.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false)).Should().BeTrue(); + + await clientB.CallToolAsync( + "poke", new Dictionary(StringComparer.Ordinal), cancellationToken: cts.Token) + .ConfigureAwait(false); + + gate.Release(); + var probeResult = await probeTask.ConfigureAwait(false); + + var text = probeResult.Content.OfType().First().Text; + text.Should().Contain("True|True"); + + await clientA.DisposeAsync().ConfigureAwait(false); + await clientB.DisposeAsync().ConfigureAwait(false); + await cts.CancelAsync().ConfigureAwait(false); + _ = serverTaskA; + _ = serverTaskB; + } + + [TestMethod] + [Description("Guards root isolation across sessions sharing one handler: the hard-roots cache must be keyed by session, otherwise the second root-capable client silently receives the FIRST client's workspace roots — a cross-session data exposure — instead of its own roots/list round-trip.")] + public async Task When_TwoRootCapableClientsShareHandler_Then_EachSeesOwnRoots() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("roots", async (IMcpClientRoots roots, CancellationToken ct) => + string.Join(',', (await roots.GetAsync(ct).ConfigureAwait(false)).Select(root => root.Uri.ToString()))); + + var options = new ReplMcpServerOptions + { + TransportFactory = static (serverName, io) => new StreamServerTransport( + ((McpTestFixture.PipeIoContext)io).InputStream, + ((McpTestFixture.PipeIoContext)io).OutputStream, + serverName), + }; + var handler = new McpServerHandler(app.Core, options, McpTestFixture.EmptyServices); + using var cts = new CancellationTokenSource(); + + var (clientA, _) = await StartSessionAsync(handler, BuildRootsClientOptions("file:///ga"), cts.Token).ConfigureAwait(false); + var (clientB, _) = await StartSessionAsync(handler, BuildRootsClientOptions("file:///bu"), cts.Token).ConfigureAwait(false); + + var resultA = await clientA.CallToolAsync( + toolName: "roots", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token).ConfigureAwait(false); + var resultB = await clientB.CallToolAsync( + toolName: "roots", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token).ConfigureAwait(false); + + resultA.Content.OfType().First().Text.Should().Contain("file:///ga"); + var textB = resultB.Content.OfType().First().Text; + textB.Should().Contain("file:///bu"); + textB.Should().NotContain("file:///ga"); + + await clientA.DisposeAsync().ConfigureAwait(false); + await clientB.DisposeAsync().ConfigureAwait(false); + await cts.CancelAsync().ConfigureAwait(false); + } + + [TestMethod] + [Description("Guards routing-notification lifetime across sessions: when the first-attached session closes, the surviving session must still receive tools/list_changed after a routing invalidation — session attachment must be reference-counted, not first-wins with a handler-wide unsubscribe on first close.")] + public async Task When_FirstSessionCloses_Then_SurvivingSessionStillReceivesRoutingNotifications() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("alpha", () => "a"); + + var options = new ReplMcpServerOptions + { + TransportFactory = static (serverName, io) => new StreamServerTransport( + ((McpTestFixture.PipeIoContext)io).InputStream, + ((McpTestFixture.PipeIoContext)io).OutputStream, + serverName), + }; + var handler = new McpServerHandler(app.Core, options, McpTestFixture.EmptyServices); + using var ctsA = new CancellationTokenSource(); + using var ctsB = new CancellationTokenSource(); + + var (clientA, serverTaskA) = await StartSessionAsync(handler, clientOptions: null, ctsA.Token).ConfigureAwait(false); + var (clientB, _) = await StartSessionAsync(handler, clientOptions: null, ctsB.Token).ConfigureAwait(false); + + var listChanged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var registration = clientB.RegisterNotificationHandler( + NotificationMethods.ToolListChangedNotification, + (_, _) => + { + listChanged.TrySetResult(); + return ValueTask.CompletedTask; + }); + await using var _ = registration.ConfigureAwait(false); + + // Both sessions are live; close the FIRST one, then invalidate routing. + await clientA.DisposeAsync().ConfigureAwait(false); + await ctsA.CancelAsync().ConfigureAwait(false); + try + { + await serverTaskA.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected: session A's RunAsync ends on cancellation. + } + + app.Map("late", () => "l"); + app.Core.InvalidateRouting(); + + await listChanged.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + await clientB.DisposeAsync().ConfigureAwait(false); + await ctsB.CancelAsync().ConfigureAwait(false); + } + + [TestMethod] + [Description("Guards snapshot isolation across sessions sharing one handler: a tool graph gated on session capabilities (module presence on IMcpClientRoots.IsSupported) must be computed per session — a shared snapshot cache would serve the roots-capable session's tools to a session without roots.")] + public async Task When_ToolGraphIsSessionGated_Then_EachSessionSeesItsOwnTools() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("always", () => "ok"); + app.MapModule(new RootsGatedModule(), (IMcpClientRoots roots) => roots.IsSupported); + + var options = new ReplMcpServerOptions + { + TransportFactory = static (serverName, io) => new StreamServerTransport( + ((McpTestFixture.PipeIoContext)io).InputStream, + ((McpTestFixture.PipeIoContext)io).OutputStream, + serverName), + }; + var handler = new McpServerHandler(app.Core, options, McpTestFixture.EmptyServices); + using var cts = new CancellationTokenSource(); + + var (clientWithRoots, _) = await StartSessionAsync(handler, BuildRootsClientOptions("file:///ga"), cts.Token).ConfigureAwait(false); + var (clientWithoutRoots, _) = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + + var toolsWithRoots = await clientWithRoots.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + var toolsWithoutRoots = await clientWithoutRoots.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + + toolsWithRoots.Should().Contain(tool => string.Equals(tool.Name, "gated", StringComparison.Ordinal)); + toolsWithoutRoots.Should().Contain(tool => string.Equals(tool.Name, "always", StringComparison.Ordinal)); + toolsWithoutRoots.Should().NotContain(tool => string.Equals(tool.Name, "gated", StringComparison.Ordinal)); + + await clientWithRoots.DisposeAsync().ConfigureAwait(false); + await clientWithoutRoots.DisposeAsync().ConfigureAwait(false); + await cts.CancelAsync().ConfigureAwait(false); + } + + [TestMethod] + [Description("Guards the compatibility-shim intro across sessions: DiscoverAndCallShim serves the discover_tools/call_tool intro on each session's FIRST tools/list — a handler-global flag would give the intro only to whichever session listed first, leaving later sessions without the documented bootstrap.")] + public async Task When_ShimEnabledAndTwoSessionsList_Then_EachSessionGetsTheIntro() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("alpha", () => "a"); + + var options = new ReplMcpServerOptions + { + DynamicToolCompatibility = DynamicToolCompatibilityMode.DiscoverAndCallShim, + TransportFactory = static (serverName, io) => new StreamServerTransport( + ((McpTestFixture.PipeIoContext)io).InputStream, + ((McpTestFixture.PipeIoContext)io).OutputStream, + serverName), + }; + var handler = new McpServerHandler(app.Core, options, McpTestFixture.EmptyServices); + using var cts = new CancellationTokenSource(); + + var (clientA, _) = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + var (clientB, _) = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + + var firstListA = await clientA.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + var firstListB = await clientB.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + + firstListA.Select(static tool => tool.Name).Should().BeEquivalentTo(["discover_tools", "call_tool"]); + firstListB.Select(static tool => tool.Name).Should().BeEquivalentTo(["discover_tools", "call_tool"]); + + await clientA.DisposeAsync().ConfigureAwait(false); + await clientB.DisposeAsync().ConfigureAwait(false); + await cts.CancelAsync().ConfigureAwait(false); + } + + private sealed class RootsGatedModule : IReplModule + { + public void Map(IReplMap app) => app.Map("gated", () => "roots-only"); + } + + private static McpClientOptions BuildRootsClientOptions(string rootUri) => new() + { + Capabilities = new ClientCapabilities + { + Roots = new RootsCapability { ListChanged = true }, + }, + Handlers = new McpClientHandlers + { + RootsHandler = (_, _) => ValueTask.FromResult(new ListRootsResult + { + Roots = [new Root { Uri = rootUri, Name = rootUri }], + }), + }, + }; + + private static McpClientOptions BuildSamplingClientOptions() => new() + { + Capabilities = new ClientCapabilities { Sampling = new SamplingCapability() }, + Handlers = new McpClientHandlers + { + SamplingHandler = static (request, _, _) => ValueTask.FromResult(new CreateMessageResult + { + Content = [new TextContentBlock { Text = "ga" }], + Model = "test-model", + }), + }, + }; + + private static async Task<(McpClient Client, Task ServerTask)> StartSessionAsync( + McpServerHandler handler, + McpClientOptions? clientOptions, + CancellationToken cancellationToken) + { + var clientToServer = new Pipe(); + var serverToClient = new Pipe(); + var io = new McpTestFixture.PipeIoContext( + clientToServer.Reader.AsStream(), + serverToClient.Writer.AsStream()); + var serverTask = handler.RunAsync(io, cancellationToken); + + var clientTransport = new StreamClientTransport( + clientToServer.Writer.AsStream(), + serverToClient.Reader.AsStream()); + var client = await McpClient.CreateAsync(clientTransport, clientOptions, cancellationToken: cancellationToken) + .ConfigureAwait(false); + return (client, serverTask); + } + [TestMethod] [Description("Two independent MCP sessions can run concurrently without interference.")] public async Task When_TwoSessionsRunConcurrently_Then_EachSeesOwnTools() diff --git a/src/Repl.McpTests/Given_McpIntegration.cs b/src/Repl.McpTests/Given_McpIntegration.cs index 55de3709..22cd245d 100644 --- a/src/Repl.McpTests/Given_McpIntegration.cs +++ b/src/Repl.McpTests/Given_McpIntegration.cs @@ -1,3 +1,5 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; using Repl.Mcp; namespace Repl.McpTests; @@ -74,7 +76,10 @@ public void When_BuildingMcpOptions_Then_LoggingCapabilityIsAdvertised() var options = app.BuildMcpServerOptions(); + // Logging is deprecated (SEP-2577, MCP9005) but still supported by Repl.Mcp (#51). +#pragma warning disable MCP9005 options.Capabilities!.Logging.Should().NotBeNull(); +#pragma warning restore MCP9005 } [TestMethod] @@ -117,6 +122,55 @@ public void When_EnrichedCommands_Then_DocModelContainsAllFields() cmd.Arguments.Should().ContainSingle(a => string.Equals(a.Name, "env", StringComparison.Ordinal)); } + [TestMethod] + [Description("Locks the legacy initialize handshake under SDK 2.0: a client pinning an initialize-era protocol revision still negotiates that exact version and can list and call tools — the fixture's default client otherwise negotiates the 2026-07-28 path and never exercises the fallback.")] + public async Task When_ClientPinsLegacyProtocolVersion_Then_InitializeHandshakeAndToolsWork() + { + // 2025-11-25 is the last initialize-era protocol revision (the SDK's + // McpProtocolVersions constants are internal, so the literal is pinned here). + const string legacyProtocolVersion = "2025-11-25"; + var clientOptions = new McpClientOptions + { + ProtocolVersion = legacyProtocolVersion, + }; + + await using var fixture = await McpTestFixture.CreateAsync( + app => app.Map("ping", () => "pong"), + configureOptions: null, + clientOptions: clientOptions); + + fixture.Client.NegotiatedProtocolVersion.Should().Be(legacyProtocolVersion); + + var tools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + tools.Should().ContainSingle(tool => string.Equals(tool.Name, "ping", StringComparison.Ordinal)); + + var result = await fixture.Client.CallToolAsync( + toolName: "ping", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + result.Content.OfType().First().Text.Should().Contain("pong"); + } + + [TestMethod] + [Description("Locks the SDK-2.0 tools/list wire shape for .LongRunning() commands: annotations survive serialization, and no task/execution augmentation is emitted — Repl deliberately does not advertise MCP task support until the Tasks runtime is implemented end-to-end (issue #51).")] + public void When_SerializingLongRunningTool_Then_NoTaskAugmentationIsEmitted() + { + var app = ReplApp.Create(); + app.Map("deploy", () => "deployed") + .WithDescription("Deploy application") + .LongRunning() + .OpenWorld(); + + var options = app.BuildMcpServerOptions(); + var tool = options.ToolCollection!.Single(tool => + string.Equals(tool.ProtocolTool.Name, "deploy", StringComparison.Ordinal)); + var json = System.Text.Json.JsonSerializer.Serialize( + tool.ProtocolTool, ModelContextProtocol.McpJsonUtilities.DefaultOptions); + + json.Should().Contain("\"openWorldHint\""); + json.Should().NotContain("execution"); + json.Should().NotContain("taskSupport"); + } + [TestMethod] [Description("Modules excluded from Programmatic channel are not visible as MCP tools.")] public void When_ModuleExcludedFromProgrammatic_Then_NotInToolCandidates() diff --git a/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs b/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs index ed70c18b..5eb7b01b 100644 --- a/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs +++ b/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs @@ -4,6 +4,11 @@ using ModelContextProtocol.Protocol; using Repl.Mcp; +// These tests exercise Roots/Sampling/Logging, deprecated by MCP spec 2026-07-28 +// (SEP-2577, MCP9005) but still supported by Repl.Mcp until the SDK removes them. +// Tracked in issue #51. +#pragma warning disable MCP9005 + namespace Repl.McpTests; [TestClass] diff --git a/src/Repl.McpTests/Given_McpUserFeedback.cs b/src/Repl.McpTests/Given_McpUserFeedback.cs index 0a8bd859..28869839 100644 --- a/src/Repl.McpTests/Given_McpUserFeedback.cs +++ b/src/Repl.McpTests/Given_McpUserFeedback.cs @@ -5,6 +5,11 @@ using ModelContextProtocol.Protocol; using Repl.Interaction; +// These tests exercise Roots/Sampling/Logging, deprecated by MCP spec 2026-07-28 +// (SEP-2577, MCP9005) but still supported by Repl.Mcp until the SDK removes them. +// Tracked in issue #51. +#pragma warning disable MCP9005 + namespace Repl.McpTests; [TestClass] diff --git a/src/Repl.McpTests/McpTestFixture.cs b/src/Repl.McpTests/McpTestFixture.cs index 23c90048..72252a3e 100644 --- a/src/Repl.McpTests/McpTestFixture.cs +++ b/src/Repl.McpTests/McpTestFixture.cs @@ -113,6 +113,8 @@ public async ValueTask DisposeAsync() _cts.Dispose(); } + internal static IServiceProvider EmptyServices => EmptyServiceProvider.Instance; + private sealed class EmptyServiceProvider : IServiceProvider { public static readonly EmptyServiceProvider Instance = new();