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