feat: version-aware Connection abstraction for server scenarios#318
feat: version-aware Connection abstraction for server scenarios#318felixweinberger wants to merge 9 commits into
Conversation
Copies schema/{version}/schema.ts from the modelcontextprotocol spec
repo into src/spec-types/{version}.ts so the conformance suite can type
against draft spec versions before any SDK ships them.
npm run sync-schema -- <ref> refreshes the copies and records the spec
commit in src/spec-types/SOURCE.
Connection encapsulates how the conformance suite talks to a server-under-test for a given spec version: - connectStateful: 2025-x lifecycle. Thin adapter over the SDK Client (initialize handshake, session id, SSE handled by the SDK). - connectStateless: 2026-x lifecycle (SEP-2575). Raw fetch with per-request _meta + MCP-Protocol-Version header. Decoupled from the SDK so the suite can test draft spec versions before the SDK implements them. connectFor(specVersion) picks the implementation. RunContext bundles serverUrl, specVersion and a bound connect() for the runner to hand to each scenario. Nothing uses this yet; wiring follows in the next commit.
ClientScenario.run(serverUrl) becomes run(ctx: RunContext). The runner builds the context from --spec-version and the server URL; scenarios destructure ctx.serverUrl and otherwise behave identically. No scenario uses ctx.connect() yet, so behaviour is unchanged: 214/214 tests pass, all-scenarios.test.ts still drives the everything-server fixture exactly as before. Test files use a testContext(url) helper to construct a RunContext. The authorization-server scenario list is retyped to ClientScenarioForAuthorizationServer since those scenarios test an OAuth server, not an MCP server, and keep run(serverUrl).
22 carry-forward and lifecycle scenarios now go through the Connection
abstraction instead of connectToServer + SDK Client:
- tools.ts (8): list, call x6, with-progress
- prompts.ts (5)
- resources.ts (7): list, read x3, subscribe x2, not-found
- utils.ts (3): completion, ping, set-level
- json-schema-2020-12.ts, caching.ts, http-standard-headers.ts
Result types come from spec-types/{introducedIn}. Same scenario code
now passes under both --spec-version 2025-11-25 (SDK-backed stateful)
and --spec-version draft (raw stateless).
ToolsCallSampling/Elicitation/WithLogging and elicitation-* keep
connectToServer (need setRequestHandler/setLoggingLevel SDK surface);
they are tagged removedIn: DRAFT in the next commit. stateless.ts and
input-required-result.ts keep their sendRpc helper; migrating those is
deferred to the DRAFT-scenario coherence pass.
…import connectStateful now catches the SDK's McpError and rethrows as JsonRpcError so scenarios always see the same error class regardless of which Connection impl ran. ResourcesNotFoundError uses instanceof JsonRpcError instead of duck-typing. types.ts uses a normal top-level import for RunContext instead of an inline import() type.
The 'valid Host accepted' check was sending an initialize body, which a 2026 server returns 404 for. Probe body is now picked from ctx.specVersion: initialize for the stateful lifecycle, server/discover with _meta for the stateless lifecycle. The Host/Origin rejection check is unchanged since rejection happens before body parsing.
…Server Adds an in-memory dispatch client connected to the same McpServer the stateful path uses. Stateless requests for tools/call, resources/*, prompts/get and completion/complete that fall through the MRTR-specific handlers are routed to it, so the fixture serves the carry-forward scenarios under --spec-version draft without duplicating ~500 lines of tool/resource/prompt registrations. tools/list now merges the McpServer's tool list with the MRTR-only stubs so json-schema-2020-12 finds its tool. draft suite against the fixture: 36/39 (was 13/39). Remaining 3 are fixture-side SEP gaps (no SSE forwarding for progress, no SEP-2549 ttlMs, no SEP-2243 Mcp-Method validation in the stateless path).
commit: |
| export function connectFor( | ||
| specVersion: SpecVersion | ||
| ): (serverUrl: string) => Promise<Connection> { | ||
| return STATEFUL_VERSIONS.has(specVersion) | ||
| ? connectStateful | ||
| : connectStateless; | ||
| } |
There was a problem hiding this comment.
For reviewers: this is the primary abstraction to allow testing scenarios over both old and new protocol version.
| drainNotifications: () => unknown[]; | ||
| close: () => Promise<void>; | ||
| }; | ||
| async function getStatelessDispatchClient(): Promise<DispatchClient> { |
There was a problem hiding this comment.
Shim for the fact that the TS SDK doesn't support stateless servers yet, and we don't want to duplicate every tool/resource/prompt registration in the stateless HTTP branch.
For each stateless request, we spin up an SDK Client + McpServer pair connected over InMemoryTransport. The in-memory client.connect() does the initialize handshake the SDK requires, then we forward the incoming method/params through it. The express handler writes whatever comes back to the HTTP response, so from the outside the fixture looks like a native stateless server.
Goes away once the SDK has stateless server support.
00b9f55 to
2b8b15c
Compare
| } | ||
|
|
||
| if (method === 'tools/list') { | ||
| const dispatch = await getStatelessDispatchClient(); |
There was a problem hiding this comment.
Note: the other changes in everything-server here sit inside the if (!session && (reqVersion || meta)) { ... } block which only applies in stateless mode.
A stateful request falls straight through to transport.handleRequest() unchanged.
I think it would be nice to refactor everything-server to have just 2 paths though, handleStateful and handleStateless, but didn't want to mix that in here.
| export interface RunContext { | ||
| serverUrl: string; | ||
| specVersion: SpecVersion; | ||
| /** | ||
| * Open a version-appropriate connection to the server-under-test. | ||
| * Scenarios that test the connection mechanics themselves (initialize, | ||
| * GET-SSE, DNS rebinding) bypass this and use raw fetch. | ||
| */ | ||
| connect(): Promise<Connection>; | ||
| } |
There was a problem hiding this comment.
instead of just a server URL, this is now the context for running a test in so the version can be specififed.
| return { | ||
| jsonrpc: '2.0', | ||
| id: 1, | ||
| method: 'server/discover', |
There was a problem hiding this comment.
small change to make the dns rebinding check compatible with stateless
| Vendored copies of `schema/{version}/schema.ts` from the | ||
| [modelcontextprotocol](https://github.com/modelcontextprotocol/modelcontextprotocol) | ||
| spec repository. | ||
|
|
||
| These are the canonical TypeScript types for each protocol version. The | ||
| conformance suite imports types from here rather than from | ||
| `@modelcontextprotocol/sdk` so that it can test draft spec versions before any | ||
| SDK has implemented them. | ||
|
|
||
| **Do not edit these files by hand.** To refresh: |
There was a problem hiding this comment.
This one we could argue about - I chose to import spec types directly here instead of relying on the typescript-sdk types to decouple the two.
Because the conformance tests really need to front-run SDKs, coupling to the typescript-sdk feels like the wrong approach here - it also matches conceptually more closely that SDKs are downstream of conformance which itself is downstram of the protocol.
Also given we now have different types we potentially need to handle (as some are removed / changed between 2025-11-25 and DRAFT) I'm thinking we probably can't get away with just having a single import anymore like we did?
Open to discussion on this one though.
…d tests Addresses self-review findings on the new connection module: - RequestOptions.handlers/.meta and ServerRequestHandler removed: zero callers (the scenarios that motivated them are deferred). They can be reintroduced when something actually uses them. - scenarios/server/client-helper.ts moved to connection/sdk-client.ts so the connection module no longer depends on the scenarios module. - connectStateless: handle CRLF SSE event separators; throw a useful error for non-JSON/non-SSE responses instead of a JSON parse error. - JSONRPCNotification consistently imported from spec-types/2025-11-25. - New connection.test.ts (8 tests) covering connectFor selection, _meta injection, error mapping, SSE LF/CRLF parsing, and the server-request-on-stream rejection.
- connectStateless: throw on non-2xx responses that lack a JSON-RPC error envelope (e.g. gateway/framework error JSON), matching the stateful path's behavior. Previously such a response would return undefined as the result. - json-schema-2020-12: rename negotiatedVersion to targetVersion and reword the skip message and details field. The value is ctx.specVersion (the run's --spec-version), not a negotiated version; the old wording was misleading. Drop dead 'unknown' fallback (specVersion is required) and the corresponding undefined test cases.

Hoists the connection preamble out of individual server scenarios and into the runner, so the same scenario code runs under both the 2025 stateful lifecycle and the 2026 stateless lifecycle (SEP-2575).
Motivation and Context
Carry-forward behaviors (
tools/list,resources/read,prompts/get, etc.) are spec-invariant, but every server scenario hard-codedconnectToServer()→ SDKClient.connect()→initialize. That preamble is 2025-specific; in the 2026 draft it's replaced by per-request_meta+MCP-Protocol-Versionheader. So a scenario tagged{introducedIn: '2025-06-18'}with noremovedInwas selected under--spec-version draftbut couldn't actually exercise a pure-2026 server — it would fail at connect, or pass only because the fixture is dual-stack.This PR makes the connect preamble a function of
--spec-version, not something each scenario owns.What changes
src/spec-types/{version}.ts— vendored verbatim frommodelcontextprotocol/schema/{version}/schema.tsso the suite can type against draft spec versions before any SDK ships them.npm run sync-schema -- <ref>refreshes;SOURCErecords the pin.src/connection/—Connectioninterface (request<R>(method, params, opts?),notifications,close) with two impls:connectStateful— thin adapter over the SDKClient(don't reimplement the 2025 handshake/session/SSE)connectStateless— raw fetch with_metainjection +MCP-Protocol-Versionheader, decoupled from the SDKconnectFor(specVersion)picks the impl. Both throwJsonRpcErroron JSON-RPC error responses.ClientScenario.run(serverUrl)→run(ctx: RunContext)whereRunContext = {serverUrl, specVersion, connect()}. Runner builds it from--spec-version.connectToServer()+ SDK convenience methods toctx.connect()+conn.request<ResultType>('method', params). Result types come fromspec-types/{introducedIn}.removedIn: DRAFT— they test methods or mechanics removed in the 2026 draft.dns-rebinding-protectionnow picks its probe body fromctx.specVersion(initialize vs server/discover) so the "valid Host accepted" check works under both.everything-serverstateless path now dispatches carry-forward methods (tools/call,resources/*,prompts/get,completion/complete) to the sameMcpServerinstance the stateful path uses, via an in-memory client.tools/callis served as SSE so progress notifications reach the conformance client.How Has This Been Tested?
214/214 unit tests, typecheck, lint, build clean.
Full
--suite allagainsteverything-server:--spec-version2025-11-25draftThe one ✗ under draft is
http-header-validation, which is already inpendingClientScenariosListon main ("Pending until everything-server fully implements SEP-2243 header validation") and is unrelated to this change — the scenario itself sends a rawinitializeto obtain a session, which doesn't work under the stateless lifecycle. That's part of the broader DRAFT-scenario coherence pass.Per-scenario matrix (51 scenarios)
Applicable under both — 20
2025-only (
removedIn: DRAFT) — 12draft-only (
introducedIn: DRAFT) — 192026 coverage gaps created by
removedIn: DRAFTtaggingSome 2025-only scenarios test behavior that still exists in 2026 via a different mechanism. The
removedIntag is correct (the wire mechanic changed), but a 2026 sibling is needed to cover the same spec requirement:removedInscenarioserver-initializeserver/discover+_metaserver-statelesspinglogging-set-levellogging/setLevelaccepted_meta.logLeveltools-call-with-loggingnotifications/messageafter setLevel_meta.logLevelpresentresources-subscribe/-unsubscriberesources/subscribeacceptedsubscriptions/listentools-call-samplingsampling/createMessagevia SSEinputRequestsinput-required-result-basic-samplingtools-call-elicitationelicitation/createvia SSEinputRequestsinput-required-result-basic-elicitationelicitation-sep1034-defaultsdefault(SEP-1034)elicitation-sep1330-enumsenum(SEP-1330)server-sse-polling/-multiple-streamsScenarios to write for full 2026 parity
tools-call-with-logging-metatools/callwith_meta['io.modelcontextprotocol/logLevel']: 'debug'set → server emitsnotifications/messageon the response stream at that levelserver-statelesscovers the negative (sep-2575-server-no-log-without-loglevel) but not the positive;sep-2575.yamlhas nocheck:row for "emits at requested level"sep-2575-server-emits-log-at-requested-level(new row insep-2575.yaml)subscriptions-resources-updatedsubscriptions/listenwithresourcesUpdated: {uris: [...]}→ server sendsnotifications/resources/updatedon the stream when the resource changesserver-statelesscoverssep-2575-server-tags-subscription-idandsep-2575-server-sends-{tools,prompts}-list-changed-on-subscription, but not the resource-content-update pathsep-2575-server-sends-resources-updated-on-subscription(new row insep-2575.yaml)mrtr-elicitation-sep1034-defaultstools/call→InputRequiredResult.inputRequests[k].params.requestedSchemacarriesdefaultvalues per SEP-1034input-required-result-basic-elicitationchecks the roundtrip but not schema-shape detailssep-1034-defaults-via-mrtr(nosep-1034.yamlexists yet)mrtr-elicitation-sep1330-enumsenumconstraints per SEP-1330sep-1330-enums-via-mrtr(nosep-1330.yamlexists yet)Adding the
check:rows to the SEP YAMLs would make these show asuntestedintraceability.json(the existing TODO mechanism); not done in this PR.Breaking Changes
ClientScenario.run(serverUrl: string)→run(ctx: RunContext). All in-tree scenarios are updated; out-of-tree scenarios (none known) would need a one-line shim.Types of changes
Checklist
Additional context
Deferred (out of scope, will conflict with the DRAFT-scenario coherence pass):
stateless.ts/input-required-result.tsoff theirsendRpchelper ontoConnection— those scenarios assert onerror.code/ HTTP status for nearly every call; would need anexpectErrorhelper orhttpStatusonJsonRpcError.http-header-validationcoherence under draft (it sends rawinitializeto get a session).src/scenarios/client/*) — symmetricctx.createServer()abstraction.Type import rule: a scenario imports result types from
spec-types/{its source.introducedIn}. That's the contract it asserts; if a later spec adds optional fields, the carry-forward scenario doesn't see them (a separate scenario covers the addition).