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