feat(mcp): migrate to ModelContextProtocol 2.0 line (2.0.0-preview.3)#71
feat(mcp): migrate to ModelContextProtocol 2.0 line (2.0.0-preview.3)#71carldebilly wants to merge 6 commits into
Conversation
- Bump ModelContextProtocol 1.4.1 -> 2.0.0-preview.3. - Remove the Tool.Execution mapping for .LongRunning() commands: SDK 2.0 dropped the experimental MCP Tasks tool augmentation (Tasks SEP deferred out of the 2.0 protocol release). The annotation stays in Repl's model; protocol-level task support returns with the SDK Tasks runtime. - Keep supporting Roots, Sampling, and Logging: deprecated by spec 2026-07-28 (SEP-2577, MCP9005) with no replacement, still relied on by current hosts. Scoped, documented pragmas at the feature touchpoints. - Document the SDK/protocol version posture in docs/mcp-reference.md. Full suite green against the new SDK (1312 passed, 1 known skip), including all MCP capability, tool-call, roots, sampling, and logging regressions. Note: re-pin to the stable 2.0.0 release before cutting stable 0.12. Refs #51
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3cb6d8d09b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…e shape (review) - Correct the migration rationale: MCP Tasks was EXTRACTED to ModelContextProtocol.Extensions.Tasks (store, task results, client polling), not removed; the per-tool Tool.Execution augmentation is gone from the protocol surface. Comments and docs now say so, and Repl still deliberately does not advertise task support without the runtime. - Name the designated successor (SEP-2322 multi-round-trip requests) in the deprecation pragmas instead of claiming 'no replacement API'. - Narrow MCP9005 pragmas to their touchpoints in McpServerHandler and Given_McpIntegration (file-scoped kept only where usage is dense). - Lock the SDK-2.0 tools/list wire shape: a .LongRunning() tool serializes its annotations and emits no task/execution augmentation. - Align remaining .LongRunning() doc mentions (overview, coding-agents guide, package README) with the current no-advertisement posture.
|
Adversarial review pass applied:
|
autocarl
left a comment
There was a problem hiding this comment.
I validated the current head locally: strict Release build is clean (0 warnings / 0 errors), and the full suite passes with 1,314 total / 1,313 passed / 1 known Inspector skip. The SDK migration itself is small, but the 2.0 request model exposes one blocking concurrency regression plus three contract/documentation gaps.
The blocker is the shared mutable MCP service overlay: with the default 2026-07-28 protocol, every request has its own destination-bound request.Server, but AttachServer overwrites _roots, _sampling, _elicitation, and _feedback shared by concurrent invocations. I reproduced this deterministically: request A advertised Sampling and paused; request B without Sampling ran; when A resumed it observed not-supported.
The existing Codex thread is also still partially unresolved: CommandAnnotations.LongRunning, CommandAnnotationsBuilder.LongRunning(), and CommandBuilder.LongRunning() continue to promise task-based programmatic execution, although this PR deliberately emits no Tasks/Execution signal. Please describe it as Repl-local metadata until #72 is implemented.
Non-blocking release note: a stable package with ModelContextProtocol 2.0.0-preview.3 fails pack with NU5104. The PR caveat already acknowledges the required re-pin before stable 0.12; keeping main on 0.12.0-dev.* is fine, but this should remain an explicit release gate.
…ed server field SDK 2.0's 2026-07-28 protocol path hands each request a destination-bound McpServer, and one handler can serve several sessions. The four capability services (roots, sampling, elicitation, feedback) stored the last-attached server in a shared mutable field, so a concurrent request from another session could cross-wire capabilities mid-call (IsSupported flipping while a handler was awaiting). - McpRequestServerAccessor: AsyncLocal request binding flowing with the invocation, session-level server as fallback for code outside a request (routing notifications, roots list-changed handler). - Services resolve the effective server through the accessor with a single read per operation (no torn check-then-use). - McpServerHandler splits session-level attach (RunAsync, once) from request-level binding (every handler); externally hosted servers adopt the first observed server for session concerns. - Deterministic regression: two sessions on one handler, sampling-capable client pauses mid-call while a sampling-less client is served — the paused call must keep observing ITS client's capabilities (RED observed: 'True|False' on the pre-fix code, exactly the reported repro).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c235f9a90e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| var server = _servers.Effective; | ||
| if (server?.ClientCapabilities?.Roots is null) | ||
| { | ||
| return Current; |
There was a problem hiding this comment.
Key cached roots by the destination session
When one McpServerHandler serves two root-capable sessions, this now resolves the correct request-bound server but still returns the single shared _hardRoots cache as soon as _hardRootsLoaded is true. Thus, if client A loads its roots first, a command invoked by client B receives A's root URIs without issuing roots/list to B; this can both expose another client's workspace roots and generate B's root-dependent tool snapshot from A's workspace. Keep the hard-root cache/version state per McpServer (or per session) rather than per handler.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed independently on 3cc415ae. A deterministic two-session probe gave client A root-a and client B root-b; after A populated the shared handler, B returned "root-a:file:///root-a" instead of its own root. This is a real cross-session data leak. The hard-roots state must be keyed by destination/session; because GetSnapshotAsync also caches one _snapshot for the handler, the root-dependent generated snapshot needs the same session affinity rather than only fixing the McpClientRootsService array.
There was a problem hiding this comment.
Fixed in the follow-up commit. Hard roots are now session state: cache entries are keyed by the destination server in a ConditionalWeakTable (weak keys — entries die with their session), with a global version stamp handling roots-list-changed invalidation (coarse but correct; the event is rare). Client B now performs its own roots/list round-trip and can no longer observe client A's workspace roots, and the root-dependent snapshot builds from the right workspace. Regression: When_TwoRootCapableClientsShareHandler_Then_EachSeesOwnRoots (RED observed with exactly your scenario — B received A's file:///ga).
… Tasks wording (review) - Regression pinning the last initialize-era protocol revision (2025-11-25): asserts the negotiated version and a tool list + call — the default client negotiates 2026-07-28 and never exercised the fallback path. - Roots/Sampling/Logging documented as legacy-compatibility only: deprecation notices in mcp-agent-capabilities.md and mcp-advanced.md steer new applications toward IReplInteractionChannel / soft roots; mcp-reference.md no longer reads as an endorsement. - Tasks wording corrected everywhere: the SDK has shipped the Tasks extension (ModelContextProtocol.Extensions.Tasks); what is pending is Repl's integration (issue #72) — including the CommandAnnotations.LongRunning XML doc that still promised task-based execution.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3cc415ae43
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (_server is null) | ||
| { | ||
| AttachServer(server); | ||
| } |
There was a problem hiding this comment.
Keep routing subscriptions alive for every active session
When a single handler serves multiple sessions (the new concurrent-session test explicitly exercises this), only the first request is attached as _server. If that first connection closes while another remains active, its RunAsync cleanup unsubscribes the handler-wide routing listener and _server still points at the disposed connection; later InvalidateRouting() calls neither rebuild the surviving session’s snapshot nor notify it. Track attachment/subscription lifetime per session (or reference-count active sessions) instead of attaching only the first server.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed independently on 3cc415ae. I started two sessions on one handler, registered tools/list_changed on B, cancelled A and waited for A server cleanup, then invalidated routing. B timed out after 2 seconds without a notification. Closing either session currently removes the handler-wide routing subscription. Please track active session servers/subscriptions (including Roots handlers and notification destinations) so one session cleanup cannot disable another.
There was a problem hiding this comment.
Fixed in the follow-up commit. Session attachment is now reference-counted: the handler tracks every active session in a list, discovery notifications fan out to ALL active sessions (per-session best-effort), the accessor's out-of-request fallback moves to a surviving session on close, and the routing subscription is dropped only when the LAST session detaches. The roots list-changed handler is registered once per session server instead of once globally. Regression: When_FirstSessionCloses_Then_SurvivingSessionStillReceivesRoutingNotifications (RED observed: the surviving session timed out waiting for tools/list_changed after the first session closed).
autocarl
left a comment
There was a problem hiding this comment.
Re-reviewed current head 3cc415ae. The original request-binding blocker is fixed: the new AsyncLocal regression passes, the pinned 2025-11-25 legacy handshake/list/call regression passes, the strict MCP test build is clean (0 warnings / 0 errors), and the documentation/.LongRunning() contract corrections are present. CI is green, including the required Build, Test, Pack check.
Two cross-session lifecycle defects remain and are independently reproduced in the existing threads:
- Roots cache/snapshot affinity: after session A loads
root-a, session B configured withroot-breceives"root-a:file:///root-a". This leaks one client workspace into another and can contaminate root-dependent tool discovery. - Routing subscription lifetime: after session A closes, routing invalidation no longer produces
tools/list_changedfor still-active session B; the deterministic probe times out after 2 seconds.
Please key Roots and root-dependent snapshots by destination/session, and track active session notification/subscription lifetimes so one session cleanup cannot disable another. The migration is materially better, but these remain blocking before merge.
…le (review) - Hard roots are now SESSION state: entries keyed by destination server in a ConditionalWeakTable (weak keys die with the session), with a global version stamp for roots-list-changed invalidation. One session can no longer receive another session's cached workspace roots, and the root-dependent snapshot builds from the right workspace (RED observed: client B received client A's roots). - Session attachment is reference-counted: the handler tracks every active session, discovery notifications fan out to ALL of them, the accessor fallback moves to a surviving session on close, and the routing subscription is dropped only when the LAST session ends. A first-session close no longer silences the survivors (RED observed: surviving session timed out waiting for tools/list_changed). - Roots list-changed handler registered once per session (per-server registration replaces the single global flag).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b094d05506
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // version stamp invalidates every entry on any roots-list change (coarse, but the | ||
| // event is rare and correctness beats granularity here). | ||
| private readonly System.Runtime.CompilerServices.ConditionalWeakTable<McpServer, SessionRoots> _sessionRoots = []; | ||
| private McpClientRoot[] _softRoots = []; |
There was a problem hiding this comment.
Scope soft-root state per MCP session
When one handler serves two clients that lack native Roots support, this single _softRoots array is shared by both sessions: after client A calls SetSoftRoots, client B sees A's URI through Current and HasSoftRoots, and its soft-root-dependent modules can be enabled. This exposes one client's workspace context to another and contradicts the documented current-session semantics; store fallback roots per effective server/session just as hard roots are scoped.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed independently on b094d055. Two sessions without native Roots shared one handler; A called SetSoftRoots(file:///root-a), then B called a separate soft_show tool and received "file:///root-a". This directly violates the current-session contract and leaks workspace context. Please key soft roots by the effective destination/session just like hard roots, including HasSoftRoots, Current, SetSoftRoots, and ClearSoftRoots.
| var snapshotVersion = Volatile.Read(ref _snapshotVersion); | ||
| if (Volatile.Read(ref _builtSnapshotVersion) == snapshotVersion |
There was a problem hiding this comment.
Cache generated snapshots per MCP session
The earlier hard-root cache fix is insufficient because this handler-level cache is returned before _roots.GetAsync() and BuildSnapshotCore() run for a later session. If client A first lists tools with a module predicate based on IMcpClientRoots.Current (or IsSupported), client B receives A's generated tool/resource/prompt snapshot; B can therefore be shown unavailable tools or miss its own workspace-specific tools. Key snapshots/version state by the request's session/destination server, or rebuild session-aware snapshots per request.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed independently on b094d055. I registered roots_supported when IMcpClientRoots.IsSupported is true and roots_unsupported otherwise, then started A with Roots and B without Roots on one handler. A first tools/list returned only roots_unsupported, showing that B session state already influenced A snapshot generation; the handler-global _snapshot then preserves that cross-session result. Please ensure BuildSnapshotCore runs with the originating request/session context and key snapshot/version/gate state per destination session.
| // One handler can serve several concurrent sessions; session-scoped concerns (routing | ||
| // notifications, roots list-changed handler) track EVERY active session, not a single | ||
| // last- or first-attached server. Guarded by _attachLock. | ||
| private readonly List<McpServer> _sessions = []; |
There was a problem hiding this comment.
Track compatibility bootstrap state per MCP session
With DiscoverAndCallShim enabled, _compatibilityIntroServed remains handler-global even though this change allows the handler to serve every session in _sessions. Once client A consumes the shim, client B's first tools/list receives the real list instead of discover_tools/call_tool; after a later routing invalidation, whichever session lists first consumes the only re-armed shim. A weak client in any other session then cannot use the documented fallback to discover newly session-specific tools, so the bootstrap flag must be maintained per session.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed independently on b094d055. With DiscoverAndCallShim, A first tools/list returned discover_tools and call_tool, but B first tools/list returned only the real echo tool. The handler-global _compatibilityIntroServed lets one client consume another client bootstrap. Please track/re-arm this state per session.
autocarl
left a comment
There was a problem hiding this comment.
Re-reviewed current head b094d055. The two previous blockers are genuinely fixed: the new hard-roots isolation regression and surviving-session routing-notification regression both pass locally; strict MCP test build is clean (0 warnings / 0 errors); CI is green, including the required Build, Test, Pack check.
Three remaining handler-global states still violate the new multi-session model, and all three are independently reproduced in the existing threads:
- Soft roots leak: after session A sets
file:///root-a, session B reads the same URI through its own command. - Generated snapshot affinity: with A advertising Roots and B not advertising Roots, A first
tools/listreturns theroots_unsupportedmodule, showing B session state influenced A generation; the single_snapshotalso caches the cross-session result. - Compatibility bootstrap affinity: in
DiscoverAndCallShimmode, A receivesdiscover_tools/call_tool, while B first list receives onlyechobecause A consumed the handler-global bootstrap flag.
Please move all session-sensitive state — hard and soft roots, generated snapshots/versioning, and compatibility intro state — behind one coherent per-session context. The latest commit is another real improvement, but these remain blocking cross-session correctness/data-isolation issues.
Architecture follow-up: relation to #70There is a strong architectural relationship between this PR's multi-session findings and #70, but they cover different lifetime boundaries:
A hosted/MCP session can process multiple requests concurrently, so a DI scope per session does not replace request-level binding to Recommended splitRequired in this PRIntroduce one coherent internal MCP session container, for example: internal sealed class McpSessionContext
{
public McpClientRootsService Roots { get; }
public McpSnapshotCache Snapshot { get; }
public McpCompatibilityState Compatibility { get; }
public IServiceProvider Services { get; }
}The exact shape is flexible, but it should own all state that currently assumes one handler equals one session:
For Externally hosted Remains in #70#70 should still implement the general Repl lifetime contract:
Once #70 exists, Sequencing recommendationThe current cross-session leaks should not be merged with the expectation that #70 will repair them later: they are observable correctness/data-isolation bugs introduced by sharing one MCP handler across sessions. The pragmatic sequence is:
Implementing #70 first and rebasing this PR on it would also be architecturally valid, but #70 has a much broader surface and would significantly delay the SDK migration. The key requirement either way is that this PR must not merge while known MCP session state remains handler-global. |
|
Agreed on the layering table and the sequencing — this matches the plan on #70. Concretely:
You are right that the snapshot cache and |
One handler serves several sessions; everything that varied per client was still handler-global after the earlier point fixes. McpSessionContext now owns it all, per the architecture review: - hard AND soft roots: McpClientRootsService is one instance per session (plain fields again — the ConditionalWeakTable keying is gone); a session's 'workspace init' no longer sets another session's workspace. - generated snapshot + version + gate: the tool graph can be gated on session capabilities, so each session caches its own build against the handler-global routing version (RED observed: the roots-less session saw the roots-gated tool of the other session). - compatibility-shim intro: per-session flag, reset for every active session on routing invalidation (RED observed: only the first session received the discover_tools/call_tool intro). - per-session service overlay handed to McpServer.Create; request handlers recover their session through request.Server.Services instead of using a destination-bound per-request server as a surrogate session key. - externally hosted servers (BuildDynamicServerOptions) share one explicit lazy fallback context instead of racing a last-attached field. Request-bound OUTBOUND capabilities (sampling/elicitation/feedback) keep flowing through the per-request AsyncLocal accessor — finer than the session, unchanged. Related to #70 (per-session DI scopes generalize the lifetime contract; this context will construct from the session-scoped provider once both merge).
|
McpSessionContext landed in 1f8950f, per the recommended split:
Both remaining handler-global leaks were RED-observed before the refactor with dedicated regressions: the roots-less session saw the roots-gated tool of the other session (snapshot cache), and only the first session received the Linked as Related to #70 — PR #74 implements the general session-scope contract, and this context will construct from the session-scoped provider once both merge. |
Summary
Migrates Repl.Mcp to the ModelContextProtocol SDK 2.0 line (
2.0.0-preview.3) and hardens the server against the concurrency model this SDK introduces (destination-bound per-request servers, multi-session handlers).SDK 2.0 migration
ModelContextProtocol1.4.1 →2.0.0-preview.3.ModelContextProtocol.Extensions.Tasksand dropped the per-toolTool.Execution/ToolTaskSupportaugmentation from the protocol surface..LongRunning()stays in Repl's model (help/docs); protocol-level task support can return once Repl integrates the Tasks extension, store, and get/update/cancel lifecycle — tracked in Implement MCP Tasks runtime for .LongRunning() commands (ModelContextProtocol.Extensions.Tasks) #72.IReplInteractionChanneland soft roots. The designated successor (SEP-2322 multi-round-trip requests) is not yet consumable in the SDK.docs/mcp-reference.md; all.LongRunning()guidance aligned (overview, coding-agents guide, package README,CommandAnnotationsXML docs).Concurrency hardening (from adversarial review)
SDK 2.0's
2026-07-28path hands each request a destination-boundMcpServer, and one handler can serve several sessions. Three real defects were found and fixed, each with a deterministic RED-observed regression:AsyncLocalrequest binding (McpRequestServerAccessor), read once per operation.ConditionalWeakTablekeyed by destination server, global version stamp forroots/list_changedinvalidation).McpServer.Create. Request handlers recover their session throughrequest.Server.Services; externally hosted servers share an explicit lazy fallback context. RED-observed regressions: cross-session snapshot leak (roots-gated tool served to a roots-less session) and shim intro served only to the first session.Broader session-scoping (Microsoft DI
Scopedper hosted session) is intentionally out of scope here and tracked in #70 — these fixes are the MCP-local corrections.Test Plan
initializehandshake (pinned2025-11-25, negotiated version + tool list/call),tools/listwire shape for.LongRunning(), per-request capability binding, per-session roots isolation, notification survival after first-session close.dotnet test --solution src/Repl.slnx -c Release— 1319 passed, 1 known external-toolchain skip, 0 failed.Caveat
mainpublishes prerelease packages (0.12.0-dev.*), so depending on an SDK preview is legal for NuGet. Re-pin to the stable2.0.0release before cutting stable 0.12.Refs #51