diff --git a/CHANGELOG.md b/CHANGELOG.md index 57bcb2f..623bbfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ Nerdbank.GitVersioning at pack time; this file groups changes by theme instead o ## Unreleased +### Changed — MCP SDK 2.2.0 + +- `Repl.Mcp` now builds on `ModelContextProtocol` **2.2.0** (from 1.4.1). The SDK is a transitively + public dependency, so a consumer referencing `Repl.Mcp` must move to the 2.x line. The server stays + multi-revision: it keeps the `initialize` handshake for existing hosts while also serving the + sessionless `2026-07-28` revision. +- **Breaking:** `IMcpFeedback.SendMessageAsync` takes a Repl-owned `McpMessageLevel` instead of the + SDK's `LoggingLevel`. `LoggingLevel` carries the SDK's `MCP9005` deprecation, and Repl's internal + `#pragma` never covered a *consumer's* compilation — anyone building with warnings as errors got a + hard error on a Repl signature. The new enum has the same members and numeric values. +- **User feedback delivery depends on the negotiated revision.** `2026-07-28` removed + `logging/setLevel` and forbids emitting `notifications/message` for a request that declared no + `_meta/io.modelcontextprotocol/logLevel` (SEP-2575), so Repl now honours that. Messages that cannot + be sent as notifications are appended to the **tool result** after the command's own payload, so no + host loses feedback. Initialize-era clients keep the previous session-wide behaviour, unchanged. + A caller reading only the first content block, or `StructuredContent`, is unaffected. +- **Discovery notifications are delivered by the SDK's own fan-out** rather than broadcast by Repl. + Initialize-era clients keep receiving unsolicited `*/list_changed`; a `2026-07-28` client receives + only the types it requested through `subscriptions/listen`, tagged with its listen request id. A + modern client that opens no subscription receives none — as the specification requires. List + results carry `ttlMs: 0`, so such a client re-lists on demand instead of caching. +- `.LongRunning()` remains Repl-local metadata and emits nothing on the protocol surface; SDK 2.x + removed the per-tool `Tool.Execution` augmentation. Protocol-level task support returns once Repl + integrates `ModelContextProtocol.Extensions.Tasks` (tracked in issue #72). + +### Compatibility notes — MCP + +- **Known limitation.** Soft roots ([`docs/mcp-advanced.md`](docs/mcp-advanced.md#soft-roots-fallback)) + set by one connection are visible to every other connection created from the same + `BuildMcpServerOptions()` result. Client capabilities *are* isolated per request on that path; + cross-call state is not, because `2026-07-28` removed protocol sessions and that path has no + per-connection identity. Host one server per process (`mcp serve`), or take the workspace as an + explicit command argument. +- `docs/mcp-transports.md` previously claimed each connection has "its own I/O capture" and "its own + session-aware routing state". I/O capture is per *invocation*, and session-aware routing state + exists only under `mcp serve`. The doc now states what is isolated at which boundary. + ### Added — option visibility - `.Hidden(bool isHidden = true)` on the option builder (`WithOption(name, option => option.Hidden())`) 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..6473a80 100644 --- a/docs/mcp-advanced.md +++ b/docs/mcp-advanced.md @@ -20,6 +20,18 @@ 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 take the workspace as an +> **explicit command parameter**, or mint a handle from a setup command and pass it back — that is +> what SEP-2567 prescribes now that the protocol has no sessions to hang such state on. See +> [mcp-reference.md](mcp-reference.md#sdk-and-protocol-versions) for the version posture. +> +> [Soft roots](#soft-roots-fallback) are **not** the modern answer: they are the same +> connection-scoped state by another name, and they are scoped to the process rather than the +> connection when a host reuses one `BuildMcpServerOptions()` result. Treat them as a legacy +> compatibility feature for clients that lack native roots. + 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`. @@ -42,7 +54,7 @@ app.Map("workspace roots", async (IMcpClientRoots roots, CancellationToken ct) = | `Current` | Current effective roots for the session | | `GetAsync()` | Refreshes native roots if supported | | `HasSoftRoots` | Fallback roots were initialized manually | -| `SetSoftRoots()` / `ClearSoftRoots()` | Manage fallback roots for the current session | +| `SetSoftRoots()` / `ClearSoftRoots()` | Manage fallback roots — per connection under `mcp serve`, per process when a host reuses one `BuildMcpServerOptions()` result | > **Why `IMcpClientRoots` is MCP-only:** Roots are session-scoped MCP data. They don't make sense as a generic `Repl.Core` concept for terminal or non-MCP execution. That's why the interface lives in `Repl.Mcp` and is injected only for MCP sessions. diff --git a/docs/mcp-agent-capabilities.md b/docs/mcp-agent-capabilities.md index cac9ce4..3997d06 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: @@ -238,12 +246,15 @@ public interface IMcpFeedback CancellationToken cancellationToken = default); ValueTask SendMessageAsync( - LoggingLevel level, + McpMessageLevel level, object? data, CancellationToken cancellationToken = default); } ``` +`McpMessageLevel` is Repl's own enum (`Debug` … `Emergency`), so this signature does not expose the +SDK's deprecated `LoggingLevel` to your build. + Use it when: - you need to control MCP progress/message notifications directly @@ -256,10 +267,10 @@ Use it when: app.Map("sync contacts", async (IMcpFeedback feedback, CancellationToken ct) => { - if (feedback.IsLoggingSupported) - { - await feedback.SendMessageAsync(LoggingLevel.Info, "Starting sync.", ct); - } + // Sending unconditionally is fine: a message the client cannot receive as a notification + // is carried back in the tool result instead. Check IsLoggingSupported only when you want + // to skip work that would otherwise be wasted. + await feedback.SendMessageAsync(McpMessageLevel.Info, "Starting sync.", ct); if (feedback.IsProgressSupported) { @@ -322,7 +333,9 @@ if (!elicitation.IsSupported) For `IMcpFeedback`, the same idea applies: - check `IsProgressSupported` before sending MCP-only progress directly -- check `IsLoggingSupported` before sending MCP-only messages directly +- `IsLoggingSupported` tells you whether a message would arrive as a **notification**; it is `false` + on `2026-07-28` unless the request declared a log level. Messages are never dropped for that + reason — they ride back in the tool result — so treat it as a hint, not a gate - prefer `IReplInteractionChannel` when the feedback should still render well outside MCP ## Client compatibility 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..c70e5bd 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -512,6 +512,14 @@ 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) shipped experimentally in the SDK 2.0 preview line and is stable as of 2.2.0; Repl has not adopted it yet. +- **Discovery notifications** follow the negotiated revision. Repl drives the SDK's own fan-out rather than broadcasting itself, so an initialize-era client keeps receiving unsolicited `*/list_changed` while a `2026-07-28` client receives only the notification types it requested through `subscriptions/listen`, each tagged with its listen request id (SEP-2575). A modern client that opens no subscription receives none — which is what the specification requires. List results carry `ttlMs: 0`, so such a client re-lists on demand rather than caching. +- **User feedback** (`notice` / `warning` / `problem`, and `IMcpFeedback.SendMessageAsync`) follows the same split. On `2026-07-28`, `logging/setLevel` is gone and a server must not emit `notifications/message` for a request that declared no `_meta/io.modelcontextprotocol/logLevel`. Messages that cannot be delivered as notifications are appended to the **tool result** instead, after the command's own payload, so no host loses them. Initialize-era clients keep the session-wide `logging/setLevel` behaviour unchanged. Note that the SDK's own client cannot request a level on `2026-07-28` at all, so in practice modern hosts see feedback in the tool result. +- **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/docs/mcp-transports.md b/docs/mcp-transports.md index e8d71c4..5ed42a8 100644 --- a/docs/mcp-transports.md +++ b/docs/mcp-transports.md @@ -46,6 +46,16 @@ async Task HandleConnectionAsync(Stream input, Stream output, CancellationToken } ``` +Client capabilities (sampling, elicitation, roots) resolve per **request** on this path, so each +connection sees its own — that is a property of the request, not of the options instance. + +> **Known limitation:** cross-call state does not. The options carry one command catalog, so +> [soft roots](mcp-advanced.md#soft-roots-fallback) set by one connection are visible to every other +> connection built from the same options. The `2026-07-28` revision removed protocol-level sessions, +> and this path has no per-connection identity to hang that state on. If your commands rely on soft +> roots, host one server per process (`mcp serve`) or pass the workspace as an explicit command +> argument. + ## Scenario B: MCP-over-HTTP The MCP spec also defines an HTTP transport. For that, you typically host MCP inside ASP.NET Core rather than through `mcp serve`. @@ -64,14 +74,19 @@ var mcpOptions = app.Core.BuildMcpServerOptions(configure: o => You can then pass those options to the MCP SDK's HTTP integration. -## Session isolation +## What is isolated, and at which boundary -Each connection or HTTP session is isolated: +The `2026-07-28` revision removed protocol-level sessions: the client declares its capabilities on +every request rather than once per connection. So the boundaries are not all the same size. -- its own MCP session -- its own I/O capture -- its own session-aware routing state +| Isolated per | What | +|---|---| +| Request | Client capabilities, the requested log level, and the destination for sampling, elicitation and progress | +| Invocation | I/O capture — each tool call gets its own capture scope, not one per connection | +| Connection (`mcp serve` only) | The MCP session object, the native roots cache, soft roots, and session-aware routing state | -That matters especially when using dynamic tools, roots, or session-specific modules. +A server created from a reused `BuildMcpServerOptions()` result has the first two but not the third; +see the known limitation above. That matters especially when using dynamic tools, roots, or +session-specific modules. For those higher-level patterns, see [mcp-advanced.md](mcp-advanced.md). 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..fda746a 100644 --- a/src/Repl.Mcp/IMcpFeedback.cs +++ b/src/Repl.Mcp/IMcpFeedback.cs @@ -1,4 +1,3 @@ -using ModelContextProtocol.Protocol; using Repl.Interaction; namespace Repl.Mcp; @@ -17,8 +16,15 @@ public interface IMcpFeedback bool IsProgressSupported { get; } /// - /// Gets a value indicating whether the connected MCP client can receive logging/message notifications. + /// Gets a value indicating whether a message sent for the current request would reach the + /// connected MCP client as a notification. /// + /// + /// On the 2026-07-28 revision this is unless the request declared + /// a log level in its metadata, because the specification forbids emitting message notifications + /// for a request that did not ask for them. A message sent while this is + /// is not lost: it is carried back in the tool result instead. + /// bool IsLoggingSupported { get; } /// @@ -29,10 +35,11 @@ ValueTask ReportProgressAsync( CancellationToken cancellationToken = default); /// - /// Sends a structured MCP message notification to the connected client. + /// Sends a message to the connected client, as a notification when the request asked for one and + /// otherwise as part of the tool result. /// ValueTask SendMessageAsync( - LoggingLevel level, + McpMessageLevel level, object? data, CancellationToken cancellationToken = default); } diff --git a/src/Repl.Mcp/McpClientRootsService.cs b/src/Repl.Mcp/McpClientRootsService.cs index fa53d4e..6c84d4a 100644 --- a/src/Repl.Mcp/McpClientRootsService.cs +++ b/src/Repl.Mcp/McpClientRootsService.cs @@ -1,24 +1,35 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +// Deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005); kept for existing hosts. +// Rationale and successor: docs/mcp-reference.md#sdk-and-protocol-versions (#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 +53,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..c1d2a90 100644 --- a/src/Repl.Mcp/McpFeedbackService.cs +++ b/src/Repl.Mcp/McpFeedbackService.cs @@ -5,24 +5,25 @@ using ModelContextProtocol.Server; using Repl.Interaction; +// Deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005); kept for existing hosts. +// Rationale and successor: docs/mcp-reference.md#sdk-and-protocol-versions (#51). +#pragma warning disable MCP9005 + namespace Repl.Mcp; /// -/// Internal implementation of backed by a live session. +/// Internal implementation of backed by the flowing MCP request. /// -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; + private readonly AsyncLocal _undelivered = new(); - 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 => ResolveThreshold() is not null; public async ValueTask ReportProgressAsync( ReplProgressEvent progress, @@ -30,13 +31,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 { @@ -48,31 +53,101 @@ public async ValueTask ReportProgressAsync( } public async ValueTask SendMessageAsync( - LoggingLevel level, + McpMessageLevel level, object? data, CancellationToken cancellationToken = default) { - if (!IsLoggingSupported) + // Single read of both the server and the threshold: a concurrent request re-binding the + // accessor must not be observed between the decision and the send. + var server = servers.Effective; + var threshold = ResolveThreshold(); + + if (server is null || threshold is null || level < threshold) { + // Either the client cannot receive notifications for this request, or the message is + // below the level it asked for. Below-threshold messages are dropped as requested; + // undeliverable ones are kept so the tool result can carry them instead. + if (threshold is null) + { + _undelivered.Value?.Add(level, data); + } + return; } - await _server!.SendNotificationAsync( + await server.SendNotificationAsync( NotificationMethods.LoggingMessageNotification, new LoggingMessageNotificationParams { - Level = level, + Level = (LoggingLevel)level, Logger = LoggerName, Data = SerializeData(data), }, cancellationToken: cancellationToken).ConfigureAwait(false); } - internal void AttachServer(McpServer server) => _server = server; + /// + /// Resolves the severity threshold the current request asked for, or when + /// it asked for nothing and no notification may be sent. + /// + /// + /// The 2026-07-28 revision (SEP-2575) replaced logging/setLevel with a per-request + /// _meta/io.modelcontextprotocol/logLevel field and states that a server MUST NOT emit + /// message notifications for a request that omitted it. The SDK parses the field onto the message + /// context but consumes it nowhere, so the filtering is Repl's to do. + /// + /// Initialize-era revisions keep the session-wide logging/setLevel semantics, including the + /// historical behaviour of sending everything when the client never set a level — those hosts must + /// not lose feedback to a rule that does not apply to them. + /// + /// + /// Note that the SDK's own CLIENT cannot currently ask for notifications on 2026-07-28: it + /// rejects logging/setLevel on that revision, exposes no option for the level, and replaces + /// a caller's _meta with its own three keys (protocol version, client info, capabilities). + /// So in practice this returns for every SDK-client request on the modern + /// revision, which is exactly why undeliverable messages are carried back in the tool result. + /// + /// + private McpMessageLevel? ResolveThreshold() + { + if (servers.Current is not { } request) + { + return null; + } + + var context = (request.JsonRpcMessage as JsonRpcRequest)?.Context; + if (context?.LogLevel is { } requestedLevel) + { + return (McpMessageLevel)requestedLevel; + } + + if (IsSessionlessRevision(context?.ProtocolVersion)) + { + return null; + } + + return request.Server.LoggingLevel is { } sessionLevel + ? (McpMessageLevel)sessionLevel + : McpMessageLevel.Debug; + } + + private static bool IsSessionlessRevision(string? protocolVersion) => + string.Equals(protocolVersion, McpProtocolRevisions.Sessionless, StringComparison.Ordinal); internal IDisposable PushProgressToken(ProgressToken? progressToken) => new ProgressTokenScope(_progressToken, progressToken); + /// + /// Opens a scope that collects messages the current request cannot receive as notifications. + /// + /// + /// Pushed per invocation by and drained into the tool result, so a + /// client that never asked for log notifications still sees what a command reported. The buffer + /// is rather than a field on + /// because a command can inject directly and bypass the channel. + /// + internal UndeliveredMessageScope PushUndeliveredMessages() => new(_undelivered); + private static JsonElement SerializeData(object? data) => data switch { @@ -89,6 +164,70 @@ private static string BuildProgressMessage(ReplProgressEvent progress) => ? progress.Label : $"{progress.Label}: {progress.Details}"; + /// Messages collected for a request that cannot receive notifications. + internal sealed class UndeliveredMessages + { + private readonly List _lines = []; + + public void Add(McpMessageLevel level, object? data) + { + var text = data as string ?? data?.ToString(); + if (string.IsNullOrWhiteSpace(text)) + { + return; + } + + lock (_lines) + { + _lines.Add($"[{level.ToString().ToLowerInvariant()}] {text}"); + } + } + + public IReadOnlyList Drain() + { + lock (_lines) + { + if (_lines.Count == 0) + { + return []; + } + + var drained = _lines.ToArray(); + _lines.Clear(); + return drained; + } + } + } + + /// Restores the previous buffer on dispose, so nested invocations stay independent. + internal sealed class UndeliveredMessageScope : IDisposable + { + private readonly AsyncLocal _slot; + private readonly UndeliveredMessages? _previous; + private bool _disposed; + + public UndeliveredMessageScope(AsyncLocal slot) + { + _slot = slot; + _previous = slot.Value; + Messages = new UndeliveredMessages(); + slot.Value = Messages; + } + + public UndeliveredMessages Messages { get; } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _slot.Value = _previous; + _disposed = true; + } + } + private sealed class ProgressTokenScope : IDisposable { private readonly AsyncLocal _progressTokenSlot; diff --git a/src/Repl.Mcp/McpInteractionChannel.cs b/src/Repl.Mcp/McpInteractionChannel.cs index ab6531a..af8bd10 100644 --- a/src/Repl.Mcp/McpInteractionChannel.cs +++ b/src/Repl.Mcp/McpInteractionChannel.cs @@ -1,10 +1,14 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Nodes; using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Repl.Interaction; +// Deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005); kept for existing hosts. +// Rationale and successor: docs/mcp-reference.md#sdk-and-protocol-versions (#51). +#pragma warning disable MCP9005 + namespace Repl.Mcp; /// @@ -221,7 +225,7 @@ await _server.NotifyProgressAsync( public async ValueTask WriteStatusAsync(string text, CancellationToken cancellationToken) { await SendFeedbackAsync( - LoggingLevel.Info, + McpMessageLevel.Info, JsonSerializer.SerializeToElement(text, McpJsonContext.Default.String), cancellationToken) .ConfigureAwait(false); @@ -240,24 +244,24 @@ public ValueTask DispatchAsync( { WriteStatusRequest status => CompleteBuiltInDispatchAsync( SendFeedbackAsync( - LoggingLevel.Info, + McpMessageLevel.Info, JsonSerializer.SerializeToElement(status.Text, McpJsonContext.Default.String), cancellationToken)), WriteProgressRequest progress => CompleteBuiltInDispatchAsync( WriteStructuredProgressAsync(progress, cancellationToken)), WriteNoticeRequest notice => CompleteBuiltInDispatchAsync( SendFeedbackAsync( - LoggingLevel.Info, + McpMessageLevel.Info, JsonSerializer.SerializeToElement(notice.Text, McpJsonContext.Default.String), cancellationToken)), WriteWarningRequest warning => CompleteBuiltInDispatchAsync( SendFeedbackAsync( - LoggingLevel.Warning, + McpMessageLevel.Warning, JsonSerializer.SerializeToElement(warning.Text, McpJsonContext.Default.String), cancellationToken)), WriteProblemRequest problem => CompleteBuiltInDispatchAsync( SendFeedbackAsync( - LoggingLevel.Error, + McpMessageLevel.Error, SerializeProblem(problem), cancellationToken)), _ => throw new NotSupportedException( @@ -266,30 +270,19 @@ public ValueTask DispatchAsync( } private async ValueTask SendFeedbackAsync( - LoggingLevel level, + McpMessageLevel level, JsonElement data, CancellationToken cancellationToken) { + // Always through IMcpFeedback: it owns the per-request log-level rule (2026-07-28 forbids + // emitting message notifications for a request that did not ask for them) and the buffer that + // carries undeliverable messages back in the tool result. Sending straight to the server here + // would bypass both. It is absent only for the discovery-only channel, which has no server + // to send to either. if (_feedback is not null) { await _feedback.SendMessageAsync(level, data, cancellationToken).ConfigureAwait(false); - return; } - - if (_server is null) - { - return; - } - - await _server.SendNotificationAsync( - NotificationMethods.LoggingMessageNotification, - new LoggingMessageNotificationParams - { - Level = level, - Logger = "repl.interaction", - Data = data, - }, - cancellationToken: cancellationToken).ConfigureAwait(false); } private async ValueTask WriteStructuredProgressAsync( @@ -310,7 +303,7 @@ await _feedback.ReportProgressAsync( if (progress.State == ReplProgressState.Warning) { await _feedback.SendMessageAsync( - LoggingLevel.Warning, + McpMessageLevel.Warning, BuildProgressPayload(progress), cancellationToken) .ConfigureAwait(false); @@ -318,7 +311,7 @@ await _feedback.SendMessageAsync( else if (progress.State == ReplProgressState.Error) { await _feedback.SendMessageAsync( - LoggingLevel.Error, + McpMessageLevel.Error, BuildProgressPayload(progress), cancellationToken) .ConfigureAwait(false); diff --git a/src/Repl.Mcp/McpMessageLevel.cs b/src/Repl.Mcp/McpMessageLevel.cs new file mode 100644 index 0000000..9bae5f7 --- /dev/null +++ b/src/Repl.Mcp/McpMessageLevel.cs @@ -0,0 +1,37 @@ +namespace Repl.Mcp; + +/// +/// Severity of a message sent to the connected MCP client through . +/// +/// +/// This mirrors the protocol's syslog-derived severities. It exists as a Repl-owned type so the +/// public surface does not expose the SDK's LoggingLevel, which the 2026-07-28 +/// specification deprecates (SEP-2577, SDK diagnostic MCP9005): a consumer building with warnings +/// as errors would otherwise fail on a Repl signature it never chose to depend on. +/// +public enum McpMessageLevel +{ + /// Detailed information, useful only when diagnosing a problem. + Debug = 0, + + /// Normal operational information. + Info = 1, + + /// A normal but significant condition. + Notice = 2, + + /// A condition that is not an error but deserves attention. + Warning = 3, + + /// An error that did not prevent the operation from continuing. + Error = 4, + + /// A condition that requires immediate attention. + Critical = 5, + + /// Action must be taken immediately. + Alert = 6, + + /// The system is unusable. + Emergency = 7, +} diff --git a/src/Repl.Mcp/McpProtocolRevisions.cs b/src/Repl.Mcp/McpProtocolRevisions.cs new file mode 100644 index 0000000..eda2273 --- /dev/null +++ b/src/Repl.Mcp/McpProtocolRevisions.cs @@ -0,0 +1,22 @@ +namespace Repl.Mcp; + +/// +/// MCP protocol revisions Repl reasons about explicitly. +/// +/// +/// The SDK's own McpProtocolVersions class is internal, so the literals have to live here. +/// +internal static class McpProtocolRevisions +{ + /// + /// The last revision built on the initialize handshake, and therefore the last one with + /// protocol-level sessions. + /// + public const string LastWithSessions = "2025-11-25"; + + /// + /// The revision that removed protocol sessions (SEP-2567) and moved per-call state — client + /// capabilities, log level — into per-request _meta (SEP-2575). + /// + public const string Sessionless = "2026-07-28"; +} diff --git a/src/Repl.Mcp/McpRequestServerAccessor.cs b/src/Repl.Mcp/McpRequestServerAccessor.cs new file mode 100644 index 0000000..18d3018 --- /dev/null +++ b/src/Repl.Mcp/McpRequestServerAccessor.cs @@ -0,0 +1,35 @@ +using ModelContextProtocol.Server; + +namespace Repl.Mcp; + +/// +/// Resolves the a capability call must target, from the flowing request. +/// +/// +/// On the 2026-07-28 revision there is no initialize handshake: the client declares its +/// capabilities per request in _meta, and the SDK surfaces them only on the destination-bound +/// server handed to a handler — is documented as +/// on the root server. Capability resolution is therefore a property of the +/// REQUEST, not of the connection. +/// +/// The whole is bound rather than just its server, because the same +/// per-request metadata carries more than the destination (see the log level in +/// ). flows with the invocation and cannot +/// leak across requests. There is deliberately no session-level fallback: a shared field would hand +/// out the capabilities of whichever connection attached last, which is precisely the cross-wiring +/// this type exists to prevent. +/// +/// +internal sealed class McpRequestServerAccessor +{ + private readonly AsyncLocal _current = new(); + + /// The request currently flowing on this async context, if any. + public MessageContext? Current => _current.Value; + + /// Server for the flowing request, or outside a request. + public McpServer? Effective => _current.Value?.Server; + + /// Binds the flowing async context to the request being served. + public void BindRequest(MessageContext request) => _current.Value = request; +} diff --git a/src/Repl.Mcp/McpSamplingService.cs b/src/Repl.Mcp/McpSamplingService.cs index 5e6a09e..568420e 100644 --- a/src/Repl.Mcp/McpSamplingService.cs +++ b/src/Repl.Mcp/McpSamplingService.cs @@ -1,28 +1,32 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +// Deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005); kept for existing hosts. +// Rationale and successor: docs/mcp-reference.md#sdk-and-protocol-versions (#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 +44,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..08d7a00 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Nodes; using ModelContextProtocol; @@ -27,29 +27,40 @@ 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); + // Context for work that belongs to no MCP session: eager fail-fast validation, the pre-built + // catalog behind BuildMcpServerOptions, and the snapshot test seams. Each of those used to mint + // its own throwaway context — five roots services and five never-disposed semaphores dropped on + // the floor. It shares the handler's lifetime, so nothing disposes it either; unlike a session + // context there is never more than one. + private readonly McpSessionContext _catalogContext; 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 = []; 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 - // indefinitely, since nothing awaits it and it would otherwise pile up one task per - // invalidation forever. - private static readonly TimeSpan NotificationSendTimeout = TimeSpan.FromSeconds(5); + // Discovery-change signals. These collections stay EMPTY and never contribute a primitive to a + // list response: they exist only so the SDK's own fan-out runs, because that is the only code + // with access to the subscription registry. On 2026-07-28 it delivers each notification type + // ONLY to clients that requested it through subscriptions/listen, over that request's stream and + // tagged with its id, while still broadcasting session-wide to initialize-era clients. Every + // McpServer built from the options subscribes on construction and unsubscribes on dispose, which + // is what makes one shared instance correct across concurrent connections. + private readonly McpServerPrimitiveCollection _toolListChanged = new(); + private readonly McpServerResourceCollection _resourceListChanged = new(); + private readonly McpServerPrimitiveCollection _promptListChanged = new(); public McpServerHandler( ICoreReplApp app, @@ -61,21 +72,53 @@ 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); + _catalogContext = CreateSessionContext(); } + 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; + } + + /// + /// Recovers the session a request belongs to, through the provider handed to + /// McpServer.Create — even a destination-bound per-request server exposes its session's + /// services. + /// + /// + /// A pure lookup on purpose. This used to fall back to one lazily created context shared by every + /// caller that missed the lookup, which made _sessions permanently non-empty — so the + /// routing subscription and its debounce timer could never be released — and latched a + /// destination-bound per-request server as if it were a session server. The fallback was also + /// unreachable: the only server built without a session provider comes from + /// , whose pre-built primitives never route through here. + /// + private static McpSessionContext ResolveContext(McpServer? requestServer) => + requestServer?.Services?.GetService(typeof(McpSessionContext)) as McpSessionContext + ?? throw new InvalidOperationException( + "An MCP request reached a Repl handler without its session context. Handlers are only " + + "registered by BuildDynamicServerOptions, whose server is always created with the " + + "session's own service provider."); + [UnconditionalSuppressMessage( "Trimming", "IL2026", @@ -89,8 +132,9 @@ public async Task RunAsync(IReplIoContext io, CancellationToken ct) : new StdioServerTransport(serverName); try { - var server = McpServer.Create(transport, serverOptions, serviceProvider: _sessionServices); - AttachServer(server); + using var context = CreateSessionContext(); + var server = McpServer.Create(transport, serverOptions, serviceProvider: context.Services); + AttachSession(context, server); try { @@ -98,7 +142,7 @@ public async Task RunAsync(IReplIoContext io, CancellationToken ct) } finally { - UnsubscribeFromRoutingChanges(); + DetachSession(context); await server.DisposeAsync().ConfigureAwait(false); } } @@ -118,7 +162,7 @@ internal McpServerOptions BuildDynamicServerOptions() // then repeated for the same commands during the first discovery request. if (_options.CommandFilter is null) { - _ = CreateDocumentationModel(); + _ = CreateDocumentationModel(_catalogContext.Services); } return new McpServerOptions @@ -135,6 +179,11 @@ internal McpServerOptions BuildDynamicServerOptions() ListPromptsHandler = ListPromptsAsync, GetPromptHandler = GetPromptAsync, }, + // Empty on purpose: collections augment the handlers rather than replace them, so the + // tool graph still comes entirely from the handlers above. See the field declarations. + ToolCollection = _toolListChanged, + ResourceCollection = _resourceListChanged, + PromptCollection = _promptListChanged, }; } @@ -142,7 +191,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(_catalogContext); return new McpServerOptions { @@ -154,10 +203,10 @@ internal McpServerOptions BuildStaticServerOptions() }; } - internal McpGeneratedSnapshot BuildSnapshotForTests() => BuildSnapshotCore(); + internal McpGeneratedSnapshot BuildSnapshotForTests() => BuildSnapshotCore(_catalogContext); internal async Task BuildSnapshotForTestsAsync(CancellationToken cancellationToken = default) => - await GetSnapshotAsync(server: null, cancellationToken).ConfigureAwait(false); + await GetSnapshotAsync(_catalogContext, cancellationToken).ConfigureAwait(false); private string? ResolveAppName() { @@ -170,13 +219,14 @@ private async ValueTask ListToolsAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + BindRequest(request); + 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) + && context.TryClaimCompatibilityIntro()) { - _ = SendNotificationSafeAsync(NotificationMethods.ToolListChangedNotification); + SignalToolListChanged(); return new ListToolsResult { Tools = @@ -197,8 +247,9 @@ private async ValueTask CallToolAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + BindRequest(request); + 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 +280,9 @@ private async ValueTask ListResourcesAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + BindRequest(request); + var context = ResolveContext(request.Server); + var snapshot = await GetSnapshotAsync(context, cancellationToken).ConfigureAwait(false); return new ListResourcesResult { Resources = @@ -246,8 +298,9 @@ private async ValueTask ListResourceTemplatesAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + BindRequest(request); + var context = ResolveContext(request.Server); + var snapshot = await GetSnapshotAsync(context, cancellationToken).ConfigureAwait(false); return new ListResourceTemplatesResult { ResourceTemplates = @@ -263,8 +316,9 @@ private async ValueTask ReadResourceAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + BindRequest(request); + 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 +333,9 @@ private async ValueTask ListPromptsAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + BindRequest(request); + 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 +346,9 @@ private async ValueTask GetPromptAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + BindRequest(request); + 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 +360,32 @@ 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.SnapshotCache is { } cached && cached.Version == snapshotVersion) { - return cached; + return cached.Snapshot; } - 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.SnapshotCache is { } refreshed && refreshed.Version == snapshotVersion) { - return refreshed; + return refreshed.Snapshot; } - var previousSnapshot = _snapshot; + var previousSnapshot = context.SnapshotCache?.Snapshot; try { - return await BuildCurrentSnapshotAsync(snapshotVersion, cancellationToken).ConfigureAwait(false); + return await BuildCurrentSnapshotAsync(context, snapshotVersion, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -344,17 +399,17 @@ private async ValueTask GetSnapshotAsync( catch (Exception) when ( previousSnapshot is not null && Volatile.Read(ref _snapshotState).LastVisibilityRetractionVersion - <= Volatile.Read(ref _builtSnapshotVersion)) + <= (context.SnapshotCache?.Version ?? McpSessionContext.SnapshotCacheEntry.StaleVersion)) { - // Preserve availability for transient projection failures, but leave the version dirty - // so the next request retries without requiring another routing mutation. - _snapshot = previousSnapshot; + // Preserve availability for transient projection failures, but republish as stale so the + // next request retries without requiring another routing mutation. + context.PublishStaleSnapshot(previousSnapshot); return previousSnapshot; } } finally { - _snapshotGate.Release(); + context.SnapshotGate.Release(); } } @@ -377,14 +432,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 +452,40 @@ private async ValueTask BuildCurrentSnapshotAsync( continue; } - _snapshot = built; + // One publication either way: a build that raced a routing bump is still serve-able, but + // is marked stale so the next request rebuilds it. if (observedState.Version == snapshotVersion) { - Volatile.Write(ref _builtSnapshotVersion, snapshotVersion); + context.PublishSnapshot(built, snapshotVersion); + } + else + { + context.PublishStaleSnapshot(built); } 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, _requestServers); 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 +494,9 @@ private ReplDocumentationModel CreateDocumentationModel() ReplSessionIO.IsProgrammatic = true; try { - return coreApp.CreateDocumentationModel(CreateDiscoveryServices(), IsMcpCandidateBeforeValidation); + return coreApp.CreateDocumentationModel( + CreateDiscoveryServices(sessionServices), + IsMcpCandidateBeforeValidation); } finally { @@ -441,9 +507,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 +536,23 @@ private void ValidateCompatibilityToolNames(IReadOnlyList tools) } } - private void AttachServer(McpServer? server) + // Request-level binding: capability services resolve the flowing request 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. The whole request is bound, not just its + // server, because 2026-07-28 carries the client's capabilities and log level in per-request + // _meta. Session-level concerns are handled by AttachSession (RunAsync). + private void BindRequest(MessageContext request) => _requestServers.BindRequest(request); + + // 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) { - if (server is null) - { - return; - } - lock (_attachLock) { - if (ReferenceEquals(_server, server)) - { - return; - } - - _server = server; - _roots.AttachServer(server); - _sampling.AttachServer(server); - _elicitation.AttachServer(server); - _feedback.AttachServer(server); + _sessions.Add(context); EnsureRoutingSubscription(); - EnsureRootsNotificationHandler(server); + EnsureRootsNotificationHandler(server, context.Roots); } } @@ -522,25 +584,39 @@ 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); + 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,49 +650,41 @@ 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) + { + session.ResetCompatibilityIntro(); + } + } } lock (_refreshLock) { _debounceTimer?.Dispose(); _debounceTimer = _timeProvider.CreateTimer( - _ => _ = SendDiscoveryNotificationsSafeAsync(), + _ => SignalDiscoveryChanged(), state: null, dueTime: DebounceDelay, period: Timeout.InfiniteTimeSpan); } } - private async Task SendDiscoveryNotificationsSafeAsync() + private void SignalDiscoveryChanged() { - await SendNotificationSafeAsync(NotificationMethods.ToolListChangedNotification).ConfigureAwait(false); - await SendNotificationSafeAsync(NotificationMethods.ResourceListChangedNotification).ConfigureAwait(false); - await SendNotificationSafeAsync(NotificationMethods.PromptListChangedNotification).ConfigureAwait(false); + _toolListChanged.Clear(); + _resourceListChanged.Clear(); + _promptListChanged.Clear(); } - 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) - { - // Notifications are best-effort. Cancellation is not actionable here. - } - catch (Exception) - { - // Notifications are best-effort. The next list/read request will rebuild on demand. - } - } + // Clearing an already-empty primitive collection raises its Changed event without mutating + // anything, which is what lets an empty collection act as a pure signal. That the event fires + // unconditionally is NOT documented on Clear(), so it is pinned by + // Given_McpSubscriptions.When_ClearingAnEmptyCollection_Then_ChangedStillFires: if a future SDK + // turns Clear() into a no-op, that test fails loudly instead of discovery notifications silently + // disappearing. + private void SignalToolListChanged() => _toolListChanged.Clear(); private void UnsubscribeFromRoutingChanges() { @@ -635,6 +703,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 +714,7 @@ private ServerCapabilities BuildCapabilities() Resources = new ResourcesCapability { ListChanged = true }, Prompts = new PromptsCapability { ListChanged = true }, }; +#pragma warning restore MCP9005 if (_options.EnableApps || HasMcpAppResources()) { @@ -674,8 +747,9 @@ private bool HasMcpAppResources() ReplSessionIO.IsProgrammatic = true; try { + var sessionServices = _catalogContext.Services; using var runtimeStateScope = coreApp.PushRuntimeState( - CreateDiscoveryServices(), + CreateDiscoveryServices(sessionServices), isInteractiveSession: false); var activeGraph = coreApp.ResolveActiveRoutingGraph(); var commands = coreApp.ResolveDiscoverableRoutes( @@ -941,7 +1015,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 +1069,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..373b69b --- /dev/null +++ b/src/Repl.Mcp/McpSessionContext.cs @@ -0,0 +1,88 @@ +using ModelContextProtocol.Server; + +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 : IDisposable +{ + private SnapshotCacheEntry? _snapshotCache; + private int _compatibilityIntroServed; + + 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; } + + /// Serializes snapshot builds for this session. + public SemaphoreSlim SnapshotGate { get; } = new(initialCount: 1, maxCount: 1); + + /// + /// Cached snapshot paired with the routing version it was built at, or + /// before this session's first build. + /// + public SnapshotCacheEntry? SnapshotCache => Volatile.Read(ref _snapshotCache); + + /// Publishes as current for . + public void PublishSnapshot(McpServerHandler.McpGeneratedSnapshot snapshot, long version) => + Volatile.Write(ref _snapshotCache, new SnapshotCacheEntry(snapshot, version)); + + /// + /// Publishes as serve-able but stale, so the next request rebuilds + /// without waiting for another routing mutation. + /// + public void PublishStaleSnapshot(McpServerHandler.McpGeneratedSnapshot snapshot) => + Volatile.Write(ref _snapshotCache, new SnapshotCacheEntry(snapshot, SnapshotCacheEntry.StaleVersion)); + + /// + /// Claims this session's one-time compatibility-shim intro; for the first + /// caller only. + /// + public bool TryClaimCompatibilityIntro() => + Interlocked.CompareExchange(ref _compatibilityIntroServed, 1, 0) == 0; + + /// Re-arms the compatibility-shim intro after a routing invalidation. + public void ResetCompatibilityIntro() => Interlocked.Exchange(ref _compatibilityIntroServed, 0); + + public void Dispose() => SnapshotGate.Dispose(); + + /// + /// A generated snapshot and the routing version it was built at, published as ONE value. + /// + /// + /// Held as two independent fields, a lock-free reader could observe the fresh version paired with + /// the previous snapshot and serve stale discovery state; one reference swapped with + /// release/acquire semantics removes the ordering question altogether. Writers are serialized by + /// , so a plain Volatile.Write suffices — unlike + /// , which races several threads and + /// therefore needs a compare-and-swap loop. + /// + internal sealed record SnapshotCacheEntry(McpServerHandler.McpGeneratedSnapshot Snapshot, long Version) + { + /// + /// Marks an entry serve-able but stale. Routing versions start at 1 and only increase, so this + /// can never equal a live version and the next request always rebuilds. + /// + public const long StaleVersion = 0; + } +} diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 5e79bf0..152d8aa 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.RegularExpressions; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -22,16 +22,33 @@ internal sealed partial class McpToolAdapter private readonly ICoreReplApp _app; private readonly ReplMcpServerOptions _options; private readonly IServiceProvider _services; + private readonly McpRequestServerAccessor _requestServers; private readonly System.Collections.Concurrent.ConcurrentDictionary _toolRoutes = new(StringComparer.OrdinalIgnoreCase); private readonly System.Collections.Concurrent.ConcurrentDictionary _staticToolResults = new(StringComparer.OrdinalIgnoreCase); - public McpToolAdapter(ICoreReplApp app, ReplMcpServerOptions options, IServiceProvider services) + public McpToolAdapter( + ICoreReplApp app, + ReplMcpServerOptions options, + IServiceProvider services, + McpRequestServerAccessor requestServers) { _app = app; _options = options; _services = services; + _requestServers = requestServers; } + /// + /// Binds the flowing async context to before dispatching a command. + /// + /// + /// The pre-built primitives ( and friends) are dispatched straight + /// by the SDK on the BuildMcpServerOptions path, bypassing 's + /// request handlers entirely. Without this, capability services resolved from DI would have no + /// request to resolve against and would report every client capability as unavailable. + /// + internal void BindRequest(MessageContext request) => _requestServers.BindRequest(request); + internal string ForcedOutputMimeType { get @@ -129,7 +146,8 @@ public async Task InvokeAsync( : $"Command failed with exit code {invocation.ExitCode}."; } - return BuildToolResult(output, invocation.ExitCode, _options.PagedResultTextMode); + return BuildToolResult( + output, invocation.ExitCode, _options.PagedResultTextMode, invocation.UndeliveredMessages); } internal async Task InvokeResourceAsync( @@ -203,8 +221,11 @@ private async Task ExecuteThroughPipelineAsync( { [typeof(IReplInteractionChannel)] = interactionChannel, }); - using var feedbackScope = (_services.GetService(typeof(IMcpFeedback)) as McpFeedbackService) - ?.PushProgressToken(progressToken); + var feedbackService = _services.GetService(typeof(IMcpFeedback)) as McpFeedbackService; + using var feedbackScope = feedbackService?.PushProgressToken(progressToken); + // Messages the client cannot receive as notifications ride back in the tool result instead, + // so no feedback is lost on a request that never asked for log notifications. + using var undeliveredScope = feedbackService?.PushUndeliveredMessages(); // Force JSON output — agents consume structured data, not human tables/banners. var effectiveTokens = new List(tokens.Count + 1) { $"--output:{ForcedOutputFormat}" }; @@ -229,21 +250,32 @@ private async Task ExecuteThroughPipelineAsync( var output = outputWriter.ToString().Trim(); var error = captureCommandOutput ? string.Empty : errorWriter.ToString().Trim(); - return new McpPipelineInvocation(output, error, exitCode); + var undelivered = undeliveredScope?.Messages.Drain() ?? []; + return new McpPipelineInvocation(output, error, exitCode, undelivered); } } internal readonly record struct McpResourceReadInvocation(string Text, string MimeType, bool IsError); - private readonly record struct McpPipelineInvocation(string Output, string Error, int ExitCode); + private readonly record struct McpPipelineInvocation( + string Output, + string Error, + int ExitCode, + IReadOnlyList UndeliveredMessages); - private static CallToolResult BuildToolResult(string output, int exitCode, McpPagedResultTextMode pagedTextMode) + private static CallToolResult BuildToolResult( + string output, + int exitCode, + McpPagedResultTextMode pagedTextMode, + IReadOnlyList undeliveredMessages) { if (exitCode == 0 && TryCreatePagedStructuredResult(output, out var structuredContent, out var summary)) { return new CallToolResult { - Content = [new TextContentBlock { Text = BuildPagedTextContent(output, summary, pagedTextMode) }], + Content = WithMessages( + new TextContentBlock { Text = BuildPagedTextContent(output, summary, pagedTextMode) }, + undeliveredMessages), StructuredContent = structuredContent, IsError = false, }; @@ -251,11 +283,39 @@ private static CallToolResult BuildToolResult(string output, int exitCode, McpPa return new CallToolResult { - Content = [new TextContentBlock { Text = output }], + Content = WithMessages(new TextContentBlock { Text = output }, undeliveredMessages), IsError = exitCode != 0, }; } + /// + /// Appends messages the client could not receive as notifications, as a trailing content block. + /// + /// + /// The command's own payload stays the FIRST block (and StructuredContent is untouched), so + /// a caller reading the primary result is unaffected. Only requests that asked for no log level + /// carry anything here — a client receiving message notifications would otherwise see each one + /// twice. Resource reads deliberately get no such block: their body must match the advertised + /// MIME type. + /// + private static List WithMessages( + TextContentBlock primary, + IReadOnlyList undeliveredMessages) + { + if (undeliveredMessages.Count == 0) + { + return [primary]; + } + + var blocks = new List(undeliveredMessages.Count + 1) { primary }; + foreach (var message in undeliveredMessages) + { + blocks.Add(new TextContentBlock { Text = message }); + } + + return blocks; + } + private static string BuildPagedTextContent( string serializedPage, string summary, 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/ReplMcpServerPrompt.cs b/src/Repl.Mcp/ReplMcpServerPrompt.cs index 8568f01..c002115 100644 --- a/src/Repl.Mcp/ReplMcpServerPrompt.cs +++ b/src/Repl.Mcp/ReplMcpServerPrompt.cs @@ -1,4 +1,4 @@ -using ModelContextProtocol; +using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Repl.Documentation; @@ -64,6 +64,8 @@ public override async ValueTask GetAsync( RequestContext request, CancellationToken cancellationToken = default) { + _adapter.BindRequest(request); + // Prompt arguments are already JsonElement — pass through directly. var jsonArgs = request.Params.Arguments is { } args ? new Dictionary(args, StringComparer.Ordinal) diff --git a/src/Repl.Mcp/ReplMcpServerResource.cs b/src/Repl.Mcp/ReplMcpServerResource.cs index bd97970..9b0fad8 100644 --- a/src/Repl.Mcp/ReplMcpServerResource.cs +++ b/src/Repl.Mcp/ReplMcpServerResource.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.RegularExpressions; using ModelContextProtocol; using ModelContextProtocol.Protocol; @@ -68,6 +68,7 @@ public override async ValueTask ReadAsync( RequestContext request, CancellationToken cancellationToken = default) { + _adapter.BindRequest(request); var arguments = ExtractArguments(request.Params.Uri); var result = await _adapter.InvokeResourceAsync( diff --git a/src/Repl.Mcp/ReplMcpServerTool.cs b/src/Repl.Mcp/ReplMcpServerTool.cs index cb4b1c0..b3aa7b2 100644 --- a/src/Repl.Mcp/ReplMcpServerTool.cs +++ b/src/Repl.Mcp/ReplMcpServerTool.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Repl.Documentation; @@ -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; @@ -52,6 +49,7 @@ public override async ValueTask InvokeAsync( RequestContext request, CancellationToken cancellationToken = default) { + _adapter.BindRequest(request); var arguments = request.Params.Arguments ?? new Dictionary(StringComparer.Ordinal); var progressToken = request.Params.ProgressToken; diff --git a/src/Repl.Mcp/ReplMcpServerUiResource.cs b/src/Repl.Mcp/ReplMcpServerUiResource.cs index 5c49059..9ae90d7 100644 --- a/src/Repl.Mcp/ReplMcpServerUiResource.cs +++ b/src/Repl.Mcp/ReplMcpServerUiResource.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.RegularExpressions; using ModelContextProtocol; using ModelContextProtocol.Protocol; @@ -57,6 +57,7 @@ public override async ValueTask ReadAsync( RequestContext request, CancellationToken cancellationToken = default) { + _adapter.BindRequest(request); var arguments = ExtractArguments(request.Params.Uri); var result = await _adapter.InvokeAsync( 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..18c8de0 100644 --- a/src/Repl.McpTests/Given_McpConcurrentSessions.cs +++ b/src/Repl.McpTests/Given_McpConcurrentSessions.cs @@ -1,10 +1,233 @@ +using ModelContextProtocol; +using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; +using Repl.Mcp; namespace Repl.McpTests; [TestClass] public sealed class Given_McpConcurrentSessions { + [TestMethod] + [Description("Pins the protocol revision every other guarantee in this file is written against: two sessions sharing one handler must both negotiate 2026-07-28. Without this, a silent fallback to the 2025-11-25 initialize handshake would make the per-session capability and catalog assertions below describe a revision they were never meant to characterise — which is exactly how four review waves missed the sessionless-protocol defects.")] + public async Task When_TwoSessionsShareOneHandler_Then_BothNegotiateTheModernRevision() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("alpha", () => "a"); + var handler = CreateHandler(app); + using var cts = new CancellationTokenSource(); + + var sessionA = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var scopeA = sessionA.ConfigureAwait(false); + var sessionB = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var scopeB = sessionB.ConfigureAwait(false); + + sessionA.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + sessionB.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + } + + [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 handler = CreateHandler(app); + using var cts = new CancellationTokenSource(); + + var sessionA = await StartSessionAsync(handler, BuildSamplingClientOptions(), cts.Token).ConfigureAwait(false); + await using var scopeA = sessionA.ConfigureAwait(false); + var sessionB = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var scopeB = sessionB.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 = sessionA.Client.CallToolAsync( + "probe", new Dictionary(StringComparer.Ordinal), cancellationToken: cts.Token); + (await entered.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false)).Should().BeTrue(); + + await sessionB.Client.CallToolAsync( + "poke", new Dictionary(StringComparer.Ordinal), cancellationToken: cts.Token) + .ConfigureAwait(false); + + gate.Release(); + var probeResult = await probeTask.ConfigureAwait(false); + + probeResult.Content.OfType().First().Text.Should().Contain("True|True"); + } + + [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 handler = CreateHandler(app); + using var cts = new CancellationTokenSource(); + + var sessionA = await StartSessionAsync(handler, BuildRootsClientOptions("file:///ga"), cts.Token).ConfigureAwait(false); + await using var scopeA = sessionA.ConfigureAwait(false); + var sessionB = await StartSessionAsync(handler, BuildRootsClientOptions("file:///bu"), cts.Token).ConfigureAwait(false); + await using var scopeB = sessionB.ConfigureAwait(false); + + var resultA = await sessionA.Client.CallToolAsync( + toolName: "roots", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token).ConfigureAwait(false); + var resultB = await sessionB.Client.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"); + } + + [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. The surviving session subscribes through subscriptions/listen, which is how a 2026-07-28 client asks for the notification at all.")] + public async Task When_FirstSessionCloses_Then_SurvivingSessionStillReceivesRoutingNotifications() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("alpha", () => "a"); + var handler = CreateHandler(app); + using var cts = new CancellationTokenSource(); + + var sessionA = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + var sessionB = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var scopeB = sessionB.ConfigureAwait(false); + + var listChanged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var registration = sessionB.Client.RegisterNotificationHandler( + NotificationMethods.ToolListChangedNotification, + (_, _) => + { + listChanged.TrySetResult(); + return ValueTask.CompletedTask; + }); + await using var scopeRegistration = registration.ConfigureAwait(false); + + using var listenCts = new CancellationTokenSource(); + var listenTask = sessionB.Client.SendRequestAsync( + RequestMethods.SubscriptionsListen, + new SubscriptionsListenRequestParams + { + Notifications = new SubscriptionsListenNotifications { ToolsListChanged = true }, + }, + cancellationToken: listenCts.Token) + .AsTask(); + + // Both sessions are live; close the FIRST one, then invalidate routing. Disposing the + // session awaits its RunAsync, so a teardown fault surfaces here instead of being swallowed. + await sessionA.DisposeAsync().ConfigureAwait(false); + + app.Map("late", () => "l"); + app.Core.InvalidateRouting(); + + await listChanged.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + await listenCts.CancelAsync().ConfigureAwait(false); + try + { + // Started above and awaited only after cancellation; MSTest has no sync context. +#pragma warning disable VSTHRD003 + await listenTask.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + catch (OperationCanceledException) + { + // Expected: the listen stream ends on cancellation. + } + } + + [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 handler = CreateHandler(app); + using var cts = new CancellationTokenSource(); + + var withRoots = await StartSessionAsync(handler, BuildRootsClientOptions("file:///ga"), cts.Token).ConfigureAwait(false); + await using var scopeWithRoots = withRoots.ConfigureAwait(false); + var withoutRoots = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var scopeWithoutRoots = withoutRoots.ConfigureAwait(false); + + var toolsWithRoots = await withRoots.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + var toolsWithoutRoots = await withoutRoots.Client.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)); + } + + [TestMethod] + [Description("Locks the cache contract that makes a per-session tools/list legal on 2026-07-28: SEP-2549 permits list results to vary per client (CacheScope.Private is documented for \"filtered list results that vary per user\"), but an absent cacheScope defaults to Public, which would let a shared gateway serve one client's capability-gated catalog to another. A varying catalog must therefore be tagged private and immediately stale.")] + public async Task When_ToolGraphIsSessionGated_Then_ListResultIsTaggedPrivateAndStale() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("always", () => "ok"); + app.MapModule(new RootsGatedModule(), (IMcpClientRoots roots) => roots.IsSupported); + var handler = CreateHandler(app); + using var cts = new CancellationTokenSource(); + + var session = await StartSessionAsync(handler, BuildRootsClientOptions("file:///ga"), cts.Token).ConfigureAwait(false); + await using var scope = session.ConfigureAwait(false); + + session.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + var result = await session.Client.SendRequestAsync( + RequestMethods.ToolsList, + new ListToolsRequestParams(), + cancellationToken: cts.Token).ConfigureAwait(false); + + result.Tools.Should().Contain(tool => string.Equals(tool.Name, "gated", StringComparison.Ordinal)); + result.CacheScope.Should().Be(CacheScope.Private); + result.TimeToLive.Should().Be(TimeSpan.Zero); + } + + [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 handler = CreateHandler(app, DynamicToolCompatibilityMode.DiscoverAndCallShim); + using var cts = new CancellationTokenSource(); + + var sessionA = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var scopeA = sessionA.ConfigureAwait(false); + var sessionB = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var scopeB = sessionB.ConfigureAwait(false); + + var firstListA = await sessionA.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + var firstListB = await sessionB.Client.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"]); + } + [TestMethod] [Description("Two independent MCP sessions can run concurrently without interference.")] public async Task When_TwoSessionsRunConcurrently_Then_EachSeesOwnTools() @@ -69,4 +292,61 @@ public async Task When_ToolsInvokedConcurrently_Then_OutputIsIsolated() } } } + + private static Task StartSessionAsync( + McpServerHandler handler, + McpClientOptions? clientOptions, + CancellationToken cancellationToken) => + McpPipeSession.StartAsync(handler.RunAsync, clientOptions, cancellationToken); + + private static McpServerHandler CreateHandler( + ReplApp app, + DynamicToolCompatibilityMode compatibility = DynamicToolCompatibilityMode.Disabled) + { + var options = new ReplMcpServerOptions + { + DynamicToolCompatibility = compatibility, + TransportFactory = McpTestFixture.PipeTransportFactory, + }; + + return new McpServerHandler(app.Core, options, McpTestFixture.EmptyServices); + } + + private sealed class RootsGatedModule : IReplModule + { + public void Map(IReplMap app) => app.Map("gated", () => "roots-only"); + } + + // Roots is 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 + 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 }], + }), + }, + }; + + // Sampling carries the same SEP-2577 deprecation as Roots above. + 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", + }), + }, + }; +#pragma warning restore MCP9005 } diff --git a/src/Repl.McpTests/Given_McpIntegration.cs b/src/Repl.McpTests/Given_McpIntegration.cs index 55de370..44b0bb8 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,63 @@ 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 tools/list wire shape for .LongRunning(): the annotation is Repl-local metadata and must leave no trace on the protocol surface until Repl integrates the SDK Tasks extension (issue #72). Asserted by serializing two otherwise identical tools that differ only by .LongRunning() and requiring byte-identical payloads — a NotContain(\"execution\") assertion could not fail, since SDK 2.x removed Tool.Execution entirely.")] + public void When_SerializingLongRunningTool_Then_WireShapeIsUnchanged() + { + var app = ReplApp.Create(); + app.Map("deploy", () => "deployed") + .WithDescription("Deploy application") + .LongRunning() + .OpenWorld(); + app.Map("release", () => "released") + .WithDescription("Deploy application") + .OpenWorld(); + + var options = app.BuildMcpServerOptions(); + + SerializeTool(options, "deploy").Should().Be( + SerializeTool(options, "release").Replace("\"release\"", "\"deploy\"", StringComparison.Ordinal), + because: ".LongRunning() must not change anything a client can observe"); + } + + private static string SerializeTool(ModelContextProtocol.Server.McpServerOptions options, string name) + { + var tool = options.ToolCollection!.Single(tool => + string.Equals(tool.ProtocolTool.Name, name, StringComparison.Ordinal)); + + return System.Text.Json.JsonSerializer.Serialize( + tool.ProtocolTool, ModelContextProtocol.McpJsonUtilities.DefaultOptions); + } + [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_McpResourceParameters.cs b/src/Repl.McpTests/Given_McpResourceParameters.cs index 7983156..52de451 100644 --- a/src/Repl.McpTests/Given_McpResourceParameters.cs +++ b/src/Repl.McpTests/Given_McpResourceParameters.cs @@ -255,7 +255,8 @@ public async Task When_ResourceCommandFails_Then_ReadThrowsMcpException() public async Task When_ResourceRouteIsUnknown_Then_AdapterReturnsTextError() { await using var services = new ServiceCollection().BuildServiceProvider(); - var adapter = new McpToolAdapter(ReplApp.Create().Core, new ReplMcpServerOptions(), services); + var adapter = new McpToolAdapter( + ReplApp.Create().Core, new ReplMcpServerOptions(), services, new McpRequestServerAccessor()); var result = await adapter.InvokeResourceAsync( "missing", diff --git a/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs b/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs index ed70c18..7fe62ae 100644 --- a/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs +++ b/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs @@ -1,9 +1,14 @@ -using System.Text.Json; +using System.Text.Json; using ModelContextProtocol; using ModelContextProtocol.Client; 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] @@ -100,7 +105,11 @@ public async Task When_DynamicToolCompatibilityEnabled_Then_ClientCanDiscoverAnd { app.Map("echo {msg}", (string msg) => $"echo:{msg}"); }, - configureOptions: options => options.DynamicToolCompatibility = DynamicToolCompatibilityMode.DiscoverAndCallShim); + configureOptions: options => options.DynamicToolCompatibility = DynamicToolCompatibilityMode.DiscoverAndCallShim, + // The shim exists for clients that do not refresh a changing tool list — i.e. initialize-era + // clients. Pinning the CLIENT (not the server) keeps the server multi-revision while making + // this test state which revision its unsolicited-notification expectation belongs to. + clientOptions: new McpClientOptions { ProtocolVersion = McpProtocolRevisions.LastWithSessions }); await using var registration = fixture.Client.RegisterNotificationHandler( NotificationMethods.ToolListChangedNotification, @@ -157,7 +166,11 @@ public async Task When_RoutingChanges_AfterCompatibilityIntro_Then_ShimIsServedA { app.Map("echo {msg}", (string msg) => $"echo:{msg}"); }, - configureOptions: options => options.DynamicToolCompatibility = DynamicToolCompatibilityMode.DiscoverAndCallShim); + configureOptions: options => options.DynamicToolCompatibility = DynamicToolCompatibilityMode.DiscoverAndCallShim, + // The shim exists for clients that do not refresh a changing tool list — i.e. initialize-era + // clients. Pinning the CLIENT (not the server) keeps the server multi-revision while making + // this test state which revision its unsolicited-notification expectation belongs to. + clientOptions: new McpClientOptions { ProtocolVersion = McpProtocolRevisions.LastWithSessions }); await using var registration = fixture.Client.RegisterNotificationHandler( NotificationMethods.ToolListChangedNotification, diff --git a/src/Repl.McpTests/Given_McpSharedServerOptions.cs b/src/Repl.McpTests/Given_McpSharedServerOptions.cs new file mode 100644 index 0000000..0354c40 --- /dev/null +++ b/src/Repl.McpTests/Given_McpSharedServerOptions.cs @@ -0,0 +1,105 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Repl.Mcp; + +namespace Repl.McpTests; + +/// +/// Regressions for the documented multi-connection host pattern: build +/// BuildMcpServerOptions() ONCE and create an per connection +/// (docs/mcp-transports.md). That path bypasses 's request handlers +/// entirely — the SDK dispatches straight into the pre-built primitives — so nothing that relies on +/// the handler prologue applies to it. +/// +[TestClass] +public sealed class Given_McpSharedServerOptions +{ + [TestMethod] + [Description("Guards capability resolution on the documented reusable-options path: two connections created from ONE BuildMcpServerOptions() result must each observe their OWN client's capabilities. The pre-built primitives never run the handler's request prologue, so without per-invocation request binding a sampling-capable client is told sampling is unavailable — the capability is resolved against nothing at all.")] + public async Task When_TwoConnectionsShareOneOptionsInstance_Then_CapabilitiesAreRequestScoped() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + // Distinct tokens, not "supported"/"not-supported": the tool result is JSON, so the text block + // carries quotes, and a substring assertion on the shorter word would match both answers. + app.Map("probe", (IMcpSampling sampling) => sampling.IsSupported ? "sampling-on" : "sampling-off"); + + // Built once and reused across connections, exactly as docs/mcp-transports.md prescribes. + var mcpOptions = app.BuildMcpServerOptions(); + using var cts = new CancellationTokenSource(); + + var capable = await StartAsync(mcpOptions, BuildSamplingClientOptions(), cts.Token).ConfigureAwait(false); + await using var capableScope = capable.ConfigureAwait(false); + var plain = await StartAsync(mcpOptions, clientOptions: null, cts.Token).ConfigureAwait(false); + await using var plainScope = plain.ConfigureAwait(false); + + capable.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + plain.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var capableText = await CallProbeAsync(capable, cts.Token).ConfigureAwait(false); + var plainText = await CallProbeAsync(plain, cts.Token).ConfigureAwait(false); + + capableText.Should().Contain("sampling-on"); + plainText.Should().Contain("sampling-off"); + } + + private static Task CallProbeAsync(McpPipeSession session, CancellationToken cancellationToken) => + CallAsync(session, "probe", cancellationToken); + + private static async Task CallAsync( + McpPipeSession session, + string toolName, + CancellationToken cancellationToken) + { + var result = await session.Client.CallToolAsync( + toolName: toolName, + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cancellationToken).ConfigureAwait(false); + + return result.Content.OfType().First().Text; + } + + /// + /// Starts one connection over the shared , mirroring the sample in + /// docs/mcp-transports.md — including passing no service provider to McpServer.Create. + /// + private static Task StartAsync( + McpServerOptions mcpOptions, + McpClientOptions? clientOptions, + CancellationToken cancellationToken) => + McpPipeSession.StartAsync( + async (io, token) => + { + var transport = new StreamServerTransport(io.InputStream, io.OutputStream, "shared-options-server"); + var server = McpServer.Create(transport, mcpOptions); + try + { + await server.RunAsync(token).ConfigureAwait(false); + } + finally + { + await server.DisposeAsync().ConfigureAwait(false); + await transport.DisposeAsync().ConfigureAwait(false); + } + }, + clientOptions, + cancellationToken); + + // Sampling is 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 + 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", + }), + }, + }; +#pragma warning restore MCP9005 +} diff --git a/src/Repl.McpTests/Given_McpSubscriptions.cs b/src/Repl.McpTests/Given_McpSubscriptions.cs new file mode 100644 index 0000000..f8d8173 --- /dev/null +++ b/src/Repl.McpTests/Given_McpSubscriptions.cs @@ -0,0 +1,173 @@ +using System.Text.Json; +using ModelContextProtocol; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Repl.Mcp; + +namespace Repl.McpTests; + +/// +/// Covers subscriptions/listen (SEP-2575) delivery over the in-process stream transport that +/// stands in for stdio, and the SDK behaviour Repl's discovery signal depends on. +/// +[TestClass] +public sealed class Given_McpSubscriptions +{ + [TestMethod] + [Description("Pins the undocumented SDK behaviour the discovery signal rests on: clearing an already-empty primitive collection must still raise Changed. McpServerHandler uses empty collections as pure list-changed signals, so if a future SDK turns Clear() into a no-op when the collection is empty, discovery notifications would silently stop; this test fails loudly instead.")] + public void When_ClearingAnEmptyCollection_Then_ChangedStillFires() + { + var resources = new McpServerResourceCollection(); + var tools = new McpServerPrimitiveCollection(); + var resourceSignals = 0; + var toolSignals = 0; + resources.Changed += (_, _) => resourceSignals++; + tools.Changed += (_, _) => toolSignals++; + + resources.Clear(); + tools.Clear(); + + resourceSignals.Should().Be(1); + toolSignals.Should().Be(1); + resources.Count.Should().Be(0, because: "the signal must not mutate anything a client could observe"); + tools.Count.Should().Be(0, because: "the signal must not mutate anything a client could observe"); + } + + [TestMethod] + [Description("Guards backward compatibility while the modern path is filtered: an initialize-era client that pins 2025-11-25 and opens NO subscription must still receive tools/list_changed as an unsolicited session-wide broadcast. Delegating fan-out to the SDK must not cost existing hosts their discovery notifications — the server stays multi-revision and the SDK picks the delivery mode per client.")] + public async Task When_LegacyClientNeverSubscribes_Then_ListChangedIsStillBroadcast() + { + await using var fixture = await McpTestFixture.CreateAsync( + app => app.Map("alpha", () => "a"), + configureOptions: null, + clientOptions: new McpClientOptions { ProtocolVersion = McpProtocolRevisions.LastWithSessions }) + .ConfigureAwait(false); + fixture.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.LastWithSessions); + + var toolsChanged = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var registration = Capture(fixture, NotificationMethods.ToolListChangedNotification, toolsChanged); + await using var registrationScope = registration.ConfigureAwait(false); + + fixture.App.Map("late", () => "l"); + fixture.App.Core.InvalidateRouting(); + + await toolsChanged.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + + [TestMethod] + [Description("Confirms subscriptions/listen reaches a stream-transport (stdio-shaped) server and that the SDK's built-in handler acknowledges the filters it grants. Repl delegates list-changed fan-out to that pipeline, so this is the precondition the delegation rests on.")] + public async Task When_ClientOpensSubscriptionsListen_Then_ServerAcknowledges() + { + await using var fixture = await McpTestFixture.CreateAsync(app => app.Map("alpha", () => "a")) + .ConfigureAwait(false); + fixture.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var acknowledged = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var registration = Capture(fixture, NotificationMethods.SubscriptionsAcknowledgedNotification, acknowledged); + await using var registrationScope = registration.ConfigureAwait(false); + + using var listenCts = new CancellationTokenSource(); + var listenTask = OpenListenAsync( + fixture, + new SubscriptionsListenNotifications { ToolsListChanged = true }, + listenCts.Token); + + var notification = await acknowledged.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + var granted = notification.Params + .Deserialize(McpJsonUtilities.DefaultOptions); + granted.Should().NotBeNull(); + granted!.Notifications.ToolsListChanged.Should().BeTrue(); + + await CloseListenAsync(listenTask, listenCts).ConfigureAwait(false); + } + + [TestMethod] + [Description("Guards SEP-2575 delivery filtering: a 2026-07-28 client that subscribes to prompts/list_changed only must NOT receive tools/list_changed, and the notification it does receive must carry its listen request id. A server that sends */list_changed itself has no access to the subscription registry, so it delivers every type to every client, untagged.")] + public async Task When_ClientSubscribesToPromptsOnly_Then_ToolListChangedIsNotDelivered() + { + await using var fixture = await McpTestFixture.CreateAsync(app => app.Map("alpha", () => "a")) + .ConfigureAwait(false); + fixture.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var toolsChanged = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var promptsChanged = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var toolsRegistration = Capture(fixture, NotificationMethods.ToolListChangedNotification, toolsChanged); + await using var toolsScope = toolsRegistration.ConfigureAwait(false); + var promptsRegistration = Capture(fixture, NotificationMethods.PromptListChangedNotification, promptsChanged); + await using var promptsScope = promptsRegistration.ConfigureAwait(false); + + using var listenCts = new CancellationTokenSource(); + var listenTask = OpenListenAsync( + fixture, + new SubscriptionsListenNotifications { PromptsListChanged = true }, + listenCts.Token); + + fixture.App.Map("late", () => "l"); + fixture.App.Core.InvalidateRouting(); + + // Discovery signals fire tools-then-resources-then-prompts, so observing the prompts + // notification proves the tools one has already had its chance. + var prompts = await promptsChanged.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + // Render to a string rather than asserting on the JsonNode: NotBeNull on a node reached via + // ?. is vacuous (the null-conditional result satisfies it even when the payload is absent), + // and the listen request id is a JSON-RPC id, so it may be a number as well as a string. + var subscriptionId = prompts.Params?["_meta"]?[MetaKeys.SubscriptionId]?.ToJsonString(); + subscriptionId.Should().NotBeNullOrEmpty( + because: "SEP-2575 requires every subscription notification to carry its listen request id"); + toolsChanged.Task.IsCompleted.Should().BeFalse( + because: "the client never subscribed to tools/list_changed"); + + await CloseListenAsync(listenTask, listenCts).ConfigureAwait(false); + } + + private static IAsyncDisposable Capture( + McpTestFixture fixture, + string method, + TaskCompletionSource received) => + fixture.Client.RegisterNotificationHandler( + method, + (notification, _) => + { + received.TrySetResult(notification); + return ValueTask.CompletedTask; + }); + + /// + /// subscriptions/listen is a long-lived request: the response is held open for the + /// subscription's lifetime, so it must not be awaited until the stream is cancelled. + /// + private static Task OpenListenAsync( + McpTestFixture fixture, + SubscriptionsListenNotifications filters, + CancellationToken cancellationToken) => + fixture.Client.SendRequestAsync( + RequestMethods.SubscriptionsListen, + new SubscriptionsListenRequestParams { Notifications = filters }, + cancellationToken: cancellationToken) + .AsTask(); + + private static async Task CloseListenAsync(Task listenTask, CancellationTokenSource listenCts) + { + await listenCts.CancelAsync().ConfigureAwait(false); + try + { + // The listen request is deliberately started by the caller and awaited only once its + // stream has been cancelled. MSTest runs without a synchronization context, so the + // deadlock VSTHRD003 guards against cannot arise here. +#pragma warning disable VSTHRD003 + await listenTask.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + catch (OperationCanceledException) + { + // Expected: the listen stream ends on cancellation. + } + } +} diff --git a/src/Repl.McpTests/Given_McpToolAdapter.cs b/src/Repl.McpTests/Given_McpToolAdapter.cs index 4a28669..0dc3218 100644 --- a/src/Repl.McpTests/Given_McpToolAdapter.cs +++ b/src/Repl.McpTests/Given_McpToolAdapter.cs @@ -633,7 +633,8 @@ public async Task When_StringToolValueNamesResponseFile_Then_ProgrammaticInvocat }) .WithOption("hidden", static option => option.Hidden()); await using var services = new ServiceCollection().BuildServiceProvider(); - var adapter = new McpToolAdapter(app.Core, new ReplMcpServerOptions(), services); + var adapter = new McpToolAdapter( + app.Core, new ReplMcpServerOptions(), services, new McpRequestServerAccessor()); adapter.RegisterRoute( "deploy", new ReplDocCommand( diff --git a/src/Repl.McpTests/Given_McpUserFeedback.cs b/src/Repl.McpTests/Given_McpUserFeedback.cs index 0a8bd85..b9ecb1e 100644 --- a/src/Repl.McpTests/Given_McpUserFeedback.cs +++ b/src/Repl.McpTests/Given_McpUserFeedback.cs @@ -4,6 +4,12 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using Repl.Interaction; +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; @@ -19,7 +25,7 @@ public async Task When_ToolEmitsUserFeedback_Then_McpReceivesNotifications() NotificationCaptureState.Current = captureState; try { - await using var fixture = await CreateFeedbackFixtureAsync(clientOptions: CreateClientOptions()).ConfigureAwait(false); + await using var fixture = await CreateFeedbackFixtureAsync(LegacyClientOptions()).ConfigureAwait(false); var result = await fixture.Client.CallToolAsync( toolName: "feedback", @@ -44,7 +50,7 @@ public async Task When_ToolEmitsStructuredProgress_Then_McpReceivesProgressAndMe NotificationCaptureState.Current = captureState; try { - await using var fixture = await CreateStructuredProgressFixtureAsync(CreateClientOptions()).ConfigureAwait(false); + await using var fixture = await CreateStructuredProgressFixtureAsync(LegacyClientOptions()).ConfigureAwait(false); var result = await fixture.Client.CallToolAsync( toolName: "feedback_progress", @@ -63,6 +69,85 @@ await WaitForConditionAsync(() => } } + [TestMethod] + [Description("Guards the 2026-07-28 rule that a server MUST NOT emit notifications/message for a request that declared no log level (SEP-2575) — and guards against that rule silently swallowing user feedback: the notice, warning and problem the command reported must instead ride back in the tool result, so no host loses them.")] + public async Task When_RequestDeclaresNoLogLevel_Then_FeedbackRidesInTheToolResultInstead() + { + var notifications = new List<(LoggingLevel Level, string Data)>(); + var captureState = new NotificationCaptureState(notifications); + NotificationCaptureState.Current = captureState; + try + { + await using var fixture = await CreateFeedbackFixtureAsync(CreateClientOptions()).ConfigureAwait(false); + fixture.Client.NegotiatedProtocolVersion.Should().Be(McpProtocolRevisions.Sessionless); + + var result = await fixture.Client.CallToolAsync( + toolName: "feedback", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + // Give a (forbidden) notification time to arrive before asserting that none did. + await WaitForConditionAsync(() => notifications.Count > 0, timeoutMs: 500).ConfigureAwait(false); + + notifications.Should().BeEmpty( + because: "the request declared no log level, so the server must not emit message notifications"); + var text = string.Join('\n', result.Content.OfType().Select(block => block.Text)); + text.Should().Contain("Connected"); + text.Should().Contain("Token expires soon"); + text.Should().Contain("Sync failed"); + result.IsError.Should().BeFalse(); + } + finally + { + NotificationCaptureState.Current = null; + } + } + + [TestMethod] + [Description("Guards severity filtering against the level the client asked for: after logging/setLevel(Error) the notice and warning a command reports must not be delivered, while the problem must. Emitting everything regardless of the requested threshold floods hosts that deliberately asked for errors only.")] + public async Task When_ClientRequestsErrorLevel_Then_LowerSeveritiesAreNotNotified() + { + var notifications = new List<(LoggingLevel Level, string Data)>(); + var captureState = new NotificationCaptureState(notifications); + NotificationCaptureState.Current = captureState; + try + { + await using var fixture = await CreateFeedbackFixtureAsync(LegacyClientOptions()).ConfigureAwait(false); + await fixture.Client.SetLoggingLevelAsync(LoggingLevel.Error).ConfigureAwait(false); + + await fixture.Client.CallToolAsync( + toolName: "feedback", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + await WaitForConditionAsync(() => notifications.Count >= 1).ConfigureAwait(false); + + notifications.Should().OnlyContain(entry => entry.Level == LoggingLevel.Error); + notifications.Should().ContainSingle(entry => + entry.Data.Contains("Sync failed", StringComparison.Ordinal)); + } + finally + { + NotificationCaptureState.Current = null; + } + } + + /// + /// A client pinned to the last revision on which message notifications can be requested at all. + /// + /// + /// On 2026-07-28 the SDK's client cannot ask for a log level: it rejects + /// logging/setLevel for that revision, exposes no option for the level, and replaces a + /// caller's _meta with its own keys (protocol version, client info, capabilities). Tests + /// that assert notification DELIVERY therefore have to pin the initialize-era revision. The modern + /// path is covered by + /// . + /// + private static McpClientOptions LegacyClientOptions() + { + var options = CreateClientOptions(); + options.ProtocolVersion = McpProtocolRevisions.LastWithSessions; + return options; + } + private static async Task CreateFeedbackFixtureAsync( McpClientOptions clientOptions) => await CreateFeedbackFixtureAsync( diff --git a/src/Repl.McpTests/McpPipeSession.cs b/src/Repl.McpTests/McpPipeSession.cs new file mode 100644 index 0000000..1c7cdf1 --- /dev/null +++ b/src/Repl.McpTests/McpPipeSession.cs @@ -0,0 +1,118 @@ +using System.IO.Pipelines; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace Repl.McpTests; + +/// +/// One MCP client connected to an existing over in-process pipes. +/// +/// +/// Several sessions can share one handler, which is what the concurrent-session regressions need. +/// This type owns the transport pair, the cancellation source, and — crucially — the +/// RunAsync task: awaits it and lets any fault surface. Discarding +/// that task is what once let a server throwing during teardown leave every concurrency test green. +/// +internal sealed class McpPipeSession : IAsyncDisposable +{ + private readonly CancellationTokenSource _cts; + private readonly Pipe _clientToServer; + private readonly Pipe _serverToClient; + private readonly Task _serverTask; + + private McpPipeSession( + McpClient client, + Task serverTask, + CancellationTokenSource cts, + Pipe clientToServer, + Pipe serverToClient) + { + Client = client; + _serverTask = serverTask; + _cts = cts; + _clientToServer = clientToServer; + _serverToClient = serverToClient; + } + + public McpClient Client { get; } + + /// + /// Starts a session by handing the server side of a fresh pipe pair. + /// + /// + /// The handshake races the server task so a server that fails while starting surfaces its own + /// exception instead of an initialize timeout carrying the wrong one. + /// + public static async Task StartAsync( + Func startServer, + McpClientOptions? clientOptions, + CancellationToken cancellationToken) + { + var clientToServer = new Pipe(); + var serverToClient = new Pipe(); + var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + var io = new McpTestFixture.PipeIoContext( + clientToServer.Reader.AsStream(), + serverToClient.Writer.AsStream()); + var serverTask = startServer(io, cts.Token); + + var clientTransport = new StreamClientTransport( + clientToServer.Writer.AsStream(), + serverToClient.Reader.AsStream()); + + try + { + var clientTask = McpClient.CreateAsync(clientTransport, clientOptions, cancellationToken: cts.Token); + if (ReferenceEquals(await Task.WhenAny(serverTask, clientTask).ConfigureAwait(false), serverTask)) + { + // Rethrows a start failure; a clean early exit means the handshake never completes. + await serverTask.ConfigureAwait(false); + + throw new InvalidOperationException( + "The MCP server stopped before the client completed its handshake."); + } + + var client = await clientTask.ConfigureAwait(false); + + return new McpPipeSession(client, serverTask, cts, clientToServer, serverToClient); + } + catch + { + await cts.CancelAsync().ConfigureAwait(false); + await clientToServer.Writer.CompleteAsync().ConfigureAwait(false); + await serverToClient.Writer.CompleteAsync().ConfigureAwait(false); + cts.Dispose(); + throw; + } + } + + /// + /// Closes the session and asserts the server terminated cleanly. + /// + /// + /// Only cancellation is an accepted outcome. A fault propagates, and so does a timeout: a server + /// that never shuts down is a defect, not noise to be swallowed. + /// + public async ValueTask DisposeAsync() + { + await Client.DisposeAsync().ConfigureAwait(false); + await _cts.CancelAsync().ConfigureAwait(false); + + await _clientToServer.Writer.CompleteAsync().ConfigureAwait(false); + await _serverToClient.Writer.CompleteAsync().ConfigureAwait(false); + + try + { + await _serverTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected: the server's RunAsync ends on cancellation. + } + finally + { + _cts.Dispose(); + } + } +} diff --git a/src/Repl.McpTests/McpTestFixture.cs b/src/Repl.McpTests/McpTestFixture.cs index 04279ee..0e37690 100644 --- a/src/Repl.McpTests/McpTestFixture.cs +++ b/src/Repl.McpTests/McpTestFixture.cs @@ -1,4 +1,3 @@ -using System.IO.Pipelines; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -14,30 +13,17 @@ namespace Repl.McpTests; /// internal sealed class McpTestFixture : IAsyncDisposable { - private readonly CancellationTokenSource _cts; - private readonly Pipe _clientToServer; - private readonly Pipe _serverToClient; - private readonly Task _serverTask; + private readonly McpPipeSession _session; private readonly ReplApp _app; - private McpTestFixture( - ReplApp app, - McpClient client, - Task serverTask, - CancellationTokenSource cts, - Pipe clientToServer, - Pipe serverToClient) + private McpTestFixture(ReplApp app, McpPipeSession session) { _app = app; - Client = client; - _serverTask = serverTask; - _cts = cts; - _clientToServer = clientToServer; - _serverToClient = serverToClient; + _session = session; } public ReplApp App => _app; - public McpClient Client { get; } + public McpClient Client => _session.Client; public static Task CreateAsync(Action configure) => CreateAsync(configure, configureOptions: null, clientOptions: null); @@ -61,97 +47,34 @@ public static async Task CreateAsync( var options = new ReplMcpServerOptions(); configureOptions?.Invoke(options); + options.TransportFactory ??= PipeTransportFactory; - var serviceProvider = app.Services; - var handler = new McpServerHandler(app.Core, options, serviceProvider); - - var clientToServer = new Pipe(); - var serverToClient = new Pipe(); - var cts = new CancellationTokenSource(); - - var inputStream = clientToServer.Reader.AsStream(); - var outputStream = serverToClient.Writer.AsStream(); - var ioContext = new PipeIoContext(inputStream, outputStream); - if (options.TransportFactory is null) - { - options.TransportFactory = static (serverName, io) => new StreamServerTransport( - ((PipeIoContext)io).InputStream, - ((PipeIoContext)io).OutputStream, - serverName); - } - var serverTask = handler.RunAsync(ioContext, cts.Token); - - var clientTransport = new StreamClientTransport( - clientToServer.Writer.AsStream(), - serverToClient.Reader.AsStream()); - - try - { - // Race the handshake against the server. Awaiting only the client means a server that - // fails while starting is observable solely as an initialize timeout carrying the wrong - // exception — which is what once pushed production code into throwing synchronously - // just to stay testable. - var clientTask = McpClient.CreateAsync(clientTransport, clientOptions); - if (ReferenceEquals(await Task.WhenAny(serverTask, clientTask).ConfigureAwait(false), serverTask)) - { - // Rethrows a start failure; a clean early exit means the handshake never completes. - await serverTask.ConfigureAwait(false); - - throw new InvalidOperationException( - "The MCP server stopped before the client completed its handshake."); - } - - var client = await clientTask.ConfigureAwait(false); - - return new McpTestFixture(app, client, serverTask, cts, clientToServer, serverToClient); - } - catch - { - await AbandonAsync(cts, clientToServer, serverToClient).ConfigureAwait(false); - throw; - } - } + var handler = new McpServerHandler(app.Core, options, app.Services); - /// - /// Releases what - /// allocated when construction fails before the fixture takes ownership. - /// - private static async Task AbandonAsync( - CancellationTokenSource cts, - Pipe clientToServer, - Pipe serverToClient) - { - await cts.CancelAsync().ConfigureAwait(false); - await clientToServer.Writer.CompleteAsync().ConfigureAwait(false); - await serverToClient.Writer.CompleteAsync().ConfigureAwait(false); - cts.Dispose(); + var session = await McpPipeSession + .StartAsync(handler.RunAsync, clientOptions, CancellationToken.None) + .ConfigureAwait(false); + + return new McpTestFixture(app, session); } - public async ValueTask DisposeAsync() + /// Builds the server transport over the pipe pair a supplies. + internal static Func PipeTransportFactory { get; } = + static (serverName, io) => new StreamServerTransport( + ((PipeIoContext)io).InputStream, + ((PipeIoContext)io).OutputStream, + serverName); + + public ValueTask DisposeAsync() => _session.DisposeAsync(); + + internal static IServiceProvider EmptyServices => EmptyServiceProvider.Instance; + + private sealed class EmptyServiceProvider : IServiceProvider { - await Client.DisposeAsync().ConfigureAwait(false); - await _cts.CancelAsync().ConfigureAwait(false); - - await _clientToServer.Writer.CompleteAsync().ConfigureAwait(false); - await _serverToClient.Writer.CompleteAsync().ConfigureAwait(false); - - try - { - await _serverTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Expected: server RunAsync cancelled during shutdown. - } - catch (TimeoutException) - { - // Server did not shut down within timeout — transport will be collected. - } - - _cts.Dispose(); + public static readonly EmptyServiceProvider Instance = new(); + public object? GetService(Type serviceType) => null; } - internal sealed class PipeIoContext(Stream inputStream, Stream outputStream) : IReplIoContext { public Stream InputStream => inputStream;