Skip to content

feat(mcp): migrate to ModelContextProtocol 2.0 line (2.0.0-preview.3)#71

Open
carldebilly wants to merge 6 commits into
mainfrom
dev/cdb/issue-51-mcp-v2
Open

feat(mcp): migrate to ModelContextProtocol 2.0 line (2.0.0-preview.3)#71
carldebilly wants to merge 6 commits into
mainfrom
dev/cdb/issue-51-mcp-v2

Conversation

@carldebilly

@carldebilly carldebilly commented Jul 16, 2026

Copy link
Copy Markdown
Member

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

  • Package: ModelContextProtocol 1.4.1 → 2.0.0-preview.3.
  • MCP Tasks: the SDK reorganized Tasks into ModelContextProtocol.Extensions.Tasks and dropped the per-tool Tool.Execution/ToolTaskSupport augmentation 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.
  • Roots / Sampling / Logging deprecated (SEP-2577, MCP9005): kept for existing hosts/applications only, with scoped documented pragmas; the docs carry visible deprecation notices steering new applications toward IReplInteractionChannel and soft roots. The designated successor (SEP-2322 multi-round-trip requests) is not yet consumable in the SDK.
  • Docs: new "SDK and protocol versions" section in docs/mcp-reference.md; all .LongRunning() guidance aligned (overview, coding-agents guide, package README, CommandAnnotations XML docs).

Concurrency hardening (from adversarial review)

SDK 2.0's 2026-07-28 path hands each request a destination-bound McpServer, and one handler can serve several sessions. Three real defects were found and fixed, each with a deterministic RED-observed regression:

  1. Capability cross-wiring: the four capability services (roots, sampling, elicitation, feedback) stored a last-attached server in a shared mutable field. They now resolve the effective server through an AsyncLocal request binding (McpRequestServerAccessor), read once per operation.
  2. Cross-session roots exposure: the hard-roots cache was handler state; client B could receive client A's workspace roots. Hard roots are now session state (ConditionalWeakTable keyed by destination server, global version stamp for roots/list_changed invalidation).
  3. First-session-close silencing survivors: session attachment is now reference-counted — discovery notifications fan out to all active sessions, and the routing subscription is dropped only when the last session detaches.
  4. McpSessionContext (per the architecture review): one container per MCP transport session owns ALL session state — hard/soft roots (one roots-service instance per session), the generated snapshot + version + gate (the tool graph can be session-gated), the compatibility-shim intro flag, and the per-session service overlay handed to McpServer.Create. Request handlers recover their session through request.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 Scoped per hosted session) is intentionally out of scope here and tracked in #70 — these fixes are the MCP-local corrections.

Test Plan

  • RED observed before every fix (build failure for the migration; failing regressions with the reviewers' exact repros for each concurrency defect, verified against the pre-fix code).
  • New regressions: legacy initialize handshake (pinned 2025-11-25, negotiated version + tool list/call), tools/list wire shape for .LongRunning(), per-request capability binding, per-session roots isolation, notification survival after first-session close.
  • Full suite: dotnet test --solution src/Repl.slnx -c Release — 1319 passed, 1 known external-toolchain skip, 0 failed.

Caveat

main publishes prerelease packages (0.12.0-dev.*), so depending on an SDK preview is legal for NuGet. Re-pin to the stable 2.0.0 release before cutting stable 0.12.

Refs #51

- 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread docs/mcp-reference.md Outdated
…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.
@carldebilly

Copy link
Copy Markdown
Member Author

Adversarial review pass applied:

  • Corrected the Tasks narrative (was factually wrong): the SDK extracted MCP Tasks into ModelContextProtocol.Extensions.Tasks (store, task results, client polling) rather than removing the feature; what is gone from the protocol surface is the per-tool Tool.Execution augmentation. Adopting the extension package is the Tasks-runtime follow-up under Ready for ModelContextProtocol v2.0 #51 — Repl deliberately does not advertise task support until tasks/get|update|cancel works end-to-end (the issue's own non-goal).
  • Deprecation pragmas now name the designated successor (SEP-2322 multi-round-trip requests, not yet consumable in the SDK) instead of claiming 'no replacement API'.
  • MCP9005 pragmas narrowed to their touchpoints where the file was not dense with deprecated usage.
  • New regression locks the tools/list wire shape for .LongRunning() tools (annotations survive, no execution/taskSupport emitted).
  • Remaining .LongRunning() doc mentions aligned (mcp-overview, for-coding-agents, package README).

@autocarl autocarl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/Directory.Packages.props
Comment thread docs/mcp-reference.md
Comment thread docs/mcp-reference.md Outdated
Comment thread docs/mcp-reference.md Outdated
…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).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +56 to 59
var server = _servers.Effective;
if (server?.ClientCapabilities?.Roots is null)
{
return Current;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/Repl.Mcp/McpServerHandler.cs Outdated
Comment on lines +421 to +424
if (_server is null)
{
AttachServer(server);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 autocarl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Roots cache/snapshot affinity: after session A loads root-a, session B configured with root-b receives "root-a:file:///root-a". This leaks one client workspace into another and can contaminate root-dependent tool discovery.
  2. Routing subscription lifetime: after session A closes, routing invalidation no longer produces tools/list_changed for 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).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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 = [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/Repl.Mcp/McpServerHandler.cs Outdated
Comment on lines 309 to 310
var snapshotVersion = Volatile.Read(ref _snapshotVersion);
if (Volatile.Read(ref _builtSnapshotVersion) == snapshotVersion

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/Repl.Mcp/McpServerHandler.cs Outdated
Comment on lines +41 to +44
// 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 = [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 autocarl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Soft roots leak: after session A sets file:///root-a, session B reads the same URI through its own command.
  2. Generated snapshot affinity: with A advertising Roots and B not advertising Roots, A first tools/list returns the roots_unsupported module, showing B session state influenced A generation; the single _snapshot also caches the cross-session result.
  3. Compatibility bootstrap affinity: in DiscoverAndCallShim mode, A receives discover_tools/call_tool, while B first list receives only echo because 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.

@autocarl

Copy link
Copy Markdown
Contributor

Architecture follow-up: relation to #70

There is a strong architectural relationship between this PR's multi-session findings and #70, but they cover different lifetime boundaries:

Lifetime State owned at that boundary Owner
Application Root provider and global configuration Existing app lifetime
Hosted Repl session Microsoft DI Scoped services, user/auth/cart state, ownership and disposal #70
MCP transport/session Hard and soft roots, generated snapshot/version, compatibility bootstrap, notification registrations This PR, designed to integrate with #70
MCP request The request-bound request.Server used for Sampling, Elicitation, Feedback, etc. This PR

A hosted/MCP session can process multiple requests concurrently, so a DI scope per session does not replace request-level binding to request.Server. Conversely, request-level AsyncLocal binding does not make handler-global caches session-safe.

Recommended split

Required in this PR

Introduce 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:

  • hard roots and soft roots;
  • generated snapshot, version, and synchronization gate;
  • compatibility-intro state (discover_tools / call_tool);
  • session notification registrations and cleanup;
  • the session-specific service overlay/provider.

For RunAsync, create this context once per MCP transport session and make it available through the provider passed to McpServer.Create. Request handlers can then recover the originating session through RequestContext.Services, while continuing to use the request's own request.Server for request-bound outbound capabilities. This avoids using a destination-bound per-request server object as a surrogate session key.

Externally hosted BuildDynamicServerOptions should have an explicit equivalent ownership/fallback path rather than silently falling back to whichever session attached last.

Remains in #70

#70 should still implement the general Repl lifetime contract:

  • create one IServiceScope / IAsyncServiceScope per hosted session;
  • reuse its provider for every command in that session;
  • dispose it exactly once when the session ends;
  • define ownership for caller-supplied providers;
  • apply the contract consistently to WebSocket, MCP, one-shot CLI, and other hosts;
  • update the public DI documentation and tests.

Once #70 exists, McpSessionContext can be constructed or resolved from that session-scoped provider instead of owning an ad hoc parent provider. That gives the two changes a clean integration seam without making this SDK migration solve the entire DI lifetime problem.

Sequencing recommendation

The 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:

  1. make this PR internally session-safe with a minimal McpSessionContext;
  2. link it as Related to Scoped DI services are shared across hosted sessions despite per-session documentation #70, not Closes #70;
  3. let Scoped DI services are shared across hosted sessions despite per-session documentation #70 generalize the actual Microsoft DI scope lifecycle across every Repl host.

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.

@carldebilly

Copy link
Copy Markdown
Member Author

Agreed on the layering table and the sequencing — this matches the plan on #70. Concretely:

You are right that the snapshot cache and discover_tools/call_tool intro state are still handler-global — those move into the context before this merges.

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).
@carldebilly

Copy link
Copy Markdown
Member Author

McpSessionContext landed in 1f8950f, per the recommended split:

  • The context owns hard AND soft roots (McpClientRootsService is one instance per session — the ConditionalWeakTable keying is gone, and a session's workspace init no longer sets another session's workspace), the generated snapshot + version + gate, the compatibility-shim intro flag (reset per active session on routing invalidation), and the per-session service overlay handed to McpServer.Create.
  • Request handlers recover their originating session through request.Server.Services — the destination-bound per-request server is no longer used as a surrogate session key.
  • Externally hosted servers (BuildDynamicServerOptions) share one explicit lazy fallback context instead of silently adopting whichever session attached last.
  • Request-bound outbound capabilities keep the per-request AsyncLocal binding, unchanged.

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 discover_tools/call_tool intro (shim flag). Full suite green (1319 passed, known skip).

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants