Skip to content

feat: version-aware Connection abstraction for server scenarios#318

Open
felixweinberger wants to merge 9 commits into
mainfrom
fweinberger/runcontext
Open

feat: version-aware Connection abstraction for server scenarios#318
felixweinberger wants to merge 9 commits into
mainfrom
fweinberger/runcontext

Conversation

@felixweinberger
Copy link
Copy Markdown
Collaborator

@felixweinberger felixweinberger commented May 26, 2026

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-coded connectToServer() → SDK Client.connect()initialize. That preamble is 2025-specific; in the 2026 draft it's replaced by per-request _meta + MCP-Protocol-Version header. So a scenario tagged {introducedIn: '2025-06-18'} with no removedIn was selected under --spec-version draft but 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 from modelcontextprotocol/schema/{version}/schema.ts so the suite can type against draft spec versions before any SDK ships them. npm run sync-schema -- <ref> refreshes; SOURCE records the pin.
  • src/connection/Connection interface (request<R>(method, params, opts?), notifications, close) with two impls:
    • connectStateful — thin adapter over the SDK Client (don't reimplement the 2025 handshake/session/SSE)
    • connectStateless — raw fetch with _meta injection + MCP-Protocol-Version header, decoupled from the SDK
    • connectFor(specVersion) picks the impl. Both throw JsonRpcError on JSON-RPC error responses.
  • ClientScenario.run(serverUrl)run(ctx: RunContext) where RunContext = {serverUrl, specVersion, connect()}. Runner builds it from --spec-version.
  • 22 server scenarios migrated from connectToServer() + SDK convenience methods to ctx.connect() + conn.request<ResultType>('method', params). Result types come from spec-types/{introducedIn}.
  • 12 scenarios tagged removedIn: DRAFT — they test methods or mechanics removed in the 2026 draft.
  • dns-rebinding-protection now picks its probe body from ctx.specVersion (initialize vs server/discover) so the "valid Host accepted" check works under both.
  • everything-server stateless path now dispatches carry-forward methods (tools/call, resources/*, prompts/get, completion/complete) to the same McpServer instance the stateful path uses, via an in-memory client. tools/call is 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 all against everything-server:

--spec-version scenarios result
2025-11-25 32 32/32
draft 39 38/39

The one ✗ under draft is http-header-validation, which is already in pendingClientScenariosList on main ("Pending until everything-server fully implements SEP-2243 header validation") and is unrelated to this change — the scenario itself sends a raw initialize to 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

Scenario 2025-11-25 draft
completion-complete
dns-rebinding-protection
json-schema-2020-12
prompts-get-embedded-resource
prompts-get-simple
prompts-get-with-args
prompts-get-with-image
prompts-list
resources-list
resources-read-binary
resources-read-text
resources-templates-read
tools-call-audio
tools-call-embedded-resource
tools-call-error
tools-call-image
tools-call-mixed-content
tools-call-simple-text
tools-call-with-progress
tools-list

2025-only (removedIn: DRAFT) — 12

Scenario 2025-11-25 draft
server-initialize
ping
logging-set-level
resources-subscribe
resources-unsubscribe
server-sse-multiple-streams
server-sse-polling
tools-call-with-logging
tools-call-sampling
tools-call-elicitation
elicitation-sep1034-defaults
elicitation-sep1330-enums

draft-only (introducedIn: DRAFT) — 19

Scenario 2025-11-25 draft
server-stateless
sep-2164-resource-not-found
caching
http-custom-header-server-validation
http-header-validation ✗ (pre-existing pending)
input-required-result-basic-elicitation
input-required-result-basic-sampling
input-required-result-basic-list-roots
input-required-result-request-state
input-required-result-multiple-input-requests
input-required-result-multi-round
input-required-result-missing-input-response
input-required-result-non-tool-request
input-required-result-result-type
input-required-result-unsupported-methods
input-required-result-tampered-state
input-required-result-capability-check
input-required-result-ignore-extra-params
input-required-result-validate-input
2026 coverage gaps created by removedIn: DRAFT tagging

Some 2025-only scenarios test behavior that still exists in 2026 via a different mechanism. The removedIn tag is correct (the wire mechanic changed), but a 2026 sibling is needed to cover the same spec requirement:

removedIn scenario What it tested 2026 mechanism 2026 scenario
server-initialize init handshake, session-id format server/discover + _meta covered — server-stateless
ping ping roundtrip (removed entirely) n/a
logging-set-level logging/setLevel accepted per-request _meta.logLevel partial — see below
tools-call-with-logging tool emits notifications/message after setLevel tool emits when _meta.logLevel present partial — see below
resources-subscribe / -unsubscribe resources/subscribe accepted subscriptions/listen partial — see below
tools-call-sampling server→client sampling/createMessage via SSE MRTR inputRequests covered — input-required-result-basic-sampling
tools-call-elicitation server→client elicitation/create via SSE MRTR inputRequests covered — input-required-result-basic-elicitation
elicitation-sep1034-defaults elicitation schema carries default (SEP-1034) same, via MRTR gap
elicitation-sep1330-enums elicitation schema carries enum (SEP-1330) same, via MRTR gap
server-sse-polling / -multiple-streams standalone GET-SSE (removed entirely) n/a

Scenarios to write for full 2026 parity

Proposed scenario What it should assert Why not already covered Suggested check IDs
tools-call-with-logging-meta tools/call with _meta['io.modelcontextprotocol/logLevel']: 'debug' set → server emits notifications/message on the response stream at that level server-stateless covers the negative (sep-2575-server-no-log-without-loglevel) but not the positive; sep-2575.yaml has no check: row for "emits at requested level" sep-2575-server-emits-log-at-requested-level (new row in sep-2575.yaml)
subscriptions-resources-updated subscriptions/listen with resourcesUpdated: {uris: [...]} → server sends notifications/resources/updated on the stream when the resource changes server-stateless covers sep-2575-server-tags-subscription-id and sep-2575-server-sends-{tools,prompts}-list-changed-on-subscription, but not the resource-content-update path sep-2575-server-sends-resources-updated-on-subscription (new row in sep-2575.yaml)
mrtr-elicitation-sep1034-defaults tools/callInputRequiredResult.inputRequests[k].params.requestedSchema carries default values per SEP-1034 input-required-result-basic-elicitation checks the roundtrip but not schema-shape details sep-1034-defaults-via-mrtr (no sep-1034.yaml exists yet)
mrtr-elicitation-sep1330-enums same, for enum constraints per SEP-1330 same sep-1330-enums-via-mrtr (no sep-1330.yaml exists yet)

Adding the check: rows to the SEP YAMLs would make these show as untested in traceability.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

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Deferred (out of scope, will conflict with the DRAFT-scenario coherence pass):

  • Migrating stateless.ts / input-required-result.ts off their sendRpc helper onto Connection — those scenarios assert on error.code / HTTP status for nearly every call; would need an expectError helper or httpStatus on JsonRpcError.
  • http-header-validation coherence under draft (it sends raw initialize to get a session).
  • Client-conformance side (src/scenarios/client/*) — symmetric ctx.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).

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).
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 26, 2026

Open in StackBlitz

npx https://pkg.pr.new/@modelcontextprotocol/conformance@318

commit: 9c785f0

@felixweinberger
Copy link
Copy Markdown
Collaborator Author

Note: vast majority of this PR is vendoring spec types from modelcontextprotocol rather than implementation code:

CleanShot 2026-05-26 at 15 38 33

Actual implementation is ~ +800/-300

Comment thread src/connection/select.ts
Comment on lines +17 to +23
export function connectFor(
specVersion: SpecVersion
): (serverUrl: string) => Promise<Connection> {
return STATEFUL_VERSIONS.has(specVersion)
? connectStateful
: connectStateless;
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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> {
Copy link
Copy Markdown
Collaborator Author

@felixweinberger felixweinberger May 26, 2026

Choose a reason for hiding this comment

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

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.

@felixweinberger felixweinberger force-pushed the fweinberger/runcontext branch from 00b9f55 to 2b8b15c Compare May 26, 2026 14:58
}

if (method === 'tools/list') {
const dispatch = await getStatelessDispatchClient();
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread src/connection/index.ts
Comment on lines +64 to +73
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>;
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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',
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

small change to make the dns rebinding check compatible with stateless

Comment thread src/spec-types/README.md
Comment on lines +3 to +12
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:
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

1 participant