Skip to content

Add draft protocol support: sessionless + handshake-less (SEP-2575 + SEP-2567)#1610

Draft
halter73 wants to merge 25 commits into
mainfrom
halter73/remove-session-id-draft
Draft

Add draft protocol support: sessionless + handshake-less (SEP-2575 + SEP-2567)#1610
halter73 wants to merge 25 commits into
mainfrom
halter73/remove-session-id-draft

Conversation

@halter73

@halter73 halter73 commented May 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the draft MCP protocol revision (2026-07-28) in the C# SDK — removing the initialize handshake and Mcp-Session-Id per SEP-2575 and SEP-2567, while preserving back-compat with legacy clients/servers via probe-and-fallback negotiation.

Stacked on the now-merged #1458 (MRTR). Opt in to draft by setting ProtocolVersion = McpSessionHandler.DraftProtocolVersion.

What's in

Protocol

  • DraftProtocolVersion value set to "2026-07-28" (spec string, replaces MRTR's "DRAFT-2026-v1" placeholder).
  • server/discover registered on every server; serves as the bootstrap mechanism (clients send it first under draft).
  • Per-request _meta.io.modelcontextprotocol/protocolVersion is validated server-side; unsupported versions return -32004 UnsupportedProtocolVersionError with {supported, requested} data.
  • ttlMs + cacheScope added to DiscoverResult per spec PR #2855; defaults to ttlMs: 0 + cacheScope: "private" under draft (immediate-stale, not shareable) for safe back-compat behavior.

Transport

  • HttpServerTransportOptions.Stateless defaults to true for new code.
  • Stateful-only knobs (EventStreamStore, SessionMigrationHandler, PerSessionExecutionContext, IdleTimeout, MaxIdleSessionCount, plus ISseEventStreamStore / ISessionMigrationHandler) are marked [Obsolete(MCP9005)] — see docs/list-of-diagnostics.md.
  • Under draft + no session-id, the HTTP handler forces stateless per request.

Client negotiation

  • HTTP fallback: client sends draft request first. On 400 Bad Request it parses the body — modern JSON-RPC errors (-32004, -32003, -32001) surface as McpProtocolException to the caller; any other JSON-RPC error (legacy -32600, -32601, -32700, parse fail, empty body) → switch to legacy and initialize. Matches spec PR #2844 ("the fallback MUST NOT be keyed to a single error code").
  • Stdio fallback: client probes with server/discover first. DiscoverResult → modern. -32004 with shaped data → retry with supported[]. Anything else, or silence past the 5-second probe timeout → fall back to initialize on the same stdin/stdout (no process restart per spec).
  • AutoDetectingClientSessionTransport now recognizes JSON-RPC error envelopes in HTTP 400 bodies; adopts StreamableHttp instead of silently falling back to SSE on modern-error responses.

Public API

  • McpClientOptions.MinProtocolVersion : string? — when set, the client refuses to fall back below this version and surfaces a clear McpException instead. Useful for strict-modern production code and for tests that want to assert draft-only behavior.

What's tested

  • Existing draft & MRTR coverage: all 51 draft/MRTR/raw-stream tests green.
  • New raw conformance tests:
    • tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs — drives McpServer directly via paired Pipe streams without McpClient. 5 tests covering server/discover first, draft tools/call after no init, -32004 on unsupported version, legacy initialize still works, dual-era dispatch on the same stream.
    • tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs — drives the C# server with raw HttpClient against in-memory Kestrel. 5 tests covering draft tools/call with full _meta, raw server/discover, -32004 on unsupported MCP-Protocol-Version header, legacy initialize on the default (stateless+draft) server, and GET /mcp returning 405 when not stateful.
  • New HTTP fallback regression tests (tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpFallbackTests.cs): three Kestrel-in-memory cases covering Python-shape (-32600 legacy error), Go-shape (-32004 with supported data), and HeaderMismatch-shape (-32001) on real HTTP. Plus null-id parser tests and HeaderMismatch passthrough test on the in-memory transport.
  • HTTP+draft → stateless migration: tests that relied on stateful HTTP for legacy coverage (notably HttpTaskIntegrationTests) now explicitly opt into Stateless = false.
  • Cross-suite status at tip d9277e0c:
    • Core (ModelContextProtocol.Tests, net9.0, excluding env-dependent ClientIntegrationTests / DockerEverythingServerTests and the env-quirk-only StdioClientTransportTests.EscapesCliArgumentsCorrectly which depends on local PATH/CMD.EXE config): 2052 passed / 4 skipped. The full suite reports 72 fails for EscapesCliArgumentsCorrectly, all on a parameterized test that's git diff origin/main..HEAD = 0 (i.e. unchanged in this PR); CI on main is green.
    • AspNetCore (ModelContextProtocol.AspNetCore.Tests, net9.0): 482 passed / 3 failed / 29 skipped. The 3 failures are all pre-existing on main: Server_RejectsInvalidUtf8EncodedHeaderValue, RunConformanceTest_Sep2243("http-custom-headers") (the SEP-2243 finding below), and RunCachingConformanceTest (parallel-run port collision; passes in 1s in isolation).

Cross-SDK compatibility (Phase 7 + Phase 11d)

Validated against the other Tier-1 SDKs (TypeScript, Python, Go) in their current main / draft-branch states. Wire-trace artifacts kept in this branch's session state.

C# direction Peer Transport Status
→ TS draft (PR #2251 sep-2575-2567-draft-protocol) http PASS — draft handshake-less, tools/list succeeds
→ Python simple-streamablehttp-stateless (origin/main) http PASS — C# probes draft, recognizes -32600, falls back to legacy initialize, negotiates 2025-06-18
→ Python simple-tool (origin/main) stdio PASS — C# probes server/discover, gets -32601, falls back to initialize on the same stdin/stdout, negotiates 2025-06-18
→ Go vanilla HTTP (origin/main, AutoDetect default) http PASS — AutoDetect recognizes -32004 in 400 body, adopts StreamableHttp, retries legacy initialize with 2025-11-25, lists 10 tools
→ Go vanilla stdio (origin/main, after PR #987) stdio PASS — Go responds to server/discover natively; C# negotiates down to 2025-11-25
→ Go draft fork (compat/go-draft-fork with version-string + exported opt-in patches) both PASS — full modern draft session end-to-end
← TS draft client both PASS — C# server serves server/discover and tools/list
← Go draft fork client both PASS
← Python simple-tool client stdio PASS — Python sends legacy initialize with max 2025-06-18; C# server (stateless default) serves single-shot legacy session

α-findings fixed in this PR (post-cross-SDK testing)

Commit Fix
ccdd4223 Accept null-id JSON-RPC error responses per spec §5.1 (Python's simple-streamablehttp-stateless returns id: null on errors before the request id can be determined).
00d57f71 Surface any JSON-RPC error in HTTP 400 body as McpProtocolException per spec PR #2844 (not just modern -32004/-32003) so the connect-time fallback chain can dispatch on the error code.
276bde45 Surface HeaderMismatch (-32001) instead of falling back to legacy initialize.
3778e00e AutoDetectingClientSessionTransport now recognizes JSON-RPC error envelopes in HTTP 400 bodies; adopts StreamableHttp instead of silently falling back to SSE.

β-findings (peer-SDK issues, informational)

  • β-TS-1 — TS draft client (compat/ts-draft) doesn't yet emit Mcp-Method / Mcp-Name headers (the fix is on a different branch). Closure awaits upstream merge.
  • β-PY-1 — partially closed; Python main no longer crashes on draft probe, now returns clean JSON-RPC error envelope.
  • β-GO-1, β-GO-2 — Go origin/main still uses 2026-06-30 version string and unexported ClientSessionOptions.protocolVersion. Documented; patches applied locally for cross-SDK testing only.

Conformance suite (Phase 12)

Ran the upstream @modelcontextprotocol/conformance suite against the C# SDK. Two tracks:

Track A — bump the published npm pin

Bumped tests/Common/Utils/package.json from 0.1.160.2.0-alpha.2 (d539e7fd). This activates 5 previously-gated test classes (ClientConformanceTests.RunConformanceTest_Sep2243, ServerConformanceTests.RunConformanceTest_HttpHeaderValidation, ServerConformanceTests.RunConformanceTest_HttpCustomHeaderServerValidation, ServerConformanceTests.RunMrtrConformanceTest, CachingConformanceTests.RunCachingConformanceTest).

Because 0.2.0-alpha.2 still emits the placeholder wire version DRAFT-2026-v1 (the spec-aligned 2026-07-28 only landed in unpublished alpha.3), a wire-version-match gate (HasMatchingDraftWireVersion() in tests/Common/Utils/NodeHelpers.cs, commit f3698c71) is ANDed into each draft-only HasXxx skip predicate so the 14 draft-scenario rows skip cleanly under the published alpha.2 instead of failing with mismatched-string assertions.

Track B — local build of compat/conformance-draft

Assembled a local compat/conformance-draft branch in modelcontextprotocol/conformance (tip 50ad0fa) by merging the following SEP-relevant open PRs on top of main:

  • #336 — stateless error-code expectations (-32001 / -32004)
  • #262 — Tasks (SEP-2663) MRTR scenarios
  • #333 — SEP-2567 sessionless suite

#310 (SEP-2549 absence-assert) was skipped — too-deep conflict with main's RunContext refactor (PRs #319 / #317 / #321 / #318). Deferred to a follow-up.

Installed locally with npm install --no-save H:\modelcontextprotocol\conformance. ⚠️ Note: npm ci reverts to pinned alpha.2; reviewers reproducing locally must re-run the path-install after dependency restore. Flipped 3 --spec-version DRAFT-2026-v1 references in ServerConformanceTests.cs + 1 in CachingConformanceTests.cs to 2026-07-28 (commit d9277e0c), and renamed 6 tools + 1 prompt in IncompleteResultTools.cs / IncompleteResultPrompts.cs to match conformance's rename of incomplete-result-* → input-required-result-* (mirrors the SDK's MRTR IncompleteResult → InputRequiredResult rename).

Outcome (serial run on stateless HTTP):

Result Count Notes
PASS 17 including all 12 MRTR input-required-result-* scenarios, both Sep2243.http-{standard,invalid-tool}-headers, and Caching
FAIL 1 ClientConformanceTests.RunConformanceTest_Sep2243("http-custom-headers") — pre-existing C# client bug on origin/main (git diff origin/main..HEAD on McpClientImpl.cs / StreamableHttpClientSessionTransport.cs is empty). C# client doesn't emit Mcp-Param-* headers when tools declare x-mcp-header annotations. Out of scope for this PR; tracking as a follow-up.
SKIP 2 input-required-result-tampered-state (needs HMAC-protected requestState pattern) and input-required-result-capability-check (needs per-request capability-aware inputRequest gating). Both are advanced scenarios that would require new server-side patterns and are outside this PR's scope; annotated with Skip = "..." and rationale.

Modes: only stateless HTTP exercised so far. Stateful HTTP and stdio modes deferred to a follow-up — Track B already validates draft conformance on the most important transport, and the published-pin gate (Track A) ensures CI on pinned alpha.2 keeps working without local conformance-build dependencies.

Parallel-run flakiness: CachingConformanceTest shows a port-pool collision (port 301x range) under parallel xUnit collections; passes consistently in isolation in under 2 s. Documented as known-flaky-in-parallel; the test suite was not switched to serial.

Out of scope

  • The Tasks extension (SEP-2663) shipped in Implement SEP-2663 Tasks Extension #1579 (merged); this PR rebases on top of it.
  • Legacy protocol types (InitializeRequestParams, Mcp-Session-Id constants, PingRequestParams, …) are still current in 2025-11-25 and remain un-obsoleted in this PR.
  • HTTP+SSE (2024-11-05) transport stays mapped under /sse and /message for legacy back-compat.

Punted to follow-up PRs

  • HeaderMismatch (-32001) validation: server should compare HTTP MCP-Protocol-Version against body _meta.protocolVersion. Currently not validated.
  • Draft request with stray mcp-session-id header: server returns 400 instead of silently ignoring (spec says draft is sessionless so the header should be a no-op).
  • IsStatefulSession() gate review in McpServerImpl.IsMrtrSupported (the existing TODO from the MRTR PR).
  • Configurable stdio probe timeout (currently hard-coded to 5 s).
  • C# client Mcp-Param-* header emission for tools declaring x-mcp-header annotations (the SEP-2243 finding in Track B above; pre-existing on origin/main).
  • Conformance suite Track B coverage extensions: (1) stateful HTTP + stdio modes, (2) reinstate SEP-2549 absence-assert (#310) after the conformance RunContext refactor settles, (3) implement the two skipped MRTR scenarios (tampered-state HMAC + capability-check per-request gating), (4) file upstream issue for missing server/discover standalone scenario.

halter73 and others added 6 commits June 5, 2026 11:02
Implements the protocol-level changes for the draft revision (SEP-2575 stateless MCP and SEP-2567 sessionless MCP):

- New _meta keys for per-request protocolVersion / clientInfo / clientCapabilities / logLevel
- New RPCs: server/discover and subscriptions/listen, plus the acknowledgement notification
- New JSON-RPC error codes -32004 (UnsupportedProtocolVersion) and -32003 (MissingRequiredClientCapability) with typed exception classes
- Client skips initialize under draft mode, calls server/discover instead, and falls back to legacy initialize when the server doesn't support the experimental version
- Server keeps the legacy initialize handler for back-compat, and a new built-in incoming message filter projects per-request _meta values onto the per-session client info/capabilities/version state under draft
- HTTP server suppresses Mcp-Session-Id and routes draft requests through the stateless path regardless of HttpServerTransportOptions.Stateless
- HTTP server returns -32004 with a structured supportedVersions data payload when a client requests an unsupported protocol version
- HTTP client transport carries the protocol version header on every request (sourced from per-request _meta when present), and surfaces -32004/-32003 from HTTP error responses as typed McpProtocolException for the connection logic to react

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds round-trip tests for the new draft protocol revision and updates the XML
documentation on ExperimentalProtocolVersion (client + server) to describe the
full SEP-2575 + SEP-2567 behavior (sessionless, handshake-less, server/discover,
MRTR-only server-to-client interactions, fallback to legacy initialize on
unsupported-version responses).

Tests added:
- DiscoverProtocolTests / SubscriptionsListenProtocolTests / DraftErrorDataTests:
  JSON-serialization round-trip coverage for the new protocol types and error
  data payloads.
- DraftConnectionTests: end-to-end client/server connection flow for draft
  client vs. draft server, draft client vs. legacy server (fallback), legacy
  client vs. draft server, and explicit server/discover invocation.
- DraftHttpHandlerTests (AspNetCore): HTTP-level checks that draft requests
  don't emit Mcp-Session-Id, unsupported protocol versions return -32004 with
  the structured supportedVersions payload, draft requests carrying an
  Mcp-Session-Id route through the legacy lookup, and draft GET/DELETE are
  rejected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…rsion to 2026-07-28

- Drop ExperimentalProtocolVersion from McpClientOptions/McpServerOptions and use
  ProtocolVersion == McpSessionHandler.DraftProtocolVersion as the draft predicate.
- McpHttpHeaders.DraftProtocolVersion and McpSessionHandler.DraftProtocolVersion are
  now `2026-07-28` (matches the published spec) instead of `DRAFT-2026-v1`.
- Server always advertises draft via SupportedProtocolVersions; ConfigureDiscover no
  longer takes an opt-in flag.
- Drop _serverHasExperimental machinery from DraftConnectionTests; the test class
  now relies on the unconditional draft support.
- Skip Mrtr_MixedExceptionAndAwaitStyle(experimentalClient: True) over Streamable
  HTTP; the await-style path needs session affinity and draft HTTP is sessionless.
  Stdio coverage stays in DraftProtocolBackcompatTests.
- Sweep the remaining DRAFT-2026-v1 / 2026-06-XX literals across docs and tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…options (MCP9005)

Default `HttpServerTransportOptions.Stateless` to true so new code on the 2026-07-28
draft revision (SEP-2567) is sessionless from the start. Mark the surface that only
makes sense in the legacy stateful HTTP mode as obsolete behind the new MCP9005
diagnostic so callers see a deprecation hint but can still pin Stateless = false
to keep using session-based behaviors during back-compat:

* `HttpServerTransportOptions.EventStreamStore` (resumability)
* `HttpServerTransportOptions.SessionMigrationHandler` (multi-node migration)
* `HttpServerTransportOptions.PerSessionExecutionContext`
* `HttpServerTransportOptions.IdleTimeout`
* `HttpServerTransportOptions.MaxIdleSessionCount`

Internal infrastructure that legitimately reads those options for the back-compat
stateful path now suppresses MCP9005 at the use site. Test projects suppress it
globally via NoWarn because the suite intentionally exercises both modes.

Update tests/samples that previously relied on the implicit `Stateless = false`
default to set it explicitly:

* TestSseServer.Program — SSE always needs stateful state shared across GET/POST.
* ConformanceServer.Program — resumability + OAuth conformance scenarios are stateful.
* ResumabilityIntegrationTestsBase — resumability is a stateful concern.
* SseIntegrationTests / MapMcpSseTests — SSE requires stateful.
* OAuthTestBase — OAuth flow uses the GET /sse session-based endpoint.
* MrtrProtocolTests / SessionMigrationTests / StreamableHttpServerConformanceTests —
  these tests intentionally drive the legacy stateful session machinery.
* DraftHttpHandlerTests — tests draft rejection of GET/DELETE endpoints, which are
  only mapped when Stateless = false.

Rework HTTP header conformance helpers (HttpHeaderConformanceTests +
StreamableHttpServerConformanceTests) to stop asserting an mcp-session-id response
header from draft/non-draft initialize, because the sessionless default means none
is returned.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…colVersion validation, and raw stream conformance tests

Phase 2-5 of the draft (2026-07-28) rollout:

- McpClientImpl.ConnectAsync: tighten the draft probe fallback to match the spec's stdio rules. Apply a 5-second probe timeout (bounded by InitializationTimeout), broaden the catch to treat any McpProtocolException OR probe-timeout as a legacy-server signal, and special-case the two modern-server JSON-RPC errors (-32004 retries with supported[]; -32003 surfaces). Honor MinProtocolVersion before falling back to legacy initialize.

- McpClientOptions: add MinProtocolVersion public string? with XML docs. Setting this to McpSessionHandler.DraftProtocolVersion disables the automatic legacy fallback.

- McpServerImpl.CreateDraftStateSyncFilter: reject any per-request _meta/io.modelcontextprotocol/protocolVersion that is not in SupportedProtocolVersions with UnsupportedProtocolVersionException (-32004). The HTTP handler already validated the MCP-Protocol-Version header; this closes the corresponding gap for stdio/Stream and for HTTP bodies where the header is absent.

- HttpTaskIntegrationTests: Tasks pin per-session state into the in-memory store, so the tests require stateful HTTP. With Stateless=true the default after Phase 1, the tests would deadlock on the second request because the task ID wasn't visible to the new stateless server invocation. Opt back into stateful mode with WithHttpTransport(options => options.Stateless = false).

- New tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs: drive McpServer directly via paired Pipe streams without going through McpClient. Hand-writes JSON-RPC messages and asserts on the exact bytes the server emits. Covers server/discover -> supportedVersions[], draft tools/call without initialize, -32004 with data.supported on unsupported version, legacy initialize on the same dual-era server, and a mixed Discover->Initialize->ToolsCall sequence.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Phase 5b + 6 of the SEP-2575/SEP-2567 work.

- tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs: 5 new tests that drive the C# server directly with hand-crafted HttpClient requests, no McpClient involvement. Covers draft tools/call with full _meta, server/discover, -32004 on unsupported MCP-Protocol-Version, legacy initialize on a default (stateless+draft) server, and GET returning 405 when not stateful.

- docs/list-of-diagnostics.md: add MCP9005 row describing the stateful Streamable HTTP options as back-compat-only knobs since the draft revision is sessionless by default.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@halter73 halter73 force-pushed the halter73/remove-session-id-draft branch from bb9f572 to 30782f6 Compare June 6, 2026 00:21
halter73 and others added 4 commits June 5, 2026 18:10
Closes the doc gap that was left dangling in the prior commits.

- src/ModelContextProtocol.Core/McpSession.cs: add public 'LatestProtocolVersion' and 'DraftProtocolVersion' constants so user code can opt into a specific revision without typing string literals. The two constants forward to the existing internal values on 'McpSessionHandler'.

- src/ModelContextProtocol.Core/Client/McpClientOptions.cs: retarget XML cref + example to 'McpSession.DraftProtocolVersion' (the new public constant) instead of the internal 'McpSessionHandler.DraftProtocolVersion'.

- docs/concepts/stateless/stateless.md: flip every stale 'Stateless = false is the default' claim, rewrite the 'Why isn't stateless the default?' note, mark MRTR as merged (no longer 'proposed'), update the property reference table with 'true' default and 'MCP9005' callouts on stateful-only knobs, and add a new 'The 2026-07-28 draft revision' subsection covering wire-level changes, server stateless routing, client probe-and-fallback negotiation (HTTP + stdio), and 'MinProtocolVersion' opt-out.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Brings in 9 commits including af6fcff (InitializeMeta), ed19286 (SEP-2243 header standardization), a4157f3 (skip IdleTrackingBackgroundService timer when stateless), and assorted fixes.

Conflict resolution in McpClientImpl.cs: kept the draft probe-timeout structure from this branch while threading the new 'Meta = _options.InitializeMeta' through to PerformLegacyInitializeAsync.

Merge fallout fixes:

- tests/.../HttpHeaderConformanceTests.cs: five MCP-Protocol-Version header values updated from the stale 'DRAFT-2026-v1' placeholder to the spec value '2026-07-28' so the new SEP-2243 tests actually exercise the draft path on this branch.

- tests/.../HttpMcpServerBuilderExtensionsTests.cs: 'IdleTrackingBackgroundService_StartsTimer_WhenStateful' now opts into 'Stateless = false' (under MCP9005 suppression) so it exercises the stateful timer path, since the default is now 'Stateless = true' on this branch.

- tests/.../RawStreamConformanceTests.cs: 'ReadLineAsync(cancellationToken)' replaced with '.ReadLineAsync().WaitAsync(...)' to fix a pre-existing net472 build error that wasn't caught earlier (net472 'StreamReader.ReadLineAsync' has no cancellation overload).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When a client configured with ProtocolVersion = DraftProtocolVersion probed
a legacy server with server/discover and fell back to the initialize handshake,
PerformLegacyInitializeAsync rejected the server's response because the post-
handshake validation compared _options.ProtocolVersion ('2026-07-28') against
the legacy server's negotiated version (e.g. Python's '2025-06-18'). The strict
comparison was correct for legacy explicit pinning but wrong for the draft-
fallback path, where the spec requires the client to accept whatever supported
version the legacy server advertises.

Now we only require an exact match when the user pinned a legacy (non-draft)
version; otherwise we accept any version in SupportedProtocolVersions. We also
enforce MinProtocolVersion against the negotiated response in case the server
downgrades further than the version we requested.

Adds DraftProtocolFallbackTests covering:
* -32601 MethodNotFound fallback with version downgrade
* -32602 InvalidParams fallback
* MinProtocolVersion refuses fallback below the configured minimum
* Legacy explicit-pin still requires exact version match

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The ping RPC was removed in the draft 2026-07-28 revision (SEP-2575).
TypeScript and Go SDKs both reject ping under draft; C# was responding
to ping unconditionally with the comment 'must always be handled',
which was a holdover from the older spec.

The built-in handler now throws McpProtocolException(MethodNotFound)
for any per-request protocol version >= DraftProtocolVersion, falling
back to the session-level NegotiatedProtocolVersion when the per-request
_meta is absent (legacy sessions).

Liveness on draft sessions belongs to transport- and request-level
timeouts, not a removed MCP RPC.

Adds PingProtocolGatingTests covering:
* Ping under draft returns MethodNotFound
* Ping under legacy still succeeds

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
halter73 and others added 14 commits June 11, 2026 14:54
Brings in PR #1579 SEP-2663 Tasks (squash dbb7a20), SEP-990 Enterprise
Managed Authorization (8202bcc), SEP-2243 alignment (ed19286), ttlMs
renames in McpSessionHandler (711e5bb), and several quality-of-life
fixes that landed between the previous merge and today.

Conflict resolutions:
- src/ModelContextProtocol.Core/McpJsonUtilities.cs: keep both sides'
  JsonSerializable additions. Our draft additions (JsonElement,
  Implementation, ClientCapabilities, ServerCapabilities, LoggingLevel)
  coexist with origin/main's IDictionary<string,object> addition.
- src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs: take
  origin/main's renamed TaskStatusNotification value ('notifications/tasks',
  formerly 'notifications/tasks/status') and the updated XML docs from
  PR #1579. Keep all our draft additions (RelatedTaskMetaKey,
  SubscriptionsAcknowledgedNotification, ProtocolVersionMetaKey,
  ClientInfoMetaKey, ClientCapabilitiesMetaKey, LogLevelMetaKey,
  SubscriptionIdMetaKey).
- tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs:
  removed. Our pre-rebase tweak to the old SEP-1686 file is moot now
  that PR #1579's reimplementation deleted it; the new task tests live
  elsewhere.

PR #1579 author addressed the reconciliation items predicted in the
preview-merge analysis: '17f95f79 Fix _meta' nests tasks opt-in inside
the SEP-2575 capabilities envelope (preview commit 89295fb3 no longer
needed); '8b47086d Address PR feedback' fixes Failed task payload shape +
JsonDocument lifetime (preview commit 8817c9fc no longer needed);
'0b8944f9 Address PR feedback: docs' adds the IMcpTaskStore lifetime/
stateless docs (preview commit 072222db no longer needed). The only
preview reconciliation that may still be required is gating per-request
capability merge to stateful sessions only (preview commit 8b95d2ca),
which is evaluated separately after this merge by re-running
StatelessServerTests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PR #1579's GetMetaWithTaskCapability writes a partial SEP-2575 capabilities
envelope (only `extensions.io.modelcontextprotocol/tasks`) on every
`tools/call`, regardless of negotiated protocol version. The server's
`CreateDraftStateSyncFilter` was treating the envelope as authoritative and
overwriting the session-scoped `_clientCapabilities` with the partial value -
wiping out whatever the initialize handshake had captured. Most visibly, a
legacy client that handshook with `Elicitation = new()` would lose
elicitation support the moment it issued a tools/call, and the back-compat
MRTR resolver would then fail with "Client does not support elicitation
requests".

Switch the per-request synchronization to a defensive merge that preserves
fields the envelope leaves null and additively merges extension keys. Gate the
merge behind `IsStatefulSession()` so per-request envelope state doesn't
leak into `_clientCapabilities` on stateless HTTP sessions (where
StatelessServerTests rely on the null invariant to surface "X is not
supported in stateless mode" errors).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Implements SEP-2549 "TTL for List Results", which lets servers attach
optional caching freshness hints to the five cacheable result types:
tools/list, prompts/list, resources/list, resources/templates/list, and
resources/read.

Protocol changes:
- Add ICacheableResult with TimeToLive (serialized as integer-millisecond
  ttlMs) and CacheScope (serialized as cacheScope).
- Add the CacheScope enum (public, private) with lowercase wire values.
- Implement the interface on the five cacheable result types.
- Register CacheScope for source-generated serialization.

Both fields are optional and omitted when unset, so the change is fully
backward compatible and requires no capability negotiation. The SDK
propagates the values without consuming them.

Robustness and security:
- ttlMs deserialization clamps out-of-range, fractional, and overflowing
  values (including positive and negative infinity) to TimeSpan.MinValue
  or MaxValue instead of throwing, so a malformed or hostile hint cannot
  break reading of the enclosing result. The shared
  TimeSpanMillisecondsConverter uses the non-throwing TryGetDouble and
  clamps by token sign, giving identical behavior on .NET and on .NET
  Framework (whose number parser reports failure on overflow rather than
  returning infinity).
- cacheScope deserialization tolerates unknown or future values by mapping
  them to null (treated as the public default) instead of failing the whole
  result, and matches the known values case-insensitively so a mis-cased
  "private" is honored rather than silently downgraded to public.

Tests:
- Serialization, round-trip, omission, and clamping edge cases for ttlMs.
- Unknown, partial, and case-insensitive cacheScope handling.
- Per-page independence of caching hints for pagination.
- End-to-end propagation of hints from server to client.
- Regression coverage for the shared converter used by McpTask ttl and
  pollInterval.
- Caching conformance scenario wiring, gated to the conformance build that
  provides it.

Verified across net8.0, net9.0, net10.0, and net472, and under Native AOT
publish with no trimming or AOT warnings.
- CacheScopeConverter.Read now consumes non-string tokens with reader.Skip()
  before returning null. Previously an object or array value for cacheScope
  left the reader mispositioned and threw "read too much or not enough",
  breaking deserialization of the whole result. Added object and array cases
  to the tolerant-deserialization test.
- GetInstalledConformanceVersion no longer calls EnsureNpmDependenciesInstalled.
  The version check backs Theory skip gates and must be side-effect-free; it
  now returns null when the conformance package is absent. The actual scenario
  run path still restores npm dependencies via ConformanceTestStartInfo.
PR #1579 (SEP-2663) replaced the SEP-1686 McpTask type with CreateTaskResult
and switched its ttl/pollInterval to bare `long?` properties, so the
TimeSpanMillisecondsConverter no longer has a second consumer. The shared
regression suite cherry-picked from PR #1623 references the now-removed
McpTask type and stops compiling.

The converter's clamp-instead-of-throw branches are still fully exercised by
CacheableResultTests (oversized, large-negative, +Inf, -Inf round-trips), so
no coverage is lost. Drop the file.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
SEP-2549 (PR #1623 cherry-picked in 51571c6) added the ICacheableResult
contract with 	tlMs and cacheScope to the five list/read results, but the
spec was subsequently amended by spec PR #2855 to also require both fields on
server/discover responses. Implement that on DiscoverResult and emit safe
defaults from the built-in handler so existing servers keep their "do not
cache" behavior while remaining wire-compliant under draft.

Changes:

- `DiscoverResult` now implements `ICacheableResult` and carries
  `TimeToLive`/`CacheScope` properties with the same wire shape as the
  list/read results.
- `ICacheableResult` xmldoc updated to mention `server/discover` alongside
  the existing list/read implementers.
- `McpServerImpl.ConfigureDiscover` emits `ttlMs: 0` +
  `cacheScope: "private"` (immediately stale, not shareable) on the built-in
  handler. The values match halter73's design call on PR #1623: the safest
  defaults preserve today's behavior without requiring server authors to
  opt-in to caching, while still satisfying the wire requirement under draft.
- `RawHttpConformanceTests.ServerDiscover_RawPost_ReturnsDiscoverResult` and
  `RawStreamConformanceTests.ServerDiscover_ReturnsSupportedVersionsIncludingDraft`
  now assert the fields are emitted with the expected values.
- New `DiscoverResultCacheableTests` exercises the round-trip on
  `DiscoverResult` (the existing parameterized `CacheableResultTests` cannot
  cover it because `DiscoverResult` has required CLR properties that block
  reflection-based `Activator.CreateInstance`).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Spec PR #2759 promotes params._meta to required on tools/list,
resources/list, resources/templates/list, prompts/list, and
server/discover under draft. The C# client already injects the SEP-2575
envelope on every outgoing request via McpSessionHandler.InjectDraftMeta
when the session has negotiated draft; this test file pins that behavior
so future refactors cannot silently regress the envelope on list-style
requests when the caller passes no params/RequestOptions.

Six tests under ClientServerTestBase:
- DraftClient_ListTools_NoOptions_EmitsRequiredMeta
- DraftClient_ListPrompts_NoOptions_EmitsRequiredMeta
- DraftClient_ListResources_NoOptions_EmitsRequiredMeta
- DraftClient_ListResourceTemplates_NoOptions_EmitsRequiredMeta
- DraftClient_ServerDiscover_EmitsRequiredMeta
- LegacyClient_ListTools_DoesNotEmitDraftMeta (negative control)

The four list-method tests attach server-side request filters that
capture request.Params?.Meta and assert the three required SEP-2575
keys (protocolVersion, clientInfo, clientCapabilities) are present
plus that protocolVersion matches the negotiated draft revision.

The server/discover test asserts round-trip success — if the client had
omitted _meta, the server would have rejected with -32602/-32003 rather
than returning a DiscoverResult; the wire-level shape is covered by the
existing RawHttp/RawStream conformance tests.

The legacy negative-control test pins that the draft-meta injector is
gated correctly on the negotiated protocol version.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The probe-error fallback comment in McpClientImpl.cs listed
-32601/-32602/-32700 as the exemplar error codes that trigger the
legacy-server fallback. Spec PR #2844 (June 3 2026) clarifies that the
fallback MUST NOT be keyed to a single error code: any non-modern
JSON-RPC error or probe timeout means legacy.

Our code already does the spec-conformant thing — the catch block is
keyed on the McpProtocolException base class, not specific error codes —
but the enumeration in the comment was misleading. Replace it with text
that makes the spec-conformant intent explicit and notes that the two
modern-server signals (-32004 UnsupportedProtocolVersion, -32003
MissingRequiredClientCapability) are caught upstream and never reach
this branch.

No behavior change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
JSON-RPC 2.0 §5.1 requires the server to respond with `id: null` when an
error occurs before the request id can be determined (e.g. parse error, invalid
request, or transport-level rejection). The `RequestId.Converter` previously
threw on a `null` token and `JsonRpcMessage.Converter` rejected responses
with both `error` and a null `id`, so any peer that legitimately produced
that shape (e.g. Python's `simple-streamablehttp-stateless` on a 400) was
surfaced as `HttpRequestException` instead of the structured JSON-RPC error
that the SEP-2575 fallback logic needs.

Cross-SDK testing against Python's `main` branch reproduces the shape:
`{""jsonrpc"":""2.0"",""id"":null,""error"":{""code"":-32600,...}}`.
Accept that shape so the draft-to-legacy fallback path can recognize it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Per spec PR #2844 (HTTP backwards compatibility for SEP-2575), a 400 Bad
Request that carries a JSON-RPC error envelope means the peer is signalling
something application-level about the request shape (not a transport failure).
The connect-time fallback path needs to see the structured exception so it can
decide between retrying with a server-advertised version (-32004), surfacing a
capability gap to the caller (-32003, -32001), or falling back to legacy
`initialize` for any other JSON-RPC error code (e.g. -32600 from a legacy
server that doesn't understand the draft `_meta` envelope).

Previously only the three modern draft error codes were surfaced; everything
else became `HttpRequestException` and bypassed the fallback chain. Now any
JSON-RPC error in a 400 body becomes `McpProtocolException`, plus the three
modern codes are surfaced for non-400 status codes for robustness (servers
occasionally pair them with other 4xx codes).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
McpClientImpl.ConnectAsync's draft probe path catches McpProtocolException to
fall back to a legacy `initialize` exchange. The handler had passthrough
cases for the two modern draft error codes whose semantics are not ""I'm a
legacy server"" (UnsupportedProtocolVersion -32004 and
MissingRequiredClientCapability -32003) but was missing the third:
HeaderMismatch -32001 from SEP-2243. That code means the body's
`_meta.io.modelcontextprotocol/protocolVersion` does not match the
`MCP-Protocol-Version` HTTP header — which is a client-side bug that should
surface to the caller, not a signal to retry with `initialize`.

Add the third passthrough so all three modern draft codes propagate, with an
in-memory transport regression test that exercises the path against a server
which only returns `-32001`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
AutoDetectingClientSessionTransport's job is to pick between StreamableHttp
(post-SEP-2243) and legacy SSE based on the server's response to the first
POST. The detection logic only checked the HTTP status code, so a draft server
returning `400 Bad Request` with a JSON-RPC error body (e.g. `-32004`
UnsupportedProtocolVersion or `-32600` from a legacy server that didn't
understand the draft `_meta` envelope) was being treated as ""this isn't
StreamableHttp"" — and the transport silently fell back to SSE, breaking
fallback negotiation against any HTTP/1.1 peer that follows the spec.

Detect a JSON-RPC error envelope in the 400 body, adopt StreamableHttp, then
re-throw the structured exception so McpClientImpl can dispatch on the error
code (retry with advertised version, surface capability gap, or fall back to
`initialize`). Use a deferred throw guarded by `catch when (ActiveTransport
is null)` to preserve transport ownership across the deferred error path.

Cross-SDK validation: against vanilla Go SDK `origin/main` HTTP everything
server, default `--http-mode autodetect` now successfully adopts
StreamableHttp, recognizes `-32004` with `data.supported`, retries with
`2025-11-25`, and lists 10 tools. Against Python `main`'s
`simple-streamablehttp-stateless`, the same flow recognizes the `-32600`
returned from the draft probe, adopts StreamableHttp, and falls back to
`initialize` with `2025-06-18` to complete the handshake.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The latest published prerelease (0.2.0-alpha.2, Jun 3 2026) adds gated scenarios
for SEP-2243 (HTTP headers), SEP-2549 (caching), and SEP-2322 (MRTR/incomplete
result). Pinning to it makes the 14 currently-skipped scenarios available to
HasSep2243Scenarios()/HasCachingScenario()/HasMrtrScenarios().

However, alpha.2 still ships the placeholder wire string 'DRAFT-2026-v1' for
draft scenarios, while this SDK (and the conformance main branch, awaiting
alpha.3 publish) emits the spec-ratified '2026-07-28'. Without an additional
gate, the 14 newly-activated scenarios all fail with mismatched draft wire
strings. The next commit tightens the gates with a HasMatchingDraftWireVersion()
guard so they remain skipped on alpha.2 and activate on alpha.3+ (or on a local
build of conformance main installed via 'npm install --no-save').

Baseline test surface unchanged: 126 pass / 14 skip / 2 pre-existing skip in
ConformanceTests on net9.0 (one pre-existing failure in
HttpHeaderConformanceTests.Server_RejectsInvalidUtf8EncodedHeaderValue predates
this change).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The previous gates activated draft-only scenarios as soon as the conformance
package version reached 0.2.0. That works for conformance >= 0.2.0-alpha.3 (or a
local build of main) where the bundled DRAFT_PROTOCOL_VERSION constant matches
this SDK's value, but breaks under 0.2.0-alpha.2 because alpha.2 still ships the
placeholder 'DRAFT-2026-v1' wire string while this SDK only accepts the
ratified '2026-07-28'.

Add HasMatchingDraftWireVersion() that greps the bundled
node_modules/@modelcontextprotocol/conformance/dist/index.js for this SDK's
McpHttpHeaders.DraftProtocolVersion (the bundle is minified so we can't grep
the constant name, but the literal version string survives bundling and is
specific enough to be reliable). AND it into the three gates:
HasSep2243Scenarios(), HasCachingScenario(), HasMrtrScenarios().

Also unify HasMrtrScenarios() with HasSep2243Scenarios()/HasCachingScenario():
read the installed version from node_modules instead of the pinned version
from package.json. This lets a local 'npm install --no-save <path-to-conformance>'
activate MRTR scenarios the same way it already activates SEP-2243/caching.

Under 0.2.0-alpha.2 (this PR's pin), the 14 gated draft scenarios all SKIP
cleanly instead of failing on wire-string mismatch. Once 0.2.0-alpha.3 publishes
(or a local main build is installed), the gates auto-activate.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…nce main

The conformance suite's main branch (post 0.2.0-alpha.2) made two
breaking changes that our pinned wire-name / spec-version contracts had
to follow:

1. `DRAFT-2026-v1` wire literal flipped to `2026-07-28` (commit
   6f83abf in the conformance repo).
2. `incomplete-result-*` server scenarios renamed to
   `input-required-result-*` and extended from 8 to 14 scenarios
   (PR #262 in the conformance repo, follow-on to our own MRTR rename
   in PR #1458 where `IncompleteResult` became `InputRequiredResult`).

Track A (the published 0.2.0-alpha.2 pin) is unaffected and continues
to skip these tests via the wire-version-match gate introduced in
`f3698c71`. Track B (a private install of the conformance `main`
branch with the four merged sessionless/MRTR/error-code/tasks PRs)
now exercises 12 of the 14 MRTR scenarios end-to-end against this
SDK's `ConformanceServer`.

Changes in this commit:

- `IncompleteResultTools.cs` and `IncompleteResultPrompts.cs` —
  rename 6 tool wire names plus 1 prompt wire name from
  `test_incomplete_result_*` (and `test_tool_with_elicitation`)
  to `test_input_required_result_*` so the conformance scenarios
  can find them. The C# class names are intentionally left as
  `Incomplete*` to keep the diff minimal; the comment block above
  `RunMrtrConformanceTest` documents the asymmetry.
- `ServerConformanceTests.cs` — replace the 8-row MRTR theory with
  the 14-row theory matching the new conformance scenario set; flip
  the two `--spec-version DRAFT-2026-v1` references to
  `2026-07-28`; mark two scenarios skipped that require
  server-side patterns the `ConformanceServer` tools don't yet
  implement (HMAC-signed requestState for
  `input-required-result-tampered-state`; per-request capability
  gating for `input-required-result-capability-check`). Those
  scenarios are still feature-flagged behind the wire-version gate so
  they only attempt to run when the installed conformance package
  speaks `2026-07-28`.
- `CachingConformanceTests.cs` — flip the `--spec-version`
  reference and rewrite the doc remarks to explain the
  wire-version-match gate.

Validation: `dotnet test` over the targeted slice (5 conformance
test classes) under serial run reports 17 pass / 1 fail / 2 skip
against the private `compat/conformance-draft` build. The single
failure (`Sep2243.http-custom-headers`) is a pre-existing C# SDK
client bug exposed by the conformance-pin bump: the client sends no
`Mcp-Param-*` headers when the server's tool advertises
`x-mcp-header` annotations. The bug exists on `origin/main`
(`git diff origin/main..HEAD -- src/ModelContextProtocol.Core/Client`
on these files is empty) and is unrelated to the SEP-2575 / SEP-2567
draft work in this PR. Tracking as a follow-up.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.

SEP-2575: Make MCP Stateless SEP-2567: Sessionless MCP via Explicit State Handles

3 participants