Skip to content

feat(mcp): migrate to ModelContextProtocol 2.2.0 - #71

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

feat(mcp): migrate to ModelContextProtocol 2.2.0#71
carldebilly wants to merge 8 commits into
mainfrom
dev/cdb/issue-51-mcp-v2

Conversation

@carldebilly

@carldebilly carldebilly commented Jul 16, 2026

Copy link
Copy Markdown
Member

Summary

Updates Repl.Mcp to the stable official ModelContextProtocol SDK 2.2.0 and adapts the MCP server to its per-request, multi-session model.

SDK migration

Session isolation and concurrency

One handler can serve multiple MCP sessions. This PR makes MCP-local state session-bound:

  • request-bound resolution for Roots, Sampling, Elicitation, and Feedback;
  • roots cache and generated tool snapshot isolated per session;
  • discovery notifications remain active until the last session disconnects;
  • dynamic-tool compatibility bootstrap state is isolated per session.

This prevents capability cross-wiring, workspace roots leaking between clients, stale session-specific tool graphs, and notifications stopping when one of several sessions closes.

Broader application DI scoping remains out of scope and is tracked in #70.

Validation

  • Regression coverage includes legacy initialization, request capability binding, per-session roots and snapshots, and notification lifetime.
  • The MCP test suite is green; the external MCP Inspector smoke test is opt-in.

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

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

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.

Revalidated on exact head ddce4d408466630cacc59d5c67b53cb92f6d8653. Two Roots-capable clients on the RunAsync path now receive their own file:///ga and file:///bu roots, and the targeted regression passes. The original hard-roots leak on that path is fixed. A separate verified leak remains when the documented public API reuses one BuildMcpServerOptions() result across servers.

@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

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

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

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.

Revalidated on exact head ddce4d408466630cacc59d5c67b53cb92f6d8653 with the same deterministic two-session probe on RunAsync. After A sets file:///root-a, B returns none and never observes A's root. The original soft-root leak is fixed on that path; the public static-options path is covered by a separate verified blocker.

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.

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.

Revalidated on exact head ddce4d408466630cacc59d5c67b53cb92f6d8653. Snapshot/gate/version state is now owned by each McpSessionContext, and the original cross-session snapshot contamination is fixed. I am raising a separate protocol-era finding because the new green test intentionally varies the modern tools/list catalog per connection.

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.

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.

Revalidated on exact head ddce4d408466630cacc59d5c67b53cb92f6d8653. Each RunAsync transport context now owns its compatibility-intro flag, so one client no longer consumes another client's bootstrap. The original handler-global state bug is fixed; modern list-invariance/subscription behavior is addressed separately.

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

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

- 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
…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.
…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).
… 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.
…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).
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).
…in SDK 2.0

The deprecation pragmas and the reference doc claimed the SEP-2322
multi-round-trip successor was 'not yet consumable in the SDK'; preview.3
actually ships it experimentally (MrtrContext/MrtrContinuation/MrtrExchange).
Reworded to 'shipped experimentally, not adopted by Repl yet' — adoption is
a follow-up under the compliance track.
@carldebilly
carldebilly force-pushed the dev/cdb/issue-51-mcp-v2 branch from b362736 to ddce4d4 Compare August 13, 2026 21:23
@carldebilly carldebilly changed the title feat(mcp): migrate to ModelContextProtocol 2.0 line (2.0.0-preview.3) feat(mcp): migrate to ModelContextProtocol 2.2.0 Aug 13, 2026

@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 the exact current head ddce4d408466630cacc59d5c67b53cb92f6d8653 against base d4f039841568c27dbb820ddfece9ec488106cf48.

The previous request-binding, RunAsync hard/soft-roots isolation, snapshot contamination, compatibility-intro ownership, first-session-close lifecycle, legacy handshake, Tasks wording, and deprecation-guidance findings are fixed. I independently rebuilt with warnings as errors, ran the full solution (1,467 passed / 0 failed / 1 external-toolchain smoke skipped), packed Repl.Mcp, ran the concurrency tests repeatedly, and verified the exact-head CI provenance.

I still cannot approve the MCP 2026-07-28 migration. Four blockers remain:

  1. the documented reusable BuildMcpServerOptions() path captures one context and demonstrably leaks soft roots across servers while losing request capabilities;
  2. modern list results still vary by connection/first-call state;
  3. list-change notifications bypass subscriptions/listen;
  4. logging notifications ignore the modern per-request logLevel opt-in.

Two additional medium findings cover a teardown false-green and modern soft-roots guidance. Each inline comment includes a deterministic reproduction and acceptance-test shape.

Primary references: MCP 2026-07-28 key changes and the exact ModelContextProtocol SDK 2.2.0 implementation.

var serverName = _options.ServerName ?? ResolveAppName() ?? "repl-mcp-server";
var serverVersion = _options.ServerVersion ?? "1.0.0";
var snapshot = BuildSnapshotCore();
var snapshot = BuildSnapshotCore(CreateSessionContext());

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.

[blocking] Do not capture one session context in reusable static options

BuildMcpServerOptions() is public and the transport guide explicitly tells hosts to build it once and reuse it across concurrent McpServer.Create(...) calls. This line now builds one McpSessionContext; its tool/resource/prompt adapters capture that context's service overlay. Static ReplMcpServerTool.InvokeAsync then calls the adapter directly, bypassing BindRequestServer/ResolveContext.

I reproduced both consequences on this exact head:

  • server A calls SetSoftRoots(file:///root-a); server B, created from the same options, reads file:///root-a instead of none;
  • a Sampling-capable client calls a tool that returns IMcpSampling.IsSupported; it receives False because the static path never binds the destination server.

This is cross-connection state exposure on the documented integration path. Please arrange per-connection/request scoping for static options (or stop documenting them as reusable), bind each invocation's request server, and add two-server soft-root isolation plus capability-binding regressions using one shared options instance.


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));

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.

[blocking] Keep 2026-07-28 catalogs invariant across connections

Both clients in this test negotiate 2026-07-28 (verified with explicit assertions in a local probe), but the test intentionally expects different tools/list results based on the connection's Roots capability. The final spec removed protocol-level sessions and explicitly says tools/list, resources/list, and prompts/list no longer vary per connection. Replacing these expectations with list equality fails deterministically: the Roots client receives { always, gated }, while the other receives { always }.

Please keep request-scoped capabilities for invocation behavior, but do not use McpSessionContext to vary modern catalog membership. If connection-specific catalogs and the first-list compatibility shim are retained for legacy clients, pin those tests/paths to 2025-11-25; add a modern regression asserting invariant lists and use explicit ordinary arguments or server-minted handles for cross-call state.

try
{
using var timeoutCts = new CancellationTokenSource(NotificationSendTimeout);
await server.SendNotificationAsync(method, timeoutCts.Token).ConfigureAwait(false);

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.

[blocking] Route modern list changes through subscriptions/listen

This raw broadcast bypasses the SDK 2.2.0 subscription pipeline. For 2026-07-28, */list_changed may be delivered only for notification types explicitly requested through subscriptions/listen, and each notification must carry the listen request id in _meta/io.modelcontextprotocol/subscriptionId. RegisterNotificationHandler only installs a local callback; it does not subscribe.

I added NegotiatedProtocolVersion == "2026-07-28" to When_FirstSessionCloses_Then_SurvivingSessionStillReceivesRoutingNotifications; it still passes even though that client never opens subscriptions/listen, proving the current test accepts an unsolicited modern notification.

Please preserve direct broadcasts only for initialize-era clients. For modern clients, either drive SDK primitive collections so its built-in fan-out runs, or implement SubscriptionsListenHandler with acknowledgement, filtering, tagging, and lifetime handling. Add regressions for no delivery without opt-in and tagged delivery with opt-in.

}

await _server!.SendNotificationAsync(
await server.SendNotificationAsync(

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.

[blocking] Honor the per-request modern log level before emitting messages

In 2026-07-28, _meta/io.modelcontextprotocol/logLevel replaces logging/setLevel; when it is absent, the server must not emit notifications/message for that request. Here IsLoggingSupported is true whenever any effective server exists, and SendMessageAsync sends unconditionally without checking the request's log level or severity threshold.

The existing When_ToolEmitsUserFeedback_Then_McpReceivesNotifications client supplies notification handlers but no logLevel. I added an assertion that it negotiated 2026-07-28; the test remains green and receives all messages, reproducing the violation.

Please carry the request context/log level through the request binding, return unsupported when modern metadata is absent, filter by the requested threshold, and keep legacy logging/setLevel semantics only on legacy revisions. Add negative and threshold regressions.

await clientA.DisposeAsync().ConfigureAwait(false);
await clientB.DisposeAsync().ConfigureAwait(false);
await cts.CancelAsync().ConfigureAwait(false);
_ = serverTaskA;

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.

[medium] Await server tasks so teardown failures cannot stay green

Discarding these tasks means the test validates assertions but not server termination. I inserted an unconditional InvalidOperationException at the end of McpServerHandler.RunAsync; When_TwoRootCapableClientsShareHandler_Then_EachSeesOwnRoots still passed because its server tasks are ignored. The same pattern occurs in the other new multi-session tests, while the shared fixture also swallows a teardown timeout.

Please cancel/close the transports, await every RunAsync task, accept only the expected cancellation outcome, and fail on fault or timeout. A teardown mutation like the one above should turn at least one regression red.

Comment thread docs/mcp-advanced.md
> **⚠️ Deprecation notice (SEP-2577):** the MCP specification (2026-07-28) deprecates the
> Roots feature, and the SDK may remove it in a future version. Repl keeps supporting it
> **for existing hosts and applications only** — new applications should not build on
> native MCP roots and can use [soft roots](#soft-roots-fallback) or explicit command

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.

[medium] Do not recommend implicit soft-root session state to new 2026-07-28 applications

This replaces deprecated native Roots with another connection-scoped state mechanism (SetSoftRoots lives in McpSessionContext and can change routing/catalog membership). The 2026-07-28 architecture removed protocol sessions and directs cross-call state through explicit ordinary arguments or server-minted handles.

Please recommend explicit parameters/handles for new applications. If soft roots remain as a compatibility feature, label them as legacy/stateful-only and update the transport/session-isolation guidance accordingly.

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

Follow-up to review #4933578205 on the unchanged head. A late independent concurrency pass identified one additional cache-publication regression; the inline comment records it separately without duplicating the existing blockers.

if (observedState.Version == snapshotVersion)
{
Volatile.Write(ref _builtSnapshotVersion, snapshotVersion);
context.BuiltSnapshotVersion = snapshotVersion;

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.

[medium] Publish the snapshot and its version with release/acquire semantics

Before this change, the lock-free fast path used Volatile.Read(ref _builtSnapshotVersion) and the writer published with Volatile.Write(ref _builtSnapshotVersion, snapshotVersion) after storing _snapshot. Moving the fields into McpSessionContext replaced both sides with ordinary property access (BuiltSnapshotVersion / Snapshot) while readers at lines 369-371 remain outside SnapshotGate.

There is therefore no release/acquire edge publishing this pair: a concurrent fast-path reader may observe the new version while still observing the previous snapshot and return stale discovery state. The semaphore only orders readers that take the slow path.

Please either publish an immutable (version, snapshot) holder atomically, or restore a Volatile.Write/Volatile.Read backing-field protocol (snapshot first, version last). Keep the exception/fallback path dirty as intended, and add a concurrent invalidation/read regression around the publication boundary.

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