From 3cb6d8d09bf865d16f069c4f88a60ac93d036eb0 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 16 Jul 2026 16:17:17 -0400 Subject: [PATCH 1/7] feat(mcp): migrate to ModelContextProtocol 2.0 line (2.0.0-preview.3) - Bump ModelContextProtocol 1.4.1 -> 2.0.0-preview.3. - Remove the Tool.Execution mapping for .LongRunning() commands: SDK 2.0 dropped the experimental MCP Tasks tool augmentation (Tasks SEP deferred out of the 2.0 protocol release). The annotation stays in Repl's model; protocol-level task support returns with the SDK Tasks runtime. - Keep supporting Roots, Sampling, and Logging: deprecated by spec 2026-07-28 (SEP-2577, MCP9005) with no replacement, still relied on by current hosts. Scoped, documented pragmas at the feature touchpoints. - Document the SDK/protocol version posture in docs/mcp-reference.md. Full suite green against the new SDK (1312 passed, 1 known skip), including all MCP capability, tool-call, roots, sampling, and logging regressions. Note: re-pin to the stable 2.0.0 release before cutting stable 0.12. Refs #51 --- docs/mcp-reference.md | 6 ++++++ src/Directory.Packages.props | 2 +- src/Repl.Mcp/IMcpFeedback.cs | 5 +++++ src/Repl.Mcp/McpClientRootsService.cs | 5 +++++ src/Repl.Mcp/McpFeedbackService.cs | 5 +++++ src/Repl.Mcp/McpInteractionChannel.cs | 5 +++++ src/Repl.Mcp/McpSamplingService.cs | 5 +++++ src/Repl.Mcp/McpServerHandler.cs | 5 +++++ src/Repl.Mcp/ReplMcpServerTool.cs | 12 ++++-------- src/Repl.McpTests/Given_McpAgentCapabilities.cs | 5 +++++ src/Repl.McpTests/Given_McpIntegration.cs | 5 +++++ src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs | 5 +++++ src/Repl.McpTests/Given_McpUserFeedback.cs | 5 +++++ 13 files changed, 61 insertions(+), 9 deletions(-) diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md index 5190316b..922e6951 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -510,6 +510,12 @@ side-channel command output and are not included in `resources/read` bodies. Feature support varies across agents. Check [mcp-availability.com](https://mcp-availability.com/) for current data. +### SDK and protocol versions + +- Repl.Mcp builds on the official C# SDK (`ModelContextProtocol`), currently on the **2.0 line** (`2.0.0-preview.3`). The SDK negotiates the protocol version with each client, including fallback to the legacy `initialize` handshake for older hosts. +- **Roots, Sampling, and Logging** are deprecated by MCP specification 2026-07-28 (SEP-2577) but remain fully functional; Repl.Mcp keeps supporting them until the SDK removes them, since current hosts still rely on these features. +- **MCP Tasks**: SDK 2.0 removed the experimental tool-execution augmentation (`Tool.Execution`), so `.LongRunning()` commands no longer advertise task support at the protocol level. The annotation stays in Repl's own model (help/docs), and protocol-level task support will return once the SDK ships its Tasks runtime. + | Feature | Claude Desktop | Claude Code | Codex | VS Code Copilot | Cursor | Continue | |---|---|---|---|---|---|---| | Tools | Yes | Yes | Yes | Yes | Yes | Yes | diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 1aa0edeb..77d7b240 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -14,7 +14,7 @@ - + diff --git a/src/Repl.Mcp/IMcpFeedback.cs b/src/Repl.Mcp/IMcpFeedback.cs index 3fff6a16..5f38df4a 100644 --- a/src/Repl.Mcp/IMcpFeedback.cs +++ b/src/Repl.Mcp/IMcpFeedback.cs @@ -1,6 +1,11 @@ using ModelContextProtocol.Protocol; using Repl.Interaction; +// Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK +// diagnostic MCP9005) with no replacement API; hosts still rely on them, so Repl keeps +// supporting the features until the SDK removes them. Tracked in issue #51. +#pragma warning disable MCP9005 + namespace Repl.Mcp; /// diff --git a/src/Repl.Mcp/McpClientRootsService.cs b/src/Repl.Mcp/McpClientRootsService.cs index fa53d4e3..fc5ac7e5 100644 --- a/src/Repl.Mcp/McpClientRootsService.cs +++ b/src/Repl.Mcp/McpClientRootsService.cs @@ -1,6 +1,11 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +// Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK +// diagnostic MCP9005) with no replacement API; hosts still rely on them, so Repl keeps +// supporting the features until the SDK removes them. Tracked in issue #51. +#pragma warning disable MCP9005 + namespace Repl.Mcp; internal sealed class McpClientRootsService : IMcpClientRoots diff --git a/src/Repl.Mcp/McpFeedbackService.cs b/src/Repl.Mcp/McpFeedbackService.cs index 92a3a2ac..66c7cb93 100644 --- a/src/Repl.Mcp/McpFeedbackService.cs +++ b/src/Repl.Mcp/McpFeedbackService.cs @@ -5,6 +5,11 @@ using ModelContextProtocol.Server; using Repl.Interaction; +// Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK +// diagnostic MCP9005) with no replacement API; hosts still rely on them, so Repl keeps +// supporting the features until the SDK removes them. Tracked in issue #51. +#pragma warning disable MCP9005 + namespace Repl.Mcp; /// diff --git a/src/Repl.Mcp/McpInteractionChannel.cs b/src/Repl.Mcp/McpInteractionChannel.cs index 1d8ea662..033c1ebe 100644 --- a/src/Repl.Mcp/McpInteractionChannel.cs +++ b/src/Repl.Mcp/McpInteractionChannel.cs @@ -5,6 +5,11 @@ using ModelContextProtocol.Server; using Repl.Interaction; +// Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK +// diagnostic MCP9005) with no replacement API; hosts still rely on them, so Repl keeps +// supporting the features until the SDK removes them. Tracked in issue #51. +#pragma warning disable MCP9005 + namespace Repl.Mcp; /// diff --git a/src/Repl.Mcp/McpSamplingService.cs b/src/Repl.Mcp/McpSamplingService.cs index 5e6a09e0..53926aa0 100644 --- a/src/Repl.Mcp/McpSamplingService.cs +++ b/src/Repl.Mcp/McpSamplingService.cs @@ -1,6 +1,11 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +// Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK +// diagnostic MCP9005) with no replacement API; hosts still rely on them, so Repl keeps +// supporting the features until the SDK removes them. Tracked in issue #51. +#pragma warning disable MCP9005 + namespace Repl.Mcp; /// diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 2ff84ebe..2a4eaf4d 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -6,6 +6,11 @@ using ModelContextProtocol.Server; using Repl.Documentation; +// Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK +// diagnostic MCP9005) with no replacement API; hosts still rely on them, so Repl keeps +// supporting the features until the SDK removes them. Tracked in issue #51. +#pragma warning disable MCP9005 + namespace Repl.Mcp; /// diff --git a/src/Repl.Mcp/ReplMcpServerTool.cs b/src/Repl.Mcp/ReplMcpServerTool.cs index cb4b1c0f..94845834 100644 --- a/src/Repl.Mcp/ReplMcpServerTool.cs +++ b/src/Repl.Mcp/ReplMcpServerTool.cs @@ -14,10 +14,10 @@ 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 removed the experimental MCP Tasks tool-augmentation surface + // (Tool.Execution / ToolTaskSupport, MCPEXP001 in 1.x): the Tasks SEP was deferred out + // of the 2.0 protocol release. Repl keeps .LongRunning() in its own model (help/docs); + // advertising task support returns with the SDK's Tasks runtime (tracked in issue #51). public ReplMcpServerTool( ReplDocCommand command, string toolName, @@ -31,15 +31,11 @@ public ReplMcpServerTool( InputSchema = McpSchemaGenerator.BuildInputSchema(command), OutputSchema = McpSchemaGenerator.BuildOutputSchema(command), Annotations = McpSchemaGenerator.MapAnnotations(command.Annotations), - Execution = command.Annotations?.LongRunning == true - ? new ToolExecution { TaskSupport = ToolTaskSupport.Optional } - : null, Meta = TryGetAppOptions(command, out var appOptions) ? McpAppMetadata.BuildToolMeta(appOptions) : null, }; } -#pragma warning restore MCPEXP001 /// public override Tool ProtocolTool => _protocolTool; diff --git a/src/Repl.McpTests/Given_McpAgentCapabilities.cs b/src/Repl.McpTests/Given_McpAgentCapabilities.cs index b87ef8d3..c1440bcb 100644 --- a/src/Repl.McpTests/Given_McpAgentCapabilities.cs +++ b/src/Repl.McpTests/Given_McpAgentCapabilities.cs @@ -4,6 +4,11 @@ using ModelContextProtocol.Protocol; using Repl.Mcp; +// These tests exercise Roots/Sampling/Logging, deprecated by MCP spec 2026-07-28 +// (SEP-2577, MCP9005) but still supported by Repl.Mcp until the SDK removes them. +// Tracked in issue #51. +#pragma warning disable MCP9005 + namespace Repl.McpTests; [TestClass] diff --git a/src/Repl.McpTests/Given_McpIntegration.cs b/src/Repl.McpTests/Given_McpIntegration.cs index 55de3709..5eba1b06 100644 --- a/src/Repl.McpTests/Given_McpIntegration.cs +++ b/src/Repl.McpTests/Given_McpIntegration.cs @@ -1,5 +1,10 @@ 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_McpRootsAndDynamicTools.cs b/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs index ed70c18b..5eb7b01b 100644 --- a/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs +++ b/src/Repl.McpTests/Given_McpRootsAndDynamicTools.cs @@ -4,6 +4,11 @@ using ModelContextProtocol.Protocol; using Repl.Mcp; +// These tests exercise Roots/Sampling/Logging, deprecated by MCP spec 2026-07-28 +// (SEP-2577, MCP9005) but still supported by Repl.Mcp until the SDK removes them. +// Tracked in issue #51. +#pragma warning disable MCP9005 + namespace Repl.McpTests; [TestClass] diff --git a/src/Repl.McpTests/Given_McpUserFeedback.cs b/src/Repl.McpTests/Given_McpUserFeedback.cs index 0a8bd859..28869839 100644 --- a/src/Repl.McpTests/Given_McpUserFeedback.cs +++ b/src/Repl.McpTests/Given_McpUserFeedback.cs @@ -5,6 +5,11 @@ using ModelContextProtocol.Protocol; using Repl.Interaction; +// These tests exercise Roots/Sampling/Logging, deprecated by MCP spec 2026-07-28 +// (SEP-2577, MCP9005) but still supported by Repl.Mcp until the SDK removes them. +// Tracked in issue #51. +#pragma warning disable MCP9005 + namespace Repl.McpTests; [TestClass] From 46792e98b598e561eb6b139e2c054eb74ee390a1 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 16 Jul 2026 16:31:43 -0400 Subject: [PATCH 2/7] fix(mcp): correct Tasks narrative, scope pragmas, lock tools/list wire shape (review) - Correct the migration rationale: MCP Tasks was EXTRACTED to ModelContextProtocol.Extensions.Tasks (store, task results, client polling), not removed; the per-tool Tool.Execution augmentation is gone from the protocol surface. Comments and docs now say so, and Repl still deliberately does not advertise task support without the runtime. - Name the designated successor (SEP-2322 multi-round-trip requests) in the deprecation pragmas instead of claiming 'no replacement API'. - Narrow MCP9005 pragmas to their touchpoints in McpServerHandler and Given_McpIntegration (file-scoped kept only where usage is dense). - Lock the SDK-2.0 tools/list wire shape: a .LongRunning() tool serializes its annotations and emits no task/execution augmentation. - Align remaining .LongRunning() doc mentions (overview, coding-agents guide, package README) with the current no-advertisement posture. --- docs/for-coding-agents.md | 2 +- docs/mcp-overview.md | 2 +- src/Repl.Mcp/IMcpFeedback.cs | 5 ++-- src/Repl.Mcp/McpClientRootsService.cs | 5 ++-- src/Repl.Mcp/McpFeedbackService.cs | 5 ++-- src/Repl.Mcp/McpInteractionChannel.cs | 5 ++-- src/Repl.Mcp/McpSamplingService.cs | 5 ++-- src/Repl.Mcp/McpServerHandler.cs | 14 +++++++---- src/Repl.Mcp/README.md | 2 +- src/Repl.Mcp/ReplMcpServerTool.cs | 9 +++---- src/Repl.McpTests/Given_McpIntegration.cs | 29 +++++++++++++++++++---- 11 files changed, 56 insertions(+), 27 deletions(-) diff --git a/docs/for-coding-agents.md b/docs/for-coding-agents.md index 57964c1e..d845abf1 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 the MCP Tasks runtime lands. | | `.AutomationHidden()` | Do not expose this command to MCP automation. | Unannotated tools force agents to assume the worst. Annotate every command that will be visible through MCP. diff --git a/docs/mcp-overview.md b/docs/mcp-overview.md index 3cdee8f6..c0bc99e1 100644 --- a/docs/mcp-overview.md +++ b/docs/mcp-overview.md @@ -63,7 +63,7 @@ app.Map("deploy", handler).Destructive().LongRunning().OpenWorld(); | `.Destructive()` | Ask user for confirmation, sequential | | `.Idempotent()` | Safe to retry, can parallelize | | `.OpenWorld()` | Reaches external systems — expect latency and transient failures | -| `.LongRunning()` | Enables call-now/poll-later pattern | +| `.LongRunning()` | Slow-operation hint (protocol-level task advertisement returns with the SDK Tasks runtime — 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/src/Repl.Mcp/IMcpFeedback.cs b/src/Repl.Mcp/IMcpFeedback.cs index 5f38df4a..9abbf943 100644 --- a/src/Repl.Mcp/IMcpFeedback.cs +++ b/src/Repl.Mcp/IMcpFeedback.cs @@ -2,8 +2,9 @@ using Repl.Interaction; // Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK -// diagnostic MCP9005) with no replacement API; hosts still rely on them, so Repl keeps -// supporting the features until the SDK removes them. Tracked in issue #51. +// diagnostic MCP9005); the designated successor for server-initiated flows (SEP-2322, +// multi-round-trip requests) is not yet consumable in the SDK and hosts still rely on +// these features, so Repl keeps supporting them until the SDK removes the surface (#51). #pragma warning disable MCP9005 namespace Repl.Mcp; diff --git a/src/Repl.Mcp/McpClientRootsService.cs b/src/Repl.Mcp/McpClientRootsService.cs index fc5ac7e5..16a4fd79 100644 --- a/src/Repl.Mcp/McpClientRootsService.cs +++ b/src/Repl.Mcp/McpClientRootsService.cs @@ -2,8 +2,9 @@ using ModelContextProtocol.Server; // Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK -// diagnostic MCP9005) with no replacement API; hosts still rely on them, so Repl keeps -// supporting the features until the SDK removes them. Tracked in issue #51. +// diagnostic MCP9005); the designated successor for server-initiated flows (SEP-2322, +// multi-round-trip requests) is not yet consumable in the SDK and hosts still rely on +// these features, so Repl keeps supporting them until the SDK removes the surface (#51). #pragma warning disable MCP9005 namespace Repl.Mcp; diff --git a/src/Repl.Mcp/McpFeedbackService.cs b/src/Repl.Mcp/McpFeedbackService.cs index 66c7cb93..efbfaaa2 100644 --- a/src/Repl.Mcp/McpFeedbackService.cs +++ b/src/Repl.Mcp/McpFeedbackService.cs @@ -6,8 +6,9 @@ using Repl.Interaction; // Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK -// diagnostic MCP9005) with no replacement API; hosts still rely on them, so Repl keeps -// supporting the features until the SDK removes them. Tracked in issue #51. +// diagnostic MCP9005); the designated successor for server-initiated flows (SEP-2322, +// multi-round-trip requests) is not yet consumable in the SDK and hosts still rely on +// these features, so Repl keeps supporting them until the SDK removes the surface (#51). #pragma warning disable MCP9005 namespace Repl.Mcp; diff --git a/src/Repl.Mcp/McpInteractionChannel.cs b/src/Repl.Mcp/McpInteractionChannel.cs index 033c1ebe..6b0c85f4 100644 --- a/src/Repl.Mcp/McpInteractionChannel.cs +++ b/src/Repl.Mcp/McpInteractionChannel.cs @@ -6,8 +6,9 @@ using Repl.Interaction; // Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK -// diagnostic MCP9005) with no replacement API; hosts still rely on them, so Repl keeps -// supporting the features until the SDK removes them. Tracked in issue #51. +// diagnostic MCP9005); the designated successor for server-initiated flows (SEP-2322, +// multi-round-trip requests) is not yet consumable in the SDK and hosts still rely on +// these features, so Repl keeps supporting them until the SDK removes the surface (#51). #pragma warning disable MCP9005 namespace Repl.Mcp; diff --git a/src/Repl.Mcp/McpSamplingService.cs b/src/Repl.Mcp/McpSamplingService.cs index 53926aa0..1f8dcddb 100644 --- a/src/Repl.Mcp/McpSamplingService.cs +++ b/src/Repl.Mcp/McpSamplingService.cs @@ -2,8 +2,9 @@ using ModelContextProtocol.Server; // Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK -// diagnostic MCP9005) with no replacement API; hosts still rely on them, so Repl keeps -// supporting the features until the SDK removes them. Tracked in issue #51. +// diagnostic MCP9005); the designated successor for server-initiated flows (SEP-2322, +// multi-round-trip requests) is not yet consumable in the SDK and hosts still rely on +// these features, so Repl keeps supporting them until the SDK removes the surface (#51). #pragma warning disable MCP9005 namespace Repl.Mcp; diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index 2a4eaf4d..3c1fda4c 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -6,11 +6,6 @@ using ModelContextProtocol.Server; using Repl.Documentation; -// Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK -// diagnostic MCP9005) with no replacement API; hosts still rely on them, so Repl keeps -// supporting the features until the SDK removes them. Tracked in issue #51. -#pragma warning disable MCP9005 - namespace Repl.Mcp; /// @@ -464,6 +459,9 @@ private void EnsureRootsNotificationHandler(McpServer server) } var weakSelf = new WeakReference(this); + // 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, (_, _) => @@ -475,6 +473,7 @@ private void EnsureRootsNotificationHandler(McpServer server) return ValueTask.CompletedTask; }); +#pragma warning restore MCP9005 } private void OnRoutingInvalidated() @@ -542,6 +541,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(), @@ -549,6 +552,7 @@ private ServerCapabilities BuildCapabilities() Resources = new ResourcesCapability { ListChanged = true }, Prompts = new PromptsCapability { ListChanged = true }, }; +#pragma warning restore MCP9005 if (_options.EnableApps || HasMcpAppResources()) { diff --git a/src/Repl.Mcp/README.md b/src/Repl.Mcp/README.md index 06ee966b..8ba4862f 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 with the SDK Tasks runtime), and `.AutomationHidden()` for commands that should stay available to humans but invisible to MCP automation. Prefer returning JSON-friendly objects instead of writing prose-only output. Structured results are easier for agents to inspect, retry, test, and summarize. diff --git a/src/Repl.Mcp/ReplMcpServerTool.cs b/src/Repl.Mcp/ReplMcpServerTool.cs index 94845834..c5e3bbf7 100644 --- a/src/Repl.Mcp/ReplMcpServerTool.cs +++ b/src/Repl.Mcp/ReplMcpServerTool.cs @@ -14,10 +14,11 @@ internal sealed class ReplMcpServerTool : McpServerTool private readonly McpToolAdapter _adapter; private readonly Tool _protocolTool; - // SDK 2.0 removed the experimental MCP Tasks tool-augmentation surface - // (Tool.Execution / ToolTaskSupport, MCPEXP001 in 1.x): the Tasks SEP was deferred out - // of the 2.0 protocol release. Repl keeps .LongRunning() in its own model (help/docs); - // advertising task support returns with the SDK's Tasks runtime (tracked in issue #51). + // 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 the Tasks runtime + // (tasks/get|update|cancel) is implemented end-to-end — tracked in issue #51. public ReplMcpServerTool( ReplDocCommand command, string toolName, diff --git a/src/Repl.McpTests/Given_McpIntegration.cs b/src/Repl.McpTests/Given_McpIntegration.cs index 5eba1b06..ce00729d 100644 --- a/src/Repl.McpTests/Given_McpIntegration.cs +++ b/src/Repl.McpTests/Given_McpIntegration.cs @@ -1,10 +1,5 @@ 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] @@ -79,7 +74,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] @@ -122,6 +120,27 @@ public void When_EnrichedCommands_Then_DocModelContainsAllFields() cmd.Arguments.Should().ContainSingle(a => string.Equals(a.Name, "env", StringComparison.Ordinal)); } + [TestMethod] + [Description("Locks the SDK-2.0 tools/list wire shape for .LongRunning() commands: annotations survive serialization, and no task/execution augmentation is emitted — Repl deliberately does not advertise MCP task support until the Tasks runtime is implemented end-to-end (issue #51).")] + public void When_SerializingLongRunningTool_Then_NoTaskAugmentationIsEmitted() + { + var app = ReplApp.Create(); + app.Map("deploy", () => "deployed") + .WithDescription("Deploy application") + .LongRunning() + .OpenWorld(); + + var options = app.BuildMcpServerOptions(); + var tool = options.ToolCollection!.Single(tool => + string.Equals(tool.ProtocolTool.Name, "deploy", StringComparison.Ordinal)); + var json = System.Text.Json.JsonSerializer.Serialize( + tool.ProtocolTool, ModelContextProtocol.McpJsonUtilities.DefaultOptions); + + json.Should().Contain("\"openWorldHint\""); + json.Should().NotContain("execution"); + json.Should().NotContain("taskSupport"); + } + [TestMethod] [Description("Modules excluded from Programmatic channel are not visible as MCP tools.")] public void When_ModuleExcludedFromProgrammatic_Then_NotInToolCandidates() From c235f9a90ee2b71e10e4177ae9ae9bde83053a7c Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 16 Jul 2026 21:39:37 -0400 Subject: [PATCH 3/7] fix(mcp): bind capability services to the flowing request, not a shared server field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDK 2.0's 2026-07-28 protocol path hands each request a destination-bound McpServer, and one handler can serve several sessions. The four capability services (roots, sampling, elicitation, feedback) stored the last-attached server in a shared mutable field, so a concurrent request from another session could cross-wire capabilities mid-call (IsSupported flipping while a handler was awaiting). - McpRequestServerAccessor: AsyncLocal request binding flowing with the invocation, session-level server as fallback for code outside a request (routing notifications, roots list-changed handler). - Services resolve the effective server through the accessor with a single read per operation (no torn check-then-use). - McpServerHandler splits session-level attach (RunAsync, once) from request-level binding (every handler); externally hosted servers adopt the first observed server for session concerns. - Deterministic regression: two sessions on one handler, sampling-capable client pauses mid-call while a sampling-less client is served — the paused call must keep observing ITS client's capabilities (RED observed: 'True|False' on the pre-fix code, exactly the reported repro). --- src/Repl.Mcp/McpClientRootsService.cs | 17 ++-- src/Repl.Mcp/McpElicitationService.cs | 14 ++- src/Repl.Mcp/McpFeedbackService.cs | 23 +++-- src/Repl.Mcp/McpRequestServerAccessor.cs | 31 ++++++ src/Repl.Mcp/McpSamplingService.cs | 13 ++- src/Repl.Mcp/McpServerHandler.cs | 53 +++++++--- .../Given_McpConcurrentSessions.cs | 97 +++++++++++++++++++ src/Repl.McpTests/McpTestFixture.cs | 2 + 8 files changed, 197 insertions(+), 53 deletions(-) create mode 100644 src/Repl.Mcp/McpRequestServerAccessor.cs diff --git a/src/Repl.Mcp/McpClientRootsService.cs b/src/Repl.Mcp/McpClientRootsService.cs index 16a4fd79..8db57690 100644 --- a/src/Repl.Mcp/McpClientRootsService.cs +++ b/src/Repl.Mcp/McpClientRootsService.cs @@ -12,19 +12,20 @@ namespace Repl.Mcp; 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 { @@ -48,15 +49,11 @@ public IReadOnlyList Current } } - public void AttachServer(McpServer server) - { - ArgumentNullException.ThrowIfNull(server); - _server = server; - } - public async ValueTask> GetAsync(CancellationToken cancellationToken = default) { - var server = _server; + // Single read: the effective server must not change between the support check and + // the roots request (a concurrent request re-binding the accessor must not be observed). + var server = _servers.Effective; if (server?.ClientCapabilities?.Roots is null) { return Current; diff --git a/src/Repl.Mcp/McpElicitationService.cs b/src/Repl.Mcp/McpElicitationService.cs index 12de5219..40a57af9 100644 --- a/src/Repl.Mcp/McpElicitationService.cs +++ b/src/Repl.Mcp/McpElicitationService.cs @@ -15,13 +15,11 @@ namespace Repl.Mcp; /// a multi-field variant would build the with /// multiple properties instead of one. /// -internal sealed class McpElicitationService : IMcpElicitation +internal sealed class McpElicitationService(McpRequestServerAccessor servers) : IMcpElicitation { private const string FieldName = "value"; - private McpServer? _server; - - public bool IsSupported => _server?.ClientCapabilities?.Elicitation is not null; + public bool IsSupported => servers.Effective?.ClientCapabilities?.Elicitation is not null; public async ValueTask ElicitTextAsync( string message, @@ -99,19 +97,19 @@ internal sealed class McpElicitationService : IMcpElicitation : null; } - internal void AttachServer(McpServer server) => _server = server; - private async ValueTask ElicitSingleFieldAsync( string message, ElicitRequestParams.PrimitiveSchemaDefinition schema, CancellationToken cancellationToken) { - if (!IsSupported) + // Single read: the effective server must not change between the support check and + // the call (a concurrent request re-binding the accessor must not be observed). + if (servers.Effective is not { ClientCapabilities.Elicitation: not null } server) { return null; } - var result = await _server!.ElicitAsync( + var result = await server.ElicitAsync( new ElicitRequestParams { Message = message, diff --git a/src/Repl.Mcp/McpFeedbackService.cs b/src/Repl.Mcp/McpFeedbackService.cs index efbfaaa2..6ad0505d 100644 --- a/src/Repl.Mcp/McpFeedbackService.cs +++ b/src/Repl.Mcp/McpFeedbackService.cs @@ -16,19 +16,15 @@ namespace Repl.Mcp; /// /// Internal implementation of backed by a live session. /// -internal sealed class McpFeedbackService : IMcpFeedback +internal sealed class McpFeedbackService(McpRequestServerAccessor servers) : IMcpFeedback { private const string LoggerName = "repl.interaction"; private readonly AsyncLocal _progressToken = new(); - // This service is created per McpServerHandler/session and overlaid into the - // per-connection service provider, so the attached server reference is not shared - // across concurrent MCP connections. - private McpServer? _server; - public bool IsProgressSupported => _server is not null && _progressToken.Value is not null; + public bool IsProgressSupported => servers.Effective is not null && _progressToken.Value is not null; - public bool IsLoggingSupported => _server is not null; + public bool IsLoggingSupported => servers.Effective is not null; public async ValueTask ReportProgressAsync( ReplProgressEvent progress, @@ -36,13 +32,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 { @@ -58,12 +58,12 @@ public async ValueTask SendMessageAsync( object? data, CancellationToken cancellationToken = default) { - if (!IsLoggingSupported) + if (servers.Effective is not { } server) { return; } - await _server!.SendNotificationAsync( + await server.SendNotificationAsync( NotificationMethods.LoggingMessageNotification, new LoggingMessageNotificationParams { @@ -74,7 +74,6 @@ public async ValueTask SendMessageAsync( cancellationToken: cancellationToken).ConfigureAwait(false); } - internal void AttachServer(McpServer server) => _server = server; internal IDisposable PushProgressToken(ProgressToken? progressToken) => new ProgressTokenScope(_progressToken, progressToken); diff --git a/src/Repl.Mcp/McpRequestServerAccessor.cs b/src/Repl.Mcp/McpRequestServerAccessor.cs new file mode 100644 index 00000000..91ad2429 --- /dev/null +++ b/src/Repl.Mcp/McpRequestServerAccessor.cs @@ -0,0 +1,31 @@ +using ModelContextProtocol.Server; + +namespace Repl.Mcp; + +/// +/// Resolves the a capability call must target. +/// +/// +/// SDK 2.0's 2026-07-28 protocol path hands each request a destination-bound +/// , and one handler can serve several sessions. The capability +/// services are singletons (exposed through DI to command handlers), so the effective +/// server must be the one bound to the FLOWING request — a shared mutable field would be +/// overwritten by whichever request attached last, cross-wiring capabilities between +/// concurrent calls. flows with the invocation and cannot +/// leak across requests; the session-level server remains the fallback for code running +/// outside a request (e.g. routing-change notifications). +/// +internal sealed class McpRequestServerAccessor +{ + private readonly AsyncLocal _current = new(); + private McpServer? _session; + + /// Server for the flowing request, falling back to the session server. + public McpServer? Effective => _current.Value ?? _session; + + /// Binds the flowing async context to the request's destination server. + public void BindRequest(McpServer server) => _current.Value = server; + + /// Records the session-level server used outside request flows. + public void AttachSession(McpServer server) => _session = server; +} diff --git a/src/Repl.Mcp/McpSamplingService.cs b/src/Repl.Mcp/McpSamplingService.cs index 1f8dcddb..c04d6b52 100644 --- a/src/Repl.Mcp/McpSamplingService.cs +++ b/src/Repl.Mcp/McpSamplingService.cs @@ -12,23 +12,23 @@ 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 = @@ -46,5 +46,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 3c1fda4c..ff39cbac 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -25,6 +25,7 @@ internal sealed class McpServerHandler private readonly IServiceProvider _services; private readonly TimeProvider _timeProvider; private readonly char _separator; + private readonly McpRequestServerAccessor _requestServers = new(); private readonly McpClientRootsService _roots; private readonly McpSamplingService _sampling; private readonly McpElicitationService _elicitation; @@ -54,10 +55,10 @@ 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(); + _roots = new McpClientRootsService(app, _requestServers); + _sampling = new McpSamplingService(_requestServers); + _elicitation = new McpElicitationService(_requestServers); + _feedback = new McpFeedbackService(_requestServers); _sessionServices = new McpServiceProviderOverlay( services, new Dictionary @@ -163,7 +164,7 @@ private async ValueTask ListToolsAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); + BindRequestServer(request.Server); var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); if (_options.DynamicToolCompatibility == DynamicToolCompatibilityMode.DiscoverAndCallShim @@ -190,7 +191,7 @@ private async ValueTask CallToolAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); + BindRequestServer(request.Server); var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); IDictionary arguments = request.Params.Arguments ?? EmptyArguments; var toolName = request.Params.Name ?? string.Empty; @@ -222,7 +223,7 @@ private async ValueTask ListResourcesAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); + BindRequestServer(request.Server); var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); return new ListResourcesResult { @@ -239,7 +240,7 @@ private async ValueTask ListResourceTemplatesAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); + BindRequestServer(request.Server); var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); return new ListResourceTemplatesResult { @@ -256,7 +257,7 @@ private async ValueTask ReadResourceAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); + BindRequestServer(request.Server); var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); var uri = request.Params.Uri ?? string.Empty; var resource = snapshot.Resources.FirstOrDefault(candidate => candidate.IsMatch(uri)); @@ -272,7 +273,7 @@ private async ValueTask ListPromptsAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); + BindRequestServer(request.Server); var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); return new ListPromptsResult { @@ -284,7 +285,7 @@ private async ValueTask GetPromptAsync( RequestContext request, CancellationToken cancellationToken) { - AttachServer(request.Server); + BindRequestServer(request.Server); var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); var promptName = request.Params.Name ?? string.Empty; var prompt = snapshot.Prompts.FirstOrDefault(candidate => @@ -301,7 +302,7 @@ private async ValueTask GetSnapshotAsync( McpServer? server, CancellationToken cancellationToken) { - AttachServer(server); + BindRequestServer(server); var snapshotVersion = Volatile.Read(ref _snapshotVersion); if (Volatile.Read(ref _builtSnapshotVersion) == snapshotVersion @@ -403,6 +404,29 @@ private void ValidateCompatibilityToolNames(IReadOnlyList tools) } } + // Request-level binding: capability services resolve the flowing request's + // destination-bound server through the AsyncLocal accessor, so concurrent requests + // (SDK 2.0 creates one destination-bound McpServer per request) cannot cross-wire + // each other's client capabilities. Externally hosted servers (options built via + // BuildDynamicServerOptions and run by the host, without RunAsync's session attach) + // adopt the first observed server for session-level concerns. + private void BindRequestServer(McpServer? server) + { + if (server is null) + { + return; + } + + _requestServers.BindRequest(server); + if (_server is null) + { + AttachServer(server); + } + } + + // Session-level attach: routing-change notifications and the roots list-changed + // handler belong to the session server, registered once — never to the per-request + // destination wrappers. private void AttachServer(McpServer? server) { if (server is null) @@ -418,10 +442,7 @@ private void AttachServer(McpServer? server) } _server = server; - _roots.AttachServer(server); - _sampling.AttachServer(server); - _elicitation.AttachServer(server); - _feedback.AttachServer(server); + _requestServers.AttachSession(server); EnsureRoutingSubscription(); EnsureRootsNotificationHandler(server); } diff --git a/src/Repl.McpTests/Given_McpConcurrentSessions.cs b/src/Repl.McpTests/Given_McpConcurrentSessions.cs index 6bfadfec..1bffbc75 100644 --- a/src/Repl.McpTests/Given_McpConcurrentSessions.cs +++ b/src/Repl.McpTests/Given_McpConcurrentSessions.cs @@ -1,10 +1,107 @@ +using System.IO.Pipelines; +using ModelContextProtocol; +using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Repl.Mcp; + +// One test exercises Sampling, deprecated by MCP spec 2026-07-28 (SEP-2577, MCP9005) +// but still supported by Repl.Mcp until the SDK removes the surface (#51). +#pragma warning disable MCP9005 namespace Repl.McpTests; [TestClass] public sealed class Given_McpConcurrentSessions { + [TestMethod] + [Description("Guards capability binding against cross-session interference: with one handler serving two sessions (SDK 2.0 binds a destination server per request), a paused call from a sampling-capable client must still observe ITS OWN client's capabilities after a request from a sampling-less client has been served — the capability services must bind to the flowing request, not to a shared last-attached server.")] + public async Task When_TwoClientsWithDifferentCapabilitiesShareHandler_Then_CapabilityBindingIsPerRequest() + { + using var entered = new SemaphoreSlim(0, 1); + using var gate = new SemaphoreSlim(0, 1); + + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("probe", async (IMcpSampling sampling) => + { + var before = sampling.IsSupported; + entered.Release(); + await gate.WaitAsync().ConfigureAwait(false); + var after = sampling.IsSupported; + return $"{before}|{after}"; + }); + app.Map("poke", () => "ok"); + + var options = new ReplMcpServerOptions + { + TransportFactory = static (serverName, io) => new StreamServerTransport( + ((McpTestFixture.PipeIoContext)io).InputStream, + ((McpTestFixture.PipeIoContext)io).OutputStream, + serverName), + }; + var handler = new McpServerHandler(app.Core, options, McpTestFixture.EmptyServices); + using var cts = new CancellationTokenSource(); + + var (clientA, serverTaskA) = await StartSessionAsync(handler, BuildSamplingClientOptions(), cts.Token).ConfigureAwait(false); + var (clientB, serverTaskB) = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + + // Session A enters "probe" (sampling supported) and pauses on the gate; session B is + // then served in full; A resumes and must STILL see its own sampling capability. + var probeTask = clientA.CallToolAsync( + "probe", new Dictionary(StringComparer.Ordinal), cancellationToken: cts.Token); + (await entered.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false)).Should().BeTrue(); + + await clientB.CallToolAsync( + "poke", new Dictionary(StringComparer.Ordinal), cancellationToken: cts.Token) + .ConfigureAwait(false); + + gate.Release(); + var probeResult = await probeTask.ConfigureAwait(false); + + var text = probeResult.Content.OfType().First().Text; + text.Should().Contain("True|True"); + + await clientA.DisposeAsync().ConfigureAwait(false); + await clientB.DisposeAsync().ConfigureAwait(false); + await cts.CancelAsync().ConfigureAwait(false); + _ = serverTaskA; + _ = serverTaskB; + } + + private static McpClientOptions BuildSamplingClientOptions() => new() + { + Capabilities = new ClientCapabilities { Sampling = new SamplingCapability() }, + Handlers = new McpClientHandlers + { + SamplingHandler = static (request, _, _) => ValueTask.FromResult(new CreateMessageResult + { + Content = [new TextContentBlock { Text = "ga" }], + Model = "test-model", + }), + }, + }; + + private static async Task<(McpClient Client, Task ServerTask)> StartSessionAsync( + McpServerHandler handler, + McpClientOptions? clientOptions, + CancellationToken cancellationToken) + { + var clientToServer = new Pipe(); + var serverToClient = new Pipe(); + var io = new McpTestFixture.PipeIoContext( + clientToServer.Reader.AsStream(), + serverToClient.Writer.AsStream()); + var serverTask = handler.RunAsync(io, cancellationToken); + + var clientTransport = new StreamClientTransport( + clientToServer.Writer.AsStream(), + serverToClient.Reader.AsStream()); + var client = await McpClient.CreateAsync(clientTransport, clientOptions, cancellationToken: cancellationToken) + .ConfigureAwait(false); + return (client, serverTask); + } + [TestMethod] [Description("Two independent MCP sessions can run concurrently without interference.")] public async Task When_TwoSessionsRunConcurrently_Then_EachSeesOwnTools() diff --git a/src/Repl.McpTests/McpTestFixture.cs b/src/Repl.McpTests/McpTestFixture.cs index 23c90048..72252a3e 100644 --- a/src/Repl.McpTests/McpTestFixture.cs +++ b/src/Repl.McpTests/McpTestFixture.cs @@ -113,6 +113,8 @@ public async ValueTask DisposeAsync() _cts.Dispose(); } + internal static IServiceProvider EmptyServices => EmptyServiceProvider.Instance; + private sealed class EmptyServiceProvider : IServiceProvider { public static readonly EmptyServiceProvider Instance = new(); From 3cc415ae4347733fb32e400d8804198c0b961f98 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 16 Jul 2026 21:48:52 -0400 Subject: [PATCH 4/7] fix(mcp): legacy handshake regression, legacy-compat warnings, honest Tasks wording (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Regression pinning the last initialize-era protocol revision (2025-11-25): asserts the negotiated version and a tool list + call — the default client negotiates 2026-07-28 and never exercised the fallback path. - Roots/Sampling/Logging documented as legacy-compatibility only: deprecation notices in mcp-agent-capabilities.md and mcp-advanced.md steer new applications toward IReplInteractionChannel / soft roots; mcp-reference.md no longer reads as an endorsement. - Tasks wording corrected everywhere: the SDK has shipped the Tasks extension (ModelContextProtocol.Extensions.Tasks); what is pending is Repl's integration (issue #72) — including the CommandAnnotations.LongRunning XML doc that still promised task-based execution. --- docs/for-coding-agents.md | 2 +- docs/mcp-advanced.md | 7 ++++++ docs/mcp-agent-capabilities.md | 8 ++++++ docs/mcp-overview.md | 2 +- docs/mcp-reference.md | 4 +-- src/Repl.Core/CommandAnnotations.cs | 5 ++-- src/Repl.Mcp/README.md | 2 +- src/Repl.Mcp/ReplMcpServerTool.cs | 4 +-- src/Repl.McpTests/Given_McpIntegration.cs | 30 +++++++++++++++++++++++ 9 files changed, 55 insertions(+), 9 deletions(-) diff --git a/docs/for-coding-agents.md b/docs/for-coding-agents.md index d845abf1..087d07da 100644 --- a/docs/for-coding-agents.md +++ b/docs/for-coding-agents.md @@ -142,7 +142,7 @@ Use these annotations to help agents make safer decisions: | `.Destructive()` | May delete or mutate important state; ask for confirmation. | | `.Idempotent()` | Safe to retry. | | `.OpenWorld()` | Talks to external systems; expect latency and failures. | -| `.LongRunning()` | May take time. Documentation hint for now — no protocol-level task advertisement until the MCP Tasks runtime lands. | +| `.LongRunning()` | May take time. Documentation hint for now — no protocol-level task advertisement until Repl integrates the SDK Tasks extension. | | `.AutomationHidden()` | Do not expose this command to MCP automation. | Unannotated tools force agents to assume the worst. Annotate every command that will be visible through MCP. diff --git a/docs/mcp-advanced.md b/docs/mcp-advanced.md index f82ebdd9..90ea3f62 100644 --- a/docs/mcp-advanced.md +++ b/docs/mcp-advanced.md @@ -20,6 +20,13 @@ If your tool list is static, stay with the default setup from [mcp-overview.md]( ## Client roots +> **⚠️ Deprecation notice (SEP-2577):** the MCP specification (2026-07-28) deprecates the +> Roots feature, and the SDK may remove it in a future version. Repl keeps supporting it +> **for existing hosts and applications only** — new applications should not build on +> native MCP roots and can use [soft roots](#soft-roots-fallback) or explicit command +> parameters instead. See +> [mcp-reference.md](mcp-reference.md#sdk-and-protocol-versions) for the version posture. + A **root** is a URI the client declares as being in scope for the session — typically an opened project folder, a working directory, or a boundary for what the agent should inspect or modify. Roots give the server session-specific workspace context without inventing a custom protocol. When the client supports native MCP roots, `Repl.Mcp` exposes them through `IMcpClientRoots`. diff --git a/docs/mcp-agent-capabilities.md b/docs/mcp-agent-capabilities.md index cac9ce4e..f4a1e8c4 100644 --- a/docs/mcp-agent-capabilities.md +++ b/docs/mcp-agent-capabilities.md @@ -8,6 +8,14 @@ See also: [sample 08-mcp-server](../samples/08-mcp-server/) for a working example that uses all three in a CSV import and feedback workflow. +> **⚠️ Deprecation notice (SEP-2577):** the MCP specification (2026-07-28) deprecates the +> Sampling and Logging features that `IMcpSampling` and `IMcpFeedback` build on, and the +> SDK may remove them in a future version. Repl keeps supporting them **for existing hosts +> and applications only** — new applications should not adopt these interfaces directly and +> should prefer the portable `IReplInteractionChannel`, which degrades gracefully across +> CLI, REPL, hosted sessions, and MCP. See +> [mcp-reference.md](mcp-reference.md#sdk-and-protocol-versions) for the version posture. + ## Overview Repl provides three MCP-oriented injectable interfaces: diff --git a/docs/mcp-overview.md b/docs/mcp-overview.md index c0bc99e1..0982bcdd 100644 --- a/docs/mcp-overview.md +++ b/docs/mcp-overview.md @@ -63,7 +63,7 @@ app.Map("deploy", handler).Destructive().LongRunning().OpenWorld(); | `.Destructive()` | Ask user for confirmation, sequential | | `.Idempotent()` | Safe to retry, can parallelize | | `.OpenWorld()` | Reaches external systems — expect latency and transient failures | -| `.LongRunning()` | Slow-operation hint (protocol-level task advertisement returns with the SDK Tasks runtime — see [mcp-reference.md](mcp-reference.md#sdk-and-protocol-versions)) | +| `.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 922e6951..6600d9fb 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -513,8 +513,8 @@ Feature support varies across agents. Check [mcp-availability.com](https://mcp-a ### SDK and protocol versions - Repl.Mcp builds on the official C# SDK (`ModelContextProtocol`), currently on the **2.0 line** (`2.0.0-preview.3`). The SDK negotiates the protocol version with each client, including fallback to the legacy `initialize` handshake for older hosts. -- **Roots, Sampling, and Logging** are deprecated by MCP specification 2026-07-28 (SEP-2577) but remain fully functional; Repl.Mcp keeps supporting them until the SDK removes them, since current hosts still rely on these features. -- **MCP Tasks**: SDK 2.0 removed the experimental tool-execution augmentation (`Tool.Execution`), so `.LongRunning()` commands no longer advertise task support at the protocol level. The annotation stays in Repl's own model (help/docs), and protocol-level task support will return once the SDK ships its Tasks runtime. +- **Roots, Sampling, and Logging** are deprecated by MCP specification 2026-07-28 (SEP-2577). Repl.Mcp keeps supporting them **for existing hosts and applications only** — new applications should not adopt these features (the SDK may remove them) and should prefer Repl's portable abstractions such as `IReplInteractionChannel`. The designated successor for server-initiated flows (SEP-2322, multi-round-trip requests) is not yet consumable in the SDK. +- **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 | |---|---|---|---|---|---|---| diff --git a/src/Repl.Core/CommandAnnotations.cs b/src/Repl.Core/CommandAnnotations.cs index 9b7a5dbf..8c66dad0 100644 --- a/src/Repl.Core/CommandAnnotations.cs +++ b/src/Repl.Core/CommandAnnotations.cs @@ -31,8 +31,9 @@ public sealed record CommandAnnotations public bool OpenWorld { get; init; } /// - /// Indicates the command may take a long time to complete. - /// Enables task-based execution in programmatic clients. + /// Indicates the command may take a long time to complete, so programmatic clients + /// should expect a slow call. Protocol-level task-based execution (MCP Tasks) is not + /// advertised until Repl integrates the SDK's Tasks extension. /// public bool LongRunning { get; init; } diff --git a/src/Repl.Mcp/README.md b/src/Repl.Mcp/README.md index 8ba4862f..09dfb2eb 100644 --- a/src/Repl.Mcp/README.md +++ b/src/Repl.Mcp/README.md @@ -104,7 +104,7 @@ app.Map("debug reset", handler) .AutomationHidden(); ``` -Unannotated tools force agents to assume the worst. Use `.ReadOnly()` for safe queries, `.Destructive()` for important mutations, `.OpenWorld()` for external systems, `.LongRunning()` for slow operations (a documentation hint today — protocol-level MCP task advertisement returns with the SDK Tasks runtime), and `.AutomationHidden()` for commands that should stay available to humans but invisible to MCP automation. +Unannotated tools force agents to assume the worst. Use `.ReadOnly()` for safe queries, `.Destructive()` for important mutations, `.OpenWorld()` for external systems, `.LongRunning()` for slow operations (a documentation hint today — protocol-level MCP task advertisement returns once Repl integrates the SDK Tasks extension), and `.AutomationHidden()` for commands that should stay available to humans but invisible to MCP automation. Prefer returning JSON-friendly objects instead of writing prose-only output. Structured results are easier for agents to inspect, retry, test, and summarize. diff --git a/src/Repl.Mcp/ReplMcpServerTool.cs b/src/Repl.Mcp/ReplMcpServerTool.cs index c5e3bbf7..a6376770 100644 --- a/src/Repl.Mcp/ReplMcpServerTool.cs +++ b/src/Repl.Mcp/ReplMcpServerTool.cs @@ -17,8 +17,8 @@ internal sealed class ReplMcpServerTool : McpServerTool // 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 the Tasks runtime - // (tasks/get|update|cancel) is implemented end-to-end — tracked in issue #51. + // (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, diff --git a/src/Repl.McpTests/Given_McpIntegration.cs b/src/Repl.McpTests/Given_McpIntegration.cs index ce00729d..22cd245d 100644 --- a/src/Repl.McpTests/Given_McpIntegration.cs +++ b/src/Repl.McpTests/Given_McpIntegration.cs @@ -1,3 +1,5 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; using Repl.Mcp; namespace Repl.McpTests; @@ -120,6 +122,34 @@ public void When_EnrichedCommands_Then_DocModelContainsAllFields() cmd.Arguments.Should().ContainSingle(a => string.Equals(a.Name, "env", StringComparison.Ordinal)); } + [TestMethod] + [Description("Locks the legacy initialize handshake under SDK 2.0: a client pinning an initialize-era protocol revision still negotiates that exact version and can list and call tools — the fixture's default client otherwise negotiates the 2026-07-28 path and never exercises the fallback.")] + public async Task When_ClientPinsLegacyProtocolVersion_Then_InitializeHandshakeAndToolsWork() + { + // 2025-11-25 is the last initialize-era protocol revision (the SDK's + // McpProtocolVersions constants are internal, so the literal is pinned here). + const string legacyProtocolVersion = "2025-11-25"; + var clientOptions = new McpClientOptions + { + ProtocolVersion = legacyProtocolVersion, + }; + + await using var fixture = await McpTestFixture.CreateAsync( + app => app.Map("ping", () => "pong"), + configureOptions: null, + clientOptions: clientOptions); + + fixture.Client.NegotiatedProtocolVersion.Should().Be(legacyProtocolVersion); + + var tools = await fixture.Client.ListToolsAsync().ConfigureAwait(false); + tools.Should().ContainSingle(tool => string.Equals(tool.Name, "ping", StringComparison.Ordinal)); + + var result = await fixture.Client.CallToolAsync( + toolName: "ping", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + result.Content.OfType().First().Text.Should().Contain("pong"); + } + [TestMethod] [Description("Locks the SDK-2.0 tools/list wire shape for .LongRunning() commands: annotations survive serialization, and no task/execution augmentation is emitted — Repl deliberately does not advertise MCP task support until the Tasks runtime is implemented end-to-end (issue #51).")] public void When_SerializingLongRunningTool_Then_NoTaskAugmentationIsEmitted() From b094d055069b89b2175330d959400342321ad6ed Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 17 Jul 2026 10:18:26 -0400 Subject: [PATCH 5/7] fix(mcp): per-session roots cache + reference-counted session lifecycle (review) - Hard roots are now SESSION state: entries keyed by destination server in a ConditionalWeakTable (weak keys die with the session), with a global version stamp for roots-list-changed invalidation. One session can no longer receive another session's cached workspace roots, and the root-dependent snapshot builds from the right workspace (RED observed: client B received client A's roots). - Session attachment is reference-counted: the handler tracks every active session, discovery notifications fan out to ALL of them, the accessor fallback moves to a surviving session on close, and the routing subscription is dropped only when the LAST session ends. A first-session close no longer silences the survivors (RED observed: surviving session timed out waiting for tools/list_changed). - Roots list-changed handler registered once per session (per-server registration replaces the single global flag). --- src/Repl.Mcp/McpClientRootsService.cs | 51 +++++--- src/Repl.Mcp/McpRequestServerAccessor.cs | 4 +- src/Repl.Mcp/McpServerHandler.cs | 73 ++++++++---- .../Given_McpConcurrentSessions.cs | 109 ++++++++++++++++++ 4 files changed, 194 insertions(+), 43 deletions(-) diff --git a/src/Repl.Mcp/McpClientRootsService.cs b/src/Repl.Mcp/McpClientRootsService.cs index 8db57690..41f0e7d4 100644 --- a/src/Repl.Mcp/McpClientRootsService.cs +++ b/src/Repl.Mcp/McpClientRootsService.cs @@ -14,10 +14,15 @@ internal sealed class McpClientRootsService : IMcpClientRoots private readonly ICoreReplApp _app; private readonly McpRequestServerAccessor _servers; private readonly Lock _syncRoot = new(); - private McpClientRoot[] _hardRoots = []; + // Hard roots are SESSION state: one handler can serve several root-capable sessions, + // and serving session A's cached roots to session B would expose A's workspace URIs + // and build B's root-dependent snapshot from the wrong workspace. Entries are keyed by + // the destination server (weak keys — they die with the session); a single global + // version stamp invalidates every entry on any roots-list change (coarse, but the + // event is rare and correctness beats granularity here). + private readonly System.Runtime.CompilerServices.ConditionalWeakTable _sessionRoots = []; private McpClientRoot[] _softRoots = []; - private bool _hardRootsLoaded; - private long _hardRootsVersion; + private long _rootsVersion; public McpClientRootsService(ICoreReplApp app, McpRequestServerAccessor servers) { @@ -25,6 +30,13 @@ public McpClientRootsService(ICoreReplApp app, McpRequestServerAccessor servers) _servers = servers; } + private sealed class SessionRoots + { + public McpClientRoot[] Roots = []; + public bool Loaded; + public long LoadedVersion; + } + public bool IsSupported => _servers.Effective?.ClientCapabilities?.Roots is not null; public bool HasSoftRoots @@ -42,9 +54,16 @@ public IReadOnlyList Current { get { + var server = _servers.Effective; lock (_syncRoot) { - return IsSupported ? _hardRoots : _softRoots; + if (server?.ClientCapabilities?.Roots is null) + { + return _softRoots; + } + + var entry = _sessionRoots.GetOrCreateValue(server); + return entry.Loaded && entry.LoadedVersion == _rootsVersion ? entry.Roots : []; } } } @@ -59,18 +78,18 @@ public async ValueTask> GetAsync(CancellationToken return Current; } + var entry = _sessionRoots.GetOrCreateValue(server); long versionAtStart; lock (_syncRoot) { - if (_hardRootsLoaded) + versionAtStart = _rootsVersion; + if (entry.Loaded && entry.LoadedVersion == versionAtStart) { - return _hardRoots; + return entry.Roots; } - - versionAtStart = _hardRootsVersion; } - return await GetAndMaybeCacheRootsAsync(server, versionAtStart, cancellationToken).ConfigureAwait(false); + return await GetAndMaybeCacheRootsAsync(server, entry, versionAtStart, cancellationToken).ConfigureAwait(false); } public void SetSoftRoots(IEnumerable roots) @@ -116,9 +135,7 @@ public void HandleRootsListChanged() { lock (_syncRoot) { - _hardRoots = []; - _hardRootsLoaded = false; - _hardRootsVersion++; + _rootsVersion++; } _app.InvalidateRouting(); @@ -126,6 +143,7 @@ public void HandleRootsListChanged() private async ValueTask> GetAndMaybeCacheRootsAsync( McpServer server, + SessionRoots entry, long versionAtStart, CancellationToken cancellationToken) { @@ -135,11 +153,12 @@ private async ValueTask> GetAndMaybeCacheRootsAsync lock (_syncRoot) { - if (_hardRootsVersion == versionAtStart) + if (_rootsVersion == versionAtStart) { - _hardRoots = mappedRoots; - _hardRootsLoaded = true; - return _hardRoots; + entry.Roots = mappedRoots; + entry.Loaded = true; + entry.LoadedVersion = versionAtStart; + return entry.Roots; } return mappedRoots; diff --git a/src/Repl.Mcp/McpRequestServerAccessor.cs b/src/Repl.Mcp/McpRequestServerAccessor.cs index 91ad2429..be138772 100644 --- a/src/Repl.Mcp/McpRequestServerAccessor.cs +++ b/src/Repl.Mcp/McpRequestServerAccessor.cs @@ -26,6 +26,6 @@ internal sealed class McpRequestServerAccessor /// Binds the flowing async context to the request's destination server. public void BindRequest(McpServer server) => _current.Value = server; - /// Records the session-level server used outside request flows. - public void AttachSession(McpServer server) => _session = server; + /// Records the session-level server used outside request flows (null when the last session ends). + public void AttachSession(McpServer? server) => _session = server; } diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index ff39cbac..ab508f49 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -38,10 +38,12 @@ internal sealed class McpServerHandler private McpGeneratedSnapshot? _snapshot; private long _snapshotVersion = 1; private long _builtSnapshotVersion; - private McpServer? _server; + // One handler can serve several concurrent sessions; session-scoped concerns (routing + // notifications, roots list-changed handler) track EVERY active session, not a single + // last- or first-attached server. Guarded by _attachLock. + private readonly List _sessions = []; private EventHandler? _routingChangedHandler; private ITimer? _debounceTimer; - private int _rootsNotificationRegistered; private int _compatibilityIntroServed; private static readonly TimeSpan DebounceDelay = TimeSpan.FromMilliseconds(100); @@ -92,7 +94,7 @@ public async Task RunAsync(IReplIoContext io, CancellationToken ct) } finally { - UnsubscribeFromRoutingChanges(); + DetachSession(server); await server.DisposeAsync().ConfigureAwait(false); } } @@ -418,15 +420,21 @@ private void BindRequestServer(McpServer? server) } _requestServers.BindRequest(server); - if (_server is null) + bool needsSessionAttach; + lock (_attachLock) + { + needsSessionAttach = _sessions.Count == 0; + } + + if (needsSessionAttach) { AttachServer(server); } } // Session-level attach: routing-change notifications and the roots list-changed - // handler belong to the session server, registered once — never to the per-request - // destination wrappers. + // handler belong to the session servers, registered once per session — never to the + // per-request destination wrappers. private void AttachServer(McpServer? server) { if (server is null) @@ -436,12 +444,12 @@ private void AttachServer(McpServer? server) lock (_attachLock) { - if (ReferenceEquals(_server, server)) + if (_sessions.Contains(server)) { return; } - _server = server; + _sessions.Add(server); _requestServers.AttachSession(server); EnsureRoutingSubscription(); EnsureRootsNotificationHandler(server); @@ -472,13 +480,24 @@ private void EnsureRoutingSubscription() coreApp.RoutingInvalidated += handler; } - private void EnsureRootsNotificationHandler(McpServer server) + // Removes a closing session and repairs the shared state: the accessor's session + // fallback moves to a surviving session, and the routing subscription is dropped only + // when the LAST session ends — a first-session close must not silence the others. + private void DetachSession(McpServer server) { - if (Interlocked.Exchange(ref _rootsNotificationRegistered, 1) != 0) + lock (_attachLock) { - return; + _sessions.Remove(server); + _requestServers.AttachSession(_sessions.Count > 0 ? _sessions[^1] : null); + if (_sessions.Count == 0) + { + UnsubscribeFromRoutingChanges(); + } } + } + private void EnsureRootsNotificationHandler(McpServer server) + { var weakSelf = new WeakReference(this); // 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). @@ -525,23 +544,27 @@ private async Task SendDiscoveryNotificationsSafeAsync() private async Task SendNotificationSafeAsync(string method) { - try - { - var server = _server; - if (server is null) - { - return; - } - - await server.SendNotificationAsync(method, CancellationToken.None).ConfigureAwait(false); - } - catch (OperationCanceledException) + McpServer[] sessions; + lock (_attachLock) { - // Notifications are best-effort. Cancellation is not actionable here. + sessions = [.. _sessions]; } - catch (Exception) + + foreach (var server in sessions) { - // Notifications are best-effort. The next list/read request will rebuild on demand. + try + { + await server.SendNotificationAsync(method, CancellationToken.None).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Notifications are best-effort. Cancellation is not actionable here. + } + catch (Exception) + { + // Notifications are best-effort per session. The next list/read request + // will rebuild on demand. + } } } diff --git a/src/Repl.McpTests/Given_McpConcurrentSessions.cs b/src/Repl.McpTests/Given_McpConcurrentSessions.cs index 1bffbc75..1b45ddc4 100644 --- a/src/Repl.McpTests/Given_McpConcurrentSessions.cs +++ b/src/Repl.McpTests/Given_McpConcurrentSessions.cs @@ -69,6 +69,115 @@ await clientB.CallToolAsync( _ = serverTaskB; } + [TestMethod] + [Description("Guards root isolation across sessions sharing one handler: the hard-roots cache must be keyed by session, otherwise the second root-capable client silently receives the FIRST client's workspace roots — a cross-session data exposure — instead of its own roots/list round-trip.")] + public async Task When_TwoRootCapableClientsShareHandler_Then_EachSeesOwnRoots() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("roots", async (IMcpClientRoots roots, CancellationToken ct) => + string.Join(',', (await roots.GetAsync(ct).ConfigureAwait(false)).Select(root => root.Uri.ToString()))); + + var options = new ReplMcpServerOptions + { + TransportFactory = static (serverName, io) => new StreamServerTransport( + ((McpTestFixture.PipeIoContext)io).InputStream, + ((McpTestFixture.PipeIoContext)io).OutputStream, + serverName), + }; + var handler = new McpServerHandler(app.Core, options, McpTestFixture.EmptyServices); + using var cts = new CancellationTokenSource(); + + var (clientA, _) = await StartSessionAsync(handler, BuildRootsClientOptions("file:///ga"), cts.Token).ConfigureAwait(false); + var (clientB, _) = await StartSessionAsync(handler, BuildRootsClientOptions("file:///bu"), cts.Token).ConfigureAwait(false); + + var resultA = await clientA.CallToolAsync( + toolName: "roots", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token).ConfigureAwait(false); + var resultB = await clientB.CallToolAsync( + toolName: "roots", + arguments: new Dictionary(StringComparer.Ordinal), + cancellationToken: cts.Token).ConfigureAwait(false); + + resultA.Content.OfType().First().Text.Should().Contain("file:///ga"); + var textB = resultB.Content.OfType().First().Text; + textB.Should().Contain("file:///bu"); + textB.Should().NotContain("file:///ga"); + + await clientA.DisposeAsync().ConfigureAwait(false); + await clientB.DisposeAsync().ConfigureAwait(false); + await cts.CancelAsync().ConfigureAwait(false); + } + + [TestMethod] + [Description("Guards routing-notification lifetime across sessions: when the first-attached session closes, the surviving session must still receive tools/list_changed after a routing invalidation — session attachment must be reference-counted, not first-wins with a handler-wide unsubscribe on first close.")] + public async Task When_FirstSessionCloses_Then_SurvivingSessionStillReceivesRoutingNotifications() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("alpha", () => "a"); + + var options = new ReplMcpServerOptions + { + TransportFactory = static (serverName, io) => new StreamServerTransport( + ((McpTestFixture.PipeIoContext)io).InputStream, + ((McpTestFixture.PipeIoContext)io).OutputStream, + serverName), + }; + var handler = new McpServerHandler(app.Core, options, McpTestFixture.EmptyServices); + using var ctsA = new CancellationTokenSource(); + using var ctsB = new CancellationTokenSource(); + + var (clientA, serverTaskA) = await StartSessionAsync(handler, clientOptions: null, ctsA.Token).ConfigureAwait(false); + var (clientB, _) = await StartSessionAsync(handler, clientOptions: null, ctsB.Token).ConfigureAwait(false); + + var listChanged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var registration = clientB.RegisterNotificationHandler( + NotificationMethods.ToolListChangedNotification, + (_, _) => + { + listChanged.TrySetResult(); + return ValueTask.CompletedTask; + }); + await using var _ = registration.ConfigureAwait(false); + + // Both sessions are live; close the FIRST one, then invalidate routing. + await clientA.DisposeAsync().ConfigureAwait(false); + await ctsA.CancelAsync().ConfigureAwait(false); + try + { + await serverTaskA.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected: session A's RunAsync ends on cancellation. + } + + app.Map("late", () => "l"); + app.Core.InvalidateRouting(); + + await listChanged.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + await clientB.DisposeAsync().ConfigureAwait(false); + await ctsB.CancelAsync().ConfigureAwait(false); + } + + private static McpClientOptions BuildRootsClientOptions(string rootUri) => new() + { + Capabilities = new ClientCapabilities + { + Roots = new RootsCapability { ListChanged = true }, + }, + Handlers = new McpClientHandlers + { + RootsHandler = (_, _) => ValueTask.FromResult(new ListRootsResult + { + Roots = [new Root { Uri = rootUri, Name = rootUri }], + }), + }, + }; + private static McpClientOptions BuildSamplingClientOptions() => new() { Capabilities = new ClientCapabilities { Sampling = new SamplingCapability() }, From 1f8950ff7afeae0a4da933fc12add4a8478de461 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 17 Jul 2026 11:47:53 -0400 Subject: [PATCH 6/7] refactor(mcp): McpSessionContext owns all per-session state (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One handler serves several sessions; everything that varied per client was still handler-global after the earlier point fixes. McpSessionContext now owns it all, per the architecture review: - hard AND soft roots: McpClientRootsService is one instance per session (plain fields again — the ConditionalWeakTable keying is gone); a session's 'workspace init' no longer sets another session's workspace. - generated snapshot + version + gate: the tool graph can be gated on session capabilities, so each session caches its own build against the handler-global routing version (RED observed: the roots-less session saw the roots-gated tool of the other session). - compatibility-shim intro: per-session flag, reset for every active session on routing invalidation (RED observed: only the first session received the discover_tools/call_tool intro). - per-session service overlay handed to McpServer.Create; request handlers recover their session through request.Server.Services instead of using a destination-bound per-request server as a surrogate session key. - externally hosted servers (BuildDynamicServerOptions) share one explicit lazy fallback context instead of racing a last-attached field. Request-bound OUTBOUND capabilities (sampling/elicitation/feedback) keep flowing through the per-request AsyncLocal accessor — finer than the session, unchanged. Related to #70 (per-session DI scopes generalize the lifetime contract; this context will construct from the session-scoped provider once both merge). --- src/Repl.Mcp/McpClientRootsService.cs | 57 ++--- src/Repl.Mcp/McpServerHandler.cs | 236 +++++++++++------- src/Repl.Mcp/McpSessionContext.cs | 49 ++++ .../Given_McpConcurrentSessions.cs | 72 ++++++ 4 files changed, 286 insertions(+), 128 deletions(-) create mode 100644 src/Repl.Mcp/McpSessionContext.cs diff --git a/src/Repl.Mcp/McpClientRootsService.cs b/src/Repl.Mcp/McpClientRootsService.cs index 41f0e7d4..c59b9973 100644 --- a/src/Repl.Mcp/McpClientRootsService.cs +++ b/src/Repl.Mcp/McpClientRootsService.cs @@ -9,20 +9,21 @@ 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(); - // Hard roots are SESSION state: one handler can serve several root-capable sessions, - // and serving session A's cached roots to session B would expose A's workspace URIs - // and build B's root-dependent snapshot from the wrong workspace. Entries are keyed by - // the destination server (weak keys — they die with the session); a single global - // version stamp invalidates every entry on any roots-list change (coarse, but the - // event is rare and correctness beats granularity here). - private readonly System.Runtime.CompilerServices.ConditionalWeakTable _sessionRoots = []; + private McpClientRoot[] _hardRoots = []; private McpClientRoot[] _softRoots = []; - private long _rootsVersion; + private bool _hardRootsLoaded; + private long _hardRootsVersion; public McpClientRootsService(ICoreReplApp app, McpRequestServerAccessor servers) { @@ -30,13 +31,6 @@ public McpClientRootsService(ICoreReplApp app, McpRequestServerAccessor servers) _servers = servers; } - private sealed class SessionRoots - { - public McpClientRoot[] Roots = []; - public bool Loaded; - public long LoadedVersion; - } - public bool IsSupported => _servers.Effective?.ClientCapabilities?.Roots is not null; public bool HasSoftRoots @@ -54,16 +48,9 @@ public IReadOnlyList Current { get { - var server = _servers.Effective; lock (_syncRoot) { - if (server?.ClientCapabilities?.Roots is null) - { - return _softRoots; - } - - var entry = _sessionRoots.GetOrCreateValue(server); - return entry.Loaded && entry.LoadedVersion == _rootsVersion ? entry.Roots : []; + return IsSupported ? _hardRoots : _softRoots; } } } @@ -78,18 +65,18 @@ public async ValueTask> GetAsync(CancellationToken return Current; } - var entry = _sessionRoots.GetOrCreateValue(server); long versionAtStart; lock (_syncRoot) { - versionAtStart = _rootsVersion; - if (entry.Loaded && entry.LoadedVersion == versionAtStart) + if (_hardRootsLoaded) { - return entry.Roots; + return _hardRoots; } + + versionAtStart = _hardRootsVersion; } - return await GetAndMaybeCacheRootsAsync(server, entry, versionAtStart, cancellationToken).ConfigureAwait(false); + return await GetAndMaybeCacheRootsAsync(server, versionAtStart, cancellationToken).ConfigureAwait(false); } public void SetSoftRoots(IEnumerable roots) @@ -135,7 +122,9 @@ public void HandleRootsListChanged() { lock (_syncRoot) { - _rootsVersion++; + _hardRoots = []; + _hardRootsLoaded = false; + _hardRootsVersion++; } _app.InvalidateRouting(); @@ -143,7 +132,6 @@ public void HandleRootsListChanged() private async ValueTask> GetAndMaybeCacheRootsAsync( McpServer server, - SessionRoots entry, long versionAtStart, CancellationToken cancellationToken) { @@ -153,12 +141,11 @@ private async ValueTask> GetAndMaybeCacheRootsAsync lock (_syncRoot) { - if (_rootsVersion == versionAtStart) + if (_hardRootsVersion == versionAtStart) { - entry.Roots = mappedRoots; - entry.Loaded = true; - entry.LoadedVersion = versionAtStart; - return entry.Roots; + _hardRoots = mappedRoots; + _hardRootsLoaded = true; + return _hardRoots; } return mappedRoots; diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index ab508f49..c80a0133 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -26,25 +26,26 @@ internal sealed class McpServerHandler private readonly TimeProvider _timeProvider; private readonly char _separator; private readonly McpRequestServerAccessor _requestServers = new(); - private readonly McpClientRootsService _roots; private readonly McpSamplingService _sampling; private readonly McpElicitationService _elicitation; private readonly McpFeedbackService _feedback; - private readonly IServiceProvider _sessionServices; - private readonly SemaphoreSlim _snapshotGate = new(initialCount: 1, maxCount: 1); private readonly Lock _refreshLock = new(); private readonly Lock _attachLock = new(); - private McpGeneratedSnapshot? _snapshot; + // Global routing version: bumped by InvalidateRouting for every session; each session's + // context caches the snapshot it built at a given version. private long _snapshotVersion = 1; - private long _builtSnapshotVersion; - // One handler can serve several concurrent sessions; session-scoped concerns (routing - // notifications, roots list-changed handler) track EVERY active session, not a single - // last- or first-attached server. Guarded by _attachLock. - private readonly List _sessions = []; + // One handler can serve several concurrent sessions; everything session-owned lives in + // McpSessionContext, and this list (guarded by _attachLock) tracks every ACTIVE session + // for server-initiated notifications and subscription lifetime. + private readonly List _sessions = []; + // Lazy single context for externally hosted servers (options built via + // BuildDynamicServerOptions and run by the host without RunAsync): those servers carry + // the HOST's provider, so requests cannot recover a per-session context from it — they + // share one explicit fallback context instead of racing a last-attached field. + private McpSessionContext? _externalContext; private EventHandler? _routingChangedHandler; private ITimer? _debounceTimer; - private int _compatibilityIntroServed; private static readonly TimeSpan DebounceDelay = TimeSpan.FromMilliseconds(100); public McpServerHandler( @@ -57,19 +58,61 @@ public McpServerHandler( _services = services; _timeProvider = services.GetService(typeof(TimeProvider)) as TimeProvider ?? TimeProvider.System; _separator = McpToolNameFlattener.ResolveSeparator(options.ToolNamingSeparator); - _roots = new McpClientRootsService(app, _requestServers); + // 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); - _sessionServices = new McpServiceProviderOverlay( - services, - new Dictionary + } + + private McpSessionContext CreateSessionContext() + { + var roots = new McpClientRootsService(_app, _requestServers); + var overlayServices = new Dictionary + { + [typeof(IMcpClientRoots)] = roots, + [typeof(IMcpSampling)] = _sampling, + [typeof(IMcpElicitation)] = _elicitation, + [typeof(IMcpFeedback)] = _feedback, + }; + var context = new McpSessionContext(roots, new McpServiceProviderOverlay(_services, overlayServices)); + // The context rides in its own overlay so request handlers can recover their + // originating session through the server's provider (the dictionary is captured by + // reference, making this two-phase registration safe). + overlayServices[typeof(McpSessionContext)] = context; + return context; + } + + // Requests recover their session through the provider handed to McpServer.Create — + // even a destination-bound per-request server exposes its session's services. Servers + // created by an external host (BuildDynamicServerOptions) carry the host's provider + // instead and share the explicit fallback context. + private McpSessionContext ResolveContext(McpServer? requestServer) + { + if (requestServer?.Services?.GetService(typeof(McpSessionContext)) is McpSessionContext context) + { + return context; + } + + lock (_attachLock) + { + if (_externalContext is null) { - [typeof(IMcpClientRoots)] = _roots, - [typeof(IMcpSampling)] = _sampling, - [typeof(IMcpElicitation)] = _elicitation, - [typeof(IMcpFeedback)] = _feedback, - }); + _externalContext = CreateSessionContext(); + _sessions.Add(_externalContext); + EnsureRoutingSubscription(); + } + + if (_externalContext.SessionServer is null && requestServer is not null) + { + _externalContext.SessionServer = requestServer; + _requestServers.AttachSession(requestServer); + EnsureRootsNotificationHandler(requestServer, _externalContext.Roots); + } + + return _externalContext; + } } [UnconditionalSuppressMessage( @@ -85,8 +128,10 @@ public async Task RunAsync(IReplIoContext io, CancellationToken ct) : new StdioServerTransport(serverName); try { - var server = McpServer.Create(transport, serverOptions, serviceProvider: _sessionServices); - AttachServer(server); + var context = CreateSessionContext(); + var server = McpServer.Create(transport, serverOptions, serviceProvider: context.Services); + context.SessionServer = server; + AttachSession(context, server); try { @@ -94,7 +139,7 @@ public async Task RunAsync(IReplIoContext io, CancellationToken ct) } finally { - DetachSession(server); + DetachSession(context); await server.DisposeAsync().ConfigureAwait(false); } } @@ -130,7 +175,7 @@ internal McpServerOptions BuildStaticServerOptions() { var serverName = _options.ServerName ?? ResolveAppName() ?? "repl-mcp-server"; var serverVersion = _options.ServerVersion ?? "1.0.0"; - var snapshot = BuildSnapshotCore(); + var snapshot = BuildSnapshotCore(CreateSessionContext()); return new McpServerOptions { @@ -142,10 +187,10 @@ internal McpServerOptions BuildStaticServerOptions() }; } - internal McpGeneratedSnapshot BuildSnapshotForTests() => BuildSnapshotCore(); + internal McpGeneratedSnapshot BuildSnapshotForTests() => BuildSnapshotCore(CreateSessionContext()); internal async Task BuildSnapshotForTestsAsync(CancellationToken cancellationToken = default) => - await GetSnapshotAsync(server: null, cancellationToken).ConfigureAwait(false); + await GetSnapshotAsync(CreateSessionContext(), cancellationToken).ConfigureAwait(false); private string? ResolveAppName() { @@ -153,7 +198,8 @@ internal async Task BuildSnapshotForTestsAsync(Cancellatio ReplSessionIO.IsProgrammatic = true; try { - var model = CreateDocumentationModel(); + // App name is session-independent; a throwaway capability-less context suffices. + var model = CreateDocumentationModel(CreateSessionContext().Services); return model.App.Name; } finally @@ -167,10 +213,11 @@ private async ValueTask ListToolsAsync( CancellationToken cancellationToken) { BindRequestServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + var context = ResolveContext(request.Server); + var snapshot = await GetSnapshotAsync(context, cancellationToken).ConfigureAwait(false); if (_options.DynamicToolCompatibility == DynamicToolCompatibilityMode.DiscoverAndCallShim - && Interlocked.CompareExchange(ref _compatibilityIntroServed, 1, 0) == 0) + && Interlocked.CompareExchange(ref context.CompatibilityIntroServed, 1, 0) == 0) { _ = SendNotificationSafeAsync(NotificationMethods.ToolListChangedNotification); return new ListToolsResult @@ -194,7 +241,8 @@ private async ValueTask CallToolAsync( CancellationToken cancellationToken) { BindRequestServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + 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; @@ -226,7 +274,8 @@ private async ValueTask ListResourcesAsync( CancellationToken cancellationToken) { BindRequestServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + var context = ResolveContext(request.Server); + var snapshot = await GetSnapshotAsync(context, cancellationToken).ConfigureAwait(false); return new ListResourcesResult { Resources = @@ -243,7 +292,8 @@ private async ValueTask ListResourceTemplatesAsync( CancellationToken cancellationToken) { BindRequestServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + var context = ResolveContext(request.Server); + var snapshot = await GetSnapshotAsync(context, cancellationToken).ConfigureAwait(false); return new ListResourceTemplatesResult { ResourceTemplates = @@ -260,7 +310,8 @@ private async ValueTask ReadResourceAsync( CancellationToken cancellationToken) { BindRequestServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + 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) @@ -276,7 +327,8 @@ private async ValueTask ListPromptsAsync( CancellationToken cancellationToken) { BindRequestServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + var context = ResolveContext(request.Server); + var snapshot = await GetSnapshotAsync(context, cancellationToken).ConfigureAwait(false); return new ListPromptsResult { Prompts = [.. snapshot.Prompts.Select(static prompt => prompt.ProtocolPrompt)], @@ -288,7 +340,8 @@ private async ValueTask GetPromptAsync( CancellationToken cancellationToken) { BindRequestServer(request.Server); - var snapshot = await GetSnapshotAsync(request.Server, cancellationToken).ConfigureAwait(false); + 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)); @@ -300,38 +353,39 @@ private async ValueTask GetPromptAsync( return await prompt.GetAsync(request, cancellationToken).ConfigureAwait(false); } + // The snapshot is SESSION state: the tool graph can be gated on session capabilities + // (roots, module presence predicates), so each context caches its own build against + // the handler-global routing version. private async ValueTask GetSnapshotAsync( - McpServer? server, + McpSessionContext context, CancellationToken cancellationToken) { - BindRequestServer(server); - var snapshotVersion = Volatile.Read(ref _snapshotVersion); - if (Volatile.Read(ref _builtSnapshotVersion) == snapshotVersion - && _snapshot is { } cached) + if (context.BuiltSnapshotVersion == snapshotVersion + && context.Snapshot is { } cached) { return cached; } - await _snapshotGate.WaitAsync(cancellationToken).ConfigureAwait(false); + await context.SnapshotGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { snapshotVersion = Volatile.Read(ref _snapshotVersion); - if (Volatile.Read(ref _builtSnapshotVersion) == snapshotVersion - && _snapshot is { } refreshed) + if (context.BuiltSnapshotVersion == snapshotVersion + && context.Snapshot is { } refreshed) { return refreshed; } - var previousSnapshot = _snapshot; + var previousSnapshot = context.Snapshot; try { - await _roots.GetAsync(cancellationToken).ConfigureAwait(false); - var built = BuildSnapshotCore(); - _snapshot = built; + await context.Roots.GetAsync(cancellationToken).ConfigureAwait(false); + var built = BuildSnapshotCore(context); + context.Snapshot = built; if (Volatile.Read(ref _snapshotVersion) == snapshotVersion) { - Volatile.Write(ref _builtSnapshotVersion, snapshotVersion); + context.BuiltSnapshotVersion = snapshotVersion; } return built; } @@ -341,36 +395,39 @@ private async ValueTask GetSnapshotAsync( } catch (Exception) when (previousSnapshot is not null) { - _snapshot = previousSnapshot; + context.Snapshot = previousSnapshot; if (Volatile.Read(ref _snapshotVersion) == snapshotVersion) { - Volatile.Write(ref _builtSnapshotVersion, snapshotVersion); + context.BuiltSnapshotVersion = snapshotVersion; } return previousSnapshot; } } finally { - _snapshotGate.Release(); + context.SnapshotGate.Release(); } } - private McpGeneratedSnapshot BuildSnapshotCore() + private McpGeneratedSnapshot BuildSnapshotCore(McpSessionContext context) { - var model = CreateDocumentationModel(); - var adapter = new McpToolAdapter(_app, _options, _sessionServices); + var model = CreateDocumentationModel(context.Services); + var adapter = new McpToolAdapter(_app, _options, context.Services); var commandsByPath = model.Commands.ToDictionary( command => command.Path, command => command, StringComparer.OrdinalIgnoreCase); var tools = GenerateAllTools(model, adapter, _separator, commandsByPath); ValidateCompatibilityToolNames(tools); - var resources = GenerateResources(model, adapter, _separator, commandsByPath); + var resources = GenerateResources(model, adapter, _separator, commandsByPath, context.Services); var prompts = CollectPrompts(model, adapter, _separator); return new McpGeneratedSnapshot(adapter, tools, resources, prompts); } - private ReplDocumentationModel CreateDocumentationModel() + // The documentation model resolves module-presence predicates against the SESSION's + // services (e.g. IMcpClientRoots), so the model — and everything generated from it — + // reflects the capabilities of the session it is built for. + private ReplDocumentationModel CreateDocumentationModel(IServiceProvider sessionServices) { var coreApp = _app as CoreReplApp ?? throw new InvalidOperationException("MCP server handler requires CoreReplApp."); @@ -379,7 +436,7 @@ private ReplDocumentationModel CreateDocumentationModel() ReplSessionIO.IsProgrammatic = true; try { - return coreApp.CreateDocumentationModel(_sessionServices); + return coreApp.CreateDocumentationModel(sessionServices); } finally { @@ -409,9 +466,8 @@ private void ValidateCompatibilityToolNames(IReadOnlyList tools) // Request-level binding: capability services resolve the flowing request's // destination-bound server through the AsyncLocal accessor, so concurrent requests // (SDK 2.0 creates one destination-bound McpServer per request) cannot cross-wire - // each other's client capabilities. Externally hosted servers (options built via - // BuildDynamicServerOptions and run by the host, without RunAsync's session attach) - // adopt the first observed server for session-level concerns. + // each other's client capabilities. Session-level concerns are handled by + // AttachSession (RunAsync) or the external fallback context (ResolveContext). private void BindRequestServer(McpServer? server) { if (server is null) @@ -420,39 +476,19 @@ private void BindRequestServer(McpServer? server) } _requestServers.BindRequest(server); - bool needsSessionAttach; - lock (_attachLock) - { - needsSessionAttach = _sessions.Count == 0; - } - - if (needsSessionAttach) - { - AttachServer(server); - } } // Session-level attach: routing-change notifications and the roots list-changed // handler belong to the session servers, registered once per session — never to the // per-request destination wrappers. - private void AttachServer(McpServer? server) + private void AttachSession(McpSessionContext context, McpServer server) { - if (server is null) - { - return; - } - lock (_attachLock) { - if (_sessions.Contains(server)) - { - return; - } - - _sessions.Add(server); + _sessions.Add(context); _requestServers.AttachSession(server); EnsureRoutingSubscription(); - EnsureRootsNotificationHandler(server); + EnsureRootsNotificationHandler(server, context.Roots); } } @@ -483,12 +519,12 @@ private void EnsureRoutingSubscription() // 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(McpServer server) + private void DetachSession(McpSessionContext context) { lock (_attachLock) { - _sessions.Remove(server); - _requestServers.AttachSession(_sessions.Count > 0 ? _sessions[^1] : null); + _sessions.Remove(context); + _requestServers.AttachSession(_sessions.Count > 0 ? _sessions[^1].SessionServer : null); if (_sessions.Count == 0) { UnsubscribeFromRoutingChanges(); @@ -496,9 +532,9 @@ private void DetachSession(McpServer server) } } - private void EnsureRootsNotificationHandler(McpServer server) + private static void EnsureRootsNotificationHandler(McpServer server, McpClientRootsService roots) { - var weakSelf = new WeakReference(this); + 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 @@ -508,7 +544,7 @@ private void EnsureRootsNotificationHandler(McpServer server) { if (weakSelf.TryGetTarget(out var target)) { - target._roots.HandleRootsListChanged(); + target.HandleRootsListChanged(); } return ValueTask.CompletedTask; @@ -521,7 +557,14 @@ private void OnRoutingInvalidated() Interlocked.Increment(ref _snapshotVersion); if (_options.DynamicToolCompatibility == DynamicToolCompatibilityMode.DiscoverAndCallShim) { - Interlocked.Exchange(ref _compatibilityIntroServed, 0); + // Every active session re-serves its compatibility intro after a routing change. + lock (_attachLock) + { + foreach (var session in _sessions) + { + Interlocked.Exchange(ref session.CompatibilityIntroServed, 0); + } + } } lock (_refreshLock) @@ -547,7 +590,11 @@ private async Task SendNotificationSafeAsync(string method) McpServer[] sessions; lock (_attachLock) { - sessions = [.. _sessions]; + sessions = [ + .. _sessions + .Select(static session => session.SessionServer) + .OfType(), + ]; } foreach (var server in sessions) @@ -623,7 +670,9 @@ private bool HasMcpAppResources() return true; } - var model = CreateDocumentationModel(); + // Capability advertisement is computed before any session exists: build against a + // throwaway capability-less context (same result as a session with no roots). + var model = CreateDocumentationModel(CreateSessionContext().Services); return model.Commands.Any(static command => command.Metadata?.ContainsKey(McpAppMetadata.ResourceMetadataKey) == true || command.Metadata?.ContainsKey(McpAppMetadata.CommandMetadataKey) == true); @@ -872,7 +921,8 @@ private List GenerateResources( ReplDocumentationModel model, McpToolAdapter adapter, char separator, - Dictionary commandsByPath) + Dictionary commandsByPath, + IServiceProvider sessionServices) { var resources = new List(); var resourceMimeType = adapter.ForcedOutputMimeType; @@ -925,7 +975,7 @@ private List GenerateResources( foreach (var uiResource in _options.UiResources) { - resources.Add(new McpAppResource(uiResource, _sessionServices)); + resources.Add(new McpAppResource(uiResource, sessionServices)); } return resources; diff --git a/src/Repl.Mcp/McpSessionContext.cs b/src/Repl.Mcp/McpSessionContext.cs new file mode 100644 index 00000000..e175d7cf --- /dev/null +++ b/src/Repl.Mcp/McpSessionContext.cs @@ -0,0 +1,49 @@ +using ModelContextProtocol.Server; +using Repl.Documentation; + +namespace Repl.Mcp; + +/// +/// State owned by ONE MCP transport session. +/// +/// +/// One can serve several concurrent sessions, so anything +/// that varies per client lives here instead of on the handler: hard/soft roots, the +/// generated snapshot cache (the tool graph can be gated on session capabilities), the +/// compatibility-shim intro state, and the session's service overlay. The context is +/// registered in the provider passed to McpServer.Create, so request handlers +/// recover their originating session through request.Server.Services — never +/// through a destination-bound per-request server used as a surrogate session key. +/// Request-bound OUTBOUND capabilities (sampling, elicitation, progress) keep flowing +/// through the per-request binding, which is +/// finer-grained than the session. +/// +internal sealed class McpSessionContext +{ + public McpSessionContext(McpClientRootsService roots, IServiceProvider services) + { + Roots = roots; + Services = services; + } + + /// Session-owned hard/soft roots. + public McpClientRootsService Roots { get; } + + /// Per-session service overlay handed to McpServer.Create. + public IServiceProvider Services { get; } + + /// Session server used for server-initiated notifications. + public McpServer? SessionServer { get; set; } + + /// Serializes snapshot builds for this session. + public SemaphoreSlim SnapshotGate { get; } = new(initialCount: 1, maxCount: 1); + + /// Cached generated snapshot for this session. + public McpServerHandler.McpGeneratedSnapshot? Snapshot { get; set; } + + /// Routing version the cached snapshot was built at. + public long BuiltSnapshotVersion { get; set; } + + /// Whether this session already received the compatibility-shim intro list. + public int CompatibilityIntroServed; +} diff --git a/src/Repl.McpTests/Given_McpConcurrentSessions.cs b/src/Repl.McpTests/Given_McpConcurrentSessions.cs index 1b45ddc4..3485029d 100644 --- a/src/Repl.McpTests/Given_McpConcurrentSessions.cs +++ b/src/Repl.McpTests/Given_McpConcurrentSessions.cs @@ -163,6 +163,78 @@ public async Task When_FirstSessionCloses_Then_SurvivingSessionStillReceivesRout await ctsB.CancelAsync().ConfigureAwait(false); } + [TestMethod] + [Description("Guards snapshot isolation across sessions sharing one handler: a tool graph gated on session capabilities (module presence on IMcpClientRoots.IsSupported) must be computed per session — a shared snapshot cache would serve the roots-capable session's tools to a session without roots.")] + public async Task When_ToolGraphIsSessionGated_Then_EachSessionSeesItsOwnTools() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("always", () => "ok"); + app.MapModule(new RootsGatedModule(), (IMcpClientRoots roots) => roots.IsSupported); + + var options = new ReplMcpServerOptions + { + TransportFactory = static (serverName, io) => new StreamServerTransport( + ((McpTestFixture.PipeIoContext)io).InputStream, + ((McpTestFixture.PipeIoContext)io).OutputStream, + serverName), + }; + var handler = new McpServerHandler(app.Core, options, McpTestFixture.EmptyServices); + using var cts = new CancellationTokenSource(); + + var (clientWithRoots, _) = await StartSessionAsync(handler, BuildRootsClientOptions("file:///ga"), cts.Token).ConfigureAwait(false); + var (clientWithoutRoots, _) = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + + var toolsWithRoots = await clientWithRoots.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + var toolsWithoutRoots = await clientWithoutRoots.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + + toolsWithRoots.Should().Contain(tool => string.Equals(tool.Name, "gated", StringComparison.Ordinal)); + toolsWithoutRoots.Should().Contain(tool => string.Equals(tool.Name, "always", StringComparison.Ordinal)); + toolsWithoutRoots.Should().NotContain(tool => string.Equals(tool.Name, "gated", StringComparison.Ordinal)); + + await clientWithRoots.DisposeAsync().ConfigureAwait(false); + await clientWithoutRoots.DisposeAsync().ConfigureAwait(false); + await cts.CancelAsync().ConfigureAwait(false); + } + + [TestMethod] + [Description("Guards the compatibility-shim intro across sessions: DiscoverAndCallShim serves the discover_tools/call_tool intro on each session's FIRST tools/list — a handler-global flag would give the intro only to whichever session listed first, leaving later sessions without the documented bootstrap.")] + public async Task When_ShimEnabledAndTwoSessionsList_Then_EachSessionGetsTheIntro() + { + var app = ReplApp.Create(); + app.UseMcpServer(); + app.Map("alpha", () => "a"); + + var options = new ReplMcpServerOptions + { + DynamicToolCompatibility = DynamicToolCompatibilityMode.DiscoverAndCallShim, + TransportFactory = static (serverName, io) => new StreamServerTransport( + ((McpTestFixture.PipeIoContext)io).InputStream, + ((McpTestFixture.PipeIoContext)io).OutputStream, + serverName), + }; + var handler = new McpServerHandler(app.Core, options, McpTestFixture.EmptyServices); + using var cts = new CancellationTokenSource(); + + var (clientA, _) = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + var (clientB, _) = await StartSessionAsync(handler, clientOptions: null, cts.Token).ConfigureAwait(false); + + var firstListA = await clientA.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + var firstListB = await clientB.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); + + firstListA.Select(static tool => tool.Name).Should().BeEquivalentTo(["discover_tools", "call_tool"]); + firstListB.Select(static tool => tool.Name).Should().BeEquivalentTo(["discover_tools", "call_tool"]); + + await clientA.DisposeAsync().ConfigureAwait(false); + await clientB.DisposeAsync().ConfigureAwait(false); + await cts.CancelAsync().ConfigureAwait(false); + } + + private sealed class RootsGatedModule : IReplModule + { + public void Map(IReplMap app) => app.Map("gated", () => "roots-only"); + } + private static McpClientOptions BuildRootsClientOptions(string rootUri) => new() { Capabilities = new ClientCapabilities From b3627366532ea9d575ae5a9f9520e02cdd873b4a Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 19 Jul 2026 18:19:57 -0400 Subject: [PATCH 7/7] =?UTF-8?q?docs(mcp):=20correct=20SEP-2322=20availabil?= =?UTF-8?q?ity=20=E2=80=94=20MRTR=20ships=20experimentally=20in=20SDK=202.?= =?UTF-8?q?0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deprecation pragmas and the reference doc claimed the SEP-2322 multi-round-trip successor was 'not yet consumable in the SDK'; preview.3 actually ships it experimentally (MrtrContext/MrtrContinuation/MrtrExchange). Reworded to 'shipped experimentally, not adopted by Repl yet' — adoption is a follow-up under the compliance track. --- docs/mcp-reference.md | 2 +- src/Repl.Mcp/IMcpFeedback.cs | 5 +++-- src/Repl.Mcp/McpClientRootsService.cs | 5 +++-- src/Repl.Mcp/McpFeedbackService.cs | 5 +++-- src/Repl.Mcp/McpInteractionChannel.cs | 3 ++- src/Repl.Mcp/McpSamplingService.cs | 3 ++- 6 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md index 6600d9fb..a4f33024 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -513,7 +513,7 @@ Feature support varies across agents. Check [mcp-availability.com](https://mcp-a ### SDK and protocol versions - Repl.Mcp builds on the official C# SDK (`ModelContextProtocol`), currently on the **2.0 line** (`2.0.0-preview.3`). The SDK negotiates the protocol version with each client, including fallback to the legacy `initialize` handshake for older hosts. -- **Roots, Sampling, and Logging** are deprecated by MCP specification 2026-07-28 (SEP-2577). Repl.Mcp keeps supporting them **for existing hosts and applications only** — new applications should not adopt these features (the SDK may remove them) and should prefer Repl's portable abstractions such as `IReplInteractionChannel`. The designated successor for server-initiated flows (SEP-2322, multi-round-trip requests) is not yet consumable in the SDK. +- **Roots, Sampling, and Logging** are deprecated by MCP specification 2026-07-28 (SEP-2577). Repl.Mcp keeps supporting them **for existing hosts and applications only** — new applications should not adopt these features (the SDK may remove them) and should prefer Repl's portable abstractions such as `IReplInteractionChannel`. The designated successor for server-initiated flows (SEP-2322, multi-round-trip requests) ships experimentally in the SDK 2.0 line; Repl has not adopted it yet. - **MCP Tasks**: the SDK reorganized Tasks into `ModelContextProtocol.Extensions.Tasks` and dropped the per-tool execution augmentation (`Tool.Execution`) from the protocol surface, so `.LongRunning()` commands no longer advertise task support at the protocol level. The annotation stays in Repl's own model (help/docs); protocol-level task support can return once Repl integrates the Tasks extension, store, and get/update/cancel lifecycle (tracked in issue #72). | Feature | Claude Desktop | Claude Code | Codex | VS Code Copilot | Cursor | Continue | diff --git a/src/Repl.Mcp/IMcpFeedback.cs b/src/Repl.Mcp/IMcpFeedback.cs index 9abbf943..2deea2e0 100644 --- a/src/Repl.Mcp/IMcpFeedback.cs +++ b/src/Repl.Mcp/IMcpFeedback.cs @@ -3,8 +3,9 @@ // Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK // diagnostic MCP9005); the designated successor for server-initiated flows (SEP-2322, -// multi-round-trip requests) is not yet consumable in the SDK and hosts still rely on -// these features, so Repl keeps supporting them until the SDK removes the surface (#51). +// multi-round-trip requests, shipped experimentally in SDK 2.0 as MrtrContext/MrtrExchange) +// is not adopted by Repl yet, and hosts still rely on these features, so Repl keeps +// supporting them until the SDK removes the surface (#51). #pragma warning disable MCP9005 namespace Repl.Mcp; diff --git a/src/Repl.Mcp/McpClientRootsService.cs b/src/Repl.Mcp/McpClientRootsService.cs index c59b9973..d0d404e3 100644 --- a/src/Repl.Mcp/McpClientRootsService.cs +++ b/src/Repl.Mcp/McpClientRootsService.cs @@ -3,8 +3,9 @@ // Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK // diagnostic MCP9005); the designated successor for server-initiated flows (SEP-2322, -// multi-round-trip requests) is not yet consumable in the SDK and hosts still rely on -// these features, so Repl keeps supporting them until the SDK removes the surface (#51). +// multi-round-trip requests, shipped experimentally in SDK 2.0 as MrtrContext/MrtrExchange) +// is not adopted by Repl yet, and hosts still rely on these features, so Repl keeps +// supporting them until the SDK removes the surface (#51). #pragma warning disable MCP9005 namespace Repl.Mcp; diff --git a/src/Repl.Mcp/McpFeedbackService.cs b/src/Repl.Mcp/McpFeedbackService.cs index 6ad0505d..9486d742 100644 --- a/src/Repl.Mcp/McpFeedbackService.cs +++ b/src/Repl.Mcp/McpFeedbackService.cs @@ -7,8 +7,9 @@ // Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK // diagnostic MCP9005); the designated successor for server-initiated flows (SEP-2322, -// multi-round-trip requests) is not yet consumable in the SDK and hosts still rely on -// these features, so Repl keeps supporting them until the SDK removes the surface (#51). +// multi-round-trip requests, shipped experimentally in SDK 2.0 as MrtrContext/MrtrExchange) +// is not adopted by Repl yet, and hosts still rely on these features, so Repl keeps +// supporting them until the SDK removes the surface (#51). #pragma warning disable MCP9005 namespace Repl.Mcp; diff --git a/src/Repl.Mcp/McpInteractionChannel.cs b/src/Repl.Mcp/McpInteractionChannel.cs index 6b0c85f4..5e8fc528 100644 --- a/src/Repl.Mcp/McpInteractionChannel.cs +++ b/src/Repl.Mcp/McpInteractionChannel.cs @@ -7,7 +7,8 @@ // Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK // diagnostic MCP9005); the designated successor for server-initiated flows (SEP-2322, -// multi-round-trip requests) is not yet consumable in the SDK and hosts still rely on +// multi-round-trip requests, shipped experimentally in SDK 2.0 as MrtrContext/MrtrExchange) +// is not adopted by Repl yet, and hosts still rely on these features, so Repl keeps // these features, so Repl keeps supporting them until the SDK removes the surface (#51). #pragma warning disable MCP9005 diff --git a/src/Repl.Mcp/McpSamplingService.cs b/src/Repl.Mcp/McpSamplingService.cs index c04d6b52..8ec4e238 100644 --- a/src/Repl.Mcp/McpSamplingService.cs +++ b/src/Repl.Mcp/McpSamplingService.cs @@ -3,7 +3,8 @@ // Roots, Sampling, and Logging are deprecated by MCP spec 2026-07-28 (SEP-2577, SDK // diagnostic MCP9005); the designated successor for server-initiated flows (SEP-2322, -// multi-round-trip requests) is not yet consumable in the SDK and hosts still rely on +// multi-round-trip requests, shipped experimentally in SDK 2.0 as MrtrContext/MrtrExchange) +// is not adopted by Repl yet, and hosts still rely on these features, so Repl keeps // these features, so Repl keeps supporting them until the SDK removes the surface (#51). #pragma warning disable MCP9005