diff --git a/docs/for-coding-agents.md b/docs/for-coding-agents.md index 0e61fba..3887440 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. | | `.WithOption(name, o => o.AutomationHidden())` | Keep this one option out of the tool schema; the command stays visible. | diff --git a/docs/mcp-advanced.md b/docs/mcp-advanced.md index f82ebdd..90ea3f6 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 cac9ce4..f4a1e8c 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 9f3f42a..ac42537 100644 --- a/docs/mcp-overview.md +++ b/docs/mcp-overview.md @@ -64,7 +64,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 962da64..c6e6f34 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -512,6 +512,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 at **2.2.0**. 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) is available starting with SDK 2.0; 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 1aa0ede..fc66d5a 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 9b7a5db..8c66dad 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 3fff6a1..543127d 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, available 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 fa53d4e..f699c70 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, available 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 12de521..40a57af 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 92a3a2a..5c03c24 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, available 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 ab6531a..4a33215 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, available 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 0000000..be13877 --- /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 5e6a09e..53d62b3 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, available 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 eaddea0..5cbf9da 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -27,23 +27,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 SnapshotVersionState _snapshotState = new(Version: 1, LastVisibilityRetractionVersion: 0); - 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); // Notifications are fire-and-forget best-effort — a stuck stdio peer must not hang this @@ -61,19 +65,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 + // 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) { - [typeof(IMcpClientRoots)] = _roots, - [typeof(IMcpSampling)] = _sampling, - [typeof(IMcpElicitation)] = _elicitation, - [typeof(IMcpFeedback)] = _feedback, - }); + _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( @@ -89,8 +135,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 { @@ -98,7 +146,7 @@ public async Task RunAsync(IReplIoContext io, CancellationToken ct) } finally { - UnsubscribeFromRoutingChanges(); + DetachSession(context); await server.DisposeAsync().ConfigureAwait(false); } } @@ -118,7 +166,7 @@ internal McpServerOptions BuildDynamicServerOptions() // then repeated for the same commands during the first discovery request. if (_options.CommandFilter is null) { - _ = CreateDocumentationModel(); + _ = CreateDocumentationModel(CreateSessionContext().Services); } return new McpServerOptions @@ -142,7 +190,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 { @@ -154,10 +202,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() { @@ -170,11 +218,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 @@ -197,8 +246,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; @@ -229,8 +279,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 = @@ -246,8 +297,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 = @@ -263,8 +315,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) @@ -279,8 +332,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)], @@ -291,8 +345,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)); @@ -304,33 +359,34 @@ 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 _snapshotState).Version; - 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 _snapshotState).Version; - 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 { - return await BuildCurrentSnapshotAsync(snapshotVersion, cancellationToken).ConfigureAwait(false); + return await BuildCurrentSnapshotAsync(context, snapshotVersion, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -344,17 +400,17 @@ private async ValueTask GetSnapshotAsync( catch (Exception) when ( previousSnapshot is not null && Volatile.Read(ref _snapshotState).LastVisibilityRetractionVersion - <= Volatile.Read(ref _builtSnapshotVersion)) + <= context.BuiltSnapshotVersion) { // Preserve availability for transient projection failures, but leave the version dirty // so the next request retries without requiring another routing mutation. - _snapshot = previousSnapshot; + context.Snapshot = previousSnapshot; return previousSnapshot; } } finally { - _snapshotGate.Release(); + context.SnapshotGate.Release(); } } @@ -377,14 +433,15 @@ private static void ThrowSanitizedIfAClientAlreadyHasASchema(McpGeneratedSnapsho } private async ValueTask BuildCurrentSnapshotAsync( + McpSessionContext context, long snapshotVersion, CancellationToken cancellationToken) { while (true) { cancellationToken.ThrowIfCancellationRequested(); - await _roots.GetAsync(cancellationToken).ConfigureAwait(false); - var built = BuildSnapshotCore(); + await context.Roots.GetAsync(cancellationToken).ConfigureAwait(false); + var built = BuildSnapshotCore(context); var observedState = Volatile.Read(ref _snapshotState); // Version and retraction watermark are one atomically published state. A reader can @@ -396,32 +453,35 @@ private async ValueTask BuildCurrentSnapshotAsync( continue; } - _snapshot = built; + context.Snapshot = built; if (observedState.Version == snapshotVersion) { - Volatile.Write(ref _builtSnapshotVersion, snapshotVersion); + context.BuiltSnapshotVersion = snapshotVersion; } return built; } } - private McpGeneratedSnapshot BuildSnapshotCore() + private McpGeneratedSnapshot BuildSnapshotCore(McpSessionContext context) { // Project once here so tools/list, tools/call and prompts/list all read the same option list. - var model = McpAutomationProjection.Apply(CreateDocumentationModel()); - var adapter = new McpToolAdapter(_app, _options, _sessionServices); + var model = McpAutomationProjection.Apply(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."); @@ -430,7 +490,9 @@ private ReplDocumentationModel CreateDocumentationModel() ReplSessionIO.IsProgrammatic = true; try { - return coreApp.CreateDocumentationModel(CreateDiscoveryServices(), IsMcpCandidateBeforeValidation); + return coreApp.CreateDocumentationModel( + CreateDiscoveryServices(sessionServices), + IsMcpCandidateBeforeValidation); } finally { @@ -441,9 +503,9 @@ private ReplDocumentationModel CreateDocumentationModel() // Every MCP tool invocation overlays a concrete interaction channel before entering the // binder. Discovery must expose that guaranteed fallback even when the caller did not supply // a base provider (or supplied one without the channel). - private McpServiceProviderOverlay CreateDiscoveryServices() => + private McpServiceProviderOverlay CreateDiscoveryServices(IServiceProvider sessionServices) => new( - _sessionServices, + sessionServices, new Dictionary { [typeof(IReplInteractionChannel)] = new McpInteractionChannel( @@ -470,27 +532,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); } } @@ -522,25 +589,40 @@ private void EnsureRoutingSubscription() coreApp.RoutingInvalidatedDetailed += 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 } internal static SnapshotVersionState PublishSnapshotInvalidation( @@ -574,7 +656,14 @@ private void OnRoutingInvalidated(bool isVisibilityRetraction) 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) @@ -597,24 +686,32 @@ private async Task SendDiscoveryNotificationsSafeAsync() private async Task SendNotificationSafeAsync(string method) { - try - { - var server = _server; - if (server is null) - { - return; - } - - using var timeoutCts = new CancellationTokenSource(NotificationSendTimeout); - await server.SendNotificationAsync(method, timeoutCts.Token).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 + { + using var timeoutCts = new CancellationTokenSource(NotificationSendTimeout); + await server.SendNotificationAsync(method, timeoutCts.Token).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. + } } } @@ -635,6 +732,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(), @@ -642,6 +743,7 @@ private ServerCapabilities BuildCapabilities() Resources = new ResourcesCapability { ListChanged = true }, Prompts = new PromptsCapability { ListChanged = true }, }; +#pragma warning restore MCP9005 if (_options.EnableApps || HasMcpAppResources()) { @@ -674,8 +776,9 @@ private bool HasMcpAppResources() ReplSessionIO.IsProgrammatic = true; try { + var sessionServices = CreateSessionContext().Services; using var runtimeStateScope = coreApp.PushRuntimeState( - CreateDiscoveryServices(), + CreateDiscoveryServices(sessionServices), isInteractiveSession: false); var activeGraph = coreApp.ResolveActiveRoutingGraph(); var commands = coreApp.ResolveDiscoverableRoutes( @@ -941,7 +1044,8 @@ private List GenerateResources( ReplDocumentationModel model, McpToolAdapter adapter, char separator, - Dictionary commandsByPath) + Dictionary commandsByPath, + IServiceProvider sessionServices) { var resources = new List(); var resourceMimeType = adapter.ForcedOutputMimeType; @@ -994,7 +1098,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 0000000..e175d7c --- /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 06ee966..09dfb2e 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 cb4b1c0..a637677 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 b87ef8d..c1440bc 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 6bfadfe..3485029 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 55de370..22cd245 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 ed70c18..5eb7b01 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 0a8bd85..2886983 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 04279ee..ab8b3c9 100644 --- a/src/Repl.McpTests/McpTestFixture.cs +++ b/src/Repl.McpTests/McpTestFixture.cs @@ -151,6 +151,13 @@ public async ValueTask DisposeAsync() _cts.Dispose(); } + internal static IServiceProvider EmptyServices => EmptyServiceProvider.Instance; + + private sealed class EmptyServiceProvider : IServiceProvider + { + public static readonly EmptyServiceProvider Instance = new(); + public object? GetService(Type serviceType) => null; + } internal sealed class PipeIoContext(Stream inputStream, Stream outputStream) : IReplIoContext {