Skip to content

Commit d70d7ad

Browse files
panyampcarleton
andauthored
Feat/tasks mrtr extension (#262)
* feat(tasks): SEP-2663 lifecycle scenario (8 checks) Adds the first scenario for the SEP-2663 io.modelcontextprotocol/tasks extension — a single TasksLifecycleScenario covering sync vs async dispatch, DetailedTask shape on tasks/get, tool errors vs protocol errors, and cancellation semantics. 8 ConformanceCheck records, all passing against a SEP-2663-conformant Go fixture. Why "tasks" (not "tasks-v2"): SEP-2663 IS the tasks surface once it lands; the v2 suffix is only meaningful in implementations that maintain a v1 surface alongside, which the conformance suite does not. Layout: - src/scenarios/server/tasks/lifecycle.ts — scenario class - src/scenarios/server/tasks/helpers.ts — raw-fetch escape hatch (the SDK's typed schemas strip resultType/inputRequests/...) - src/scenarios/server/tasks/lifecycle.test.ts — fork-local vitest runner. Two modes: spawn a fixture binary via MCPKIT_TASKS_BINARY, or point at an already-running server via MCPKIT_TASKS_SERVER_URL. Skips when neither is set so it doesn't break upstream CI runs that go through everything-server (which doesn't yet implement io.modelcontextprotocol/tasks). Scenario is registered in pendingClientScenariosList so all-scenarios.test.ts skips it; promote to active once the upstream fixture grows extension support. Tagged ['extension', DRAFT_PROTOCOL_VERSION] — selectable via --suite extensions and --spec-version draft. * style(tasks): apply prettier formatting * feat(tasks,mrtr): port full SEP-2663 + SEP-2322 scenario suite Builds out the rest of the tasks scenarios (atop the lifecycle canary) and adds the SEP-2322 ephemeral MRTR scenario in a sibling folder. Both target their own fixtures; both runners are brand-neutral and language-agnostic (TASKS_SERVER_URL / TASKS_SERVER_CMD, MRTR_SERVER_URL / MRTR_SERVER_CMD; readiness via TCP polling). Tasks ClientScenario classes: - TasksLifecycleScenario (8 checks; v2-01..v2-08) - TasksCapabilityNegotiationScenario (4 checks; v2-11/22/23/25, SEP-2575) - TasksWireFieldsScenario (3 checks; v2-12/13/21) - TasksRequestStateScenario (3 checks; v2-14/15/28) - TasksMRTRInputScenario (3 checks; v2-16/17/29 partial fulfillment) - TasksRequestHeadersScenario (3 checks; SEP-2243 request-header tolerance) - TasksDispatchScenario (8 checks; v2-09/10/19/20/26/27/30/31) - TasksStatusNotificationsScenario (1 check; SEP-2663 §notifications, optional) MRTR ClientScenario class: - MrtrEphemeralFlowScenario (7 checks + 1 SKIPPED; mrtr-01..07, mrtr-08 deferred for spec terminology + reference-impl reasons) Both runners spawn the fixture via a shell command and detect readiness by TCP-polling the URL's host/port — no log-line scanning, no language-specific assumptions. The same env vars work for any server implementation. Scenarios are tagged ['extension', DRAFT_PROTOCOL_VERSION] and registered in pendingClientScenariosList so all-scenarios.test.ts (which targets the upstream everything-server) skips them until the fixture grows SEP-2322 / SEP-2663 support. * docs(tasks,mrtr): scenario READMEs for upstream porting Restructured around ClientScenario classes (one row per class with check-list under it) rather than per-numbered-test slugs. Documents fixture requirements, env vars, open spec questions, and the wire-format diff for each suite. Per AGENTS.md, severity follows spec keyword (MUST/MUST NOT → FAILURE, SHOULD/SHOULD NOT → WARNING). The READMEs explain why some checks emit INFO rather than FAILURE (optional emission paths per SEP-2322). * tasks: assert createdAt + lastUpdatedAt; factor _shared/ helpers Two reviewer-driven additions: 1. SEP-2663 createdAt / lastUpdatedAt ISO-8601 assertion in `tasks-server-task-creation` (per Luca's PR #262 review feedback). The check now flags servers that emit non-ISO timestamps (epoch seconds, RFC-2822, etc.) on TaskInfoV2 envelopes. 2. Factor cross-cutting test-harness helpers into _shared/: - `_shared/test-runner.ts` — `waitForServerReady` (renamed from `waitForTcpReady`; the call site cares about server readiness, not the TCP-poll mechanism). Imported by tasks/ and mrtr/ all-scenarios.test.ts; replaces ~30 LOC of inline duplication in each. - `_shared/wire-format.ts` — `ISO_8601_PATTERN` constant + `isIso8601(s)` predicate. Documented rationale for choosing a regex over `Date.parse` (too permissive), `new Date(s).toISOString()` (too strict), or `Temporal.Instant.from` (Node 24+ experimental). Future wire-shape predicates (data URI, percent-encoded filename, etc.) can land here. Cherry-pick footprint when graduating to upstream PR is the SEP folder + the imported `_shared/` files. First PR through carries them upstream; subsequent feat branches inherit via standard upstream-sync flow. All 9 scenario tests still pass against the Go reference fixtures. * refactor(tasks,mrtr): use SDK Client + AnyResult instead of raw-fetch helpers Drops initRawSession/rawRequest/rawRequestFull from tasks/helpers.ts in favor of the SDK's Client + StreamableHTTPClientTransport, paired with a Zod passthrough schema (AnyResult) that preserves SEP-2663 / SEP-2322 draft fields the SDK's typed schemas would strip. headers.ts and notifications.ts keep a small inline fetch where the SDK can't reach: per-request HTTP headers (SEP-2243) and SSE notification observation. Both reuse the SDK session via transport.sessionId. All SEP-2663 + MRTR ephemeral-flow scenarios pass against the Go fixture. * style: prettier formatting on tasks/mrtr scenarios * tasks: align ttlMs / pollIntervalMs assertions per 2026-05-07 SEP PR 2663 commit 62758914 standardised every duration field on the Ms suffix, integer milliseconds. wire-fields.ts now asserts ttlMs and pollIntervalMs are present on CreateTaskResult, the legacy v1 ttl and pollInterval keys are absent (already covered), and the interim ttlSeconds / pollIntervalMilliseconds keys are also absent on a post-2026-05-07 server. lifecycle.ts and the scenario README pick up matching prose updates. Verified by make testconf-tasks-v2 (8/8) against a renamed mcpkit fixture, and make testconf-mrtr (7/7 + 1 SKIPPED) against the paired MRTR surface. * style: prettier column alignment on tasks README * mrtr: flip MRTR_INPUT_REQUIRED_RESULT_TYPE per merged SEP-2322 SEP-2322 merged on 2026-05-06 with the variant renamed from IncompleteResult to InputRequiredResult and the resultType discriminator from "incomplete" to "input_required" (commit de6d76fb, per dsp-ant request). The MRTR_INCOMPLETE_RESULT_TYPE constant was specifically designed as a one-line flip point for this scenario. Renames - MRTR_INCOMPLETE_RESULT_TYPE = "incomplete" -> MRTR_INPUT_REQUIRED_RESULT_TYPE = "input_required" - isIncompleteResult -> isInputRequiredResult - All "IncompleteResult" -> "InputRequiredResult" in scenario prose and check descriptions (ephemeral-flow.ts, README.md) SEP-2663 had not yet flipped its discriminator literal as of PR head 82fb2c4d (5/7 21:52 UTC). Caitie's 5/15 RC commitment (issue comment 4384052694 on PR 2322) tracks the alignment to "input_required" both sides. The constant remains the one-line flip point in case the 2663 follow-up surprises us. Tested via mcpkit's make testconf-mrtr (7/7 + 1 SKIPPED green against a renamed mcpkit fixture) and make testconf-tasks-v2 (8/8 still green, no regressions on the paired surface). * mrtr: prettier formatting + fix README rename typo Lefthook prettier reformatted column alignment on first push attempt; README also had a stale "renamed from InputRequiredResult" — should read "renamed from IncompleteResult". Fix both. * mrtr-08: refresh SKIPPED prose post SEP-2322 merge Two stale references in the mrtr-tasks-composition SKIPPED check: - Comment block + errorMessage framed blocker (a) as "spec authors disagree" / "input_required vs incomplete". SEP-2322 merged 2026-05-06 with "input_required" (commit de6d76fb). The blocker now reads as "SEP-2663 has not yet aligned to the merged 2322 literal" — Caitie's 5/15 RC commitment (PR 2322 issue comment 4384052694) tracks the alignment. - errorMessage referenced "IsIncomplete signal" — that field was renamed to IsInputRequired on the mcpkit side in lockstep with the SEP-2322 wire-variant rename. Updated to match. Status stays SKIPPED because blocker (b) — the mcpkit middleware refactor (issue 347) — is still open. * mrtr-08: drop the resolved-discriminator framing from the SKIPPED message After SEP-2322 merged with "input_required", the only blocker that actually keeps mrtr-08 SKIPPED is the eager-task-creation pattern in reference-server middleware (panyam/mcpkit issue 347). The earlier two-blocker framing read as if the test were waiting on both, but blocker (a) is effectively resolved for any server that emits the merged-2322 literal — leaving (b) as the sole gate. Tighten the comment block + description + errorMessage to lead with the middleware refactor and demote the discriminator history to a parenthetical aside. * style: prettier line-break on mrtr-08 description string * tasks-wire-fields: address PR 262 review feedback Two requested changes from the SEP-2663 author's review pass on modelcontextprotocol/conformance PR 262 (pullrequestreview-4254601106). (1) Drop the interim-key absence checks. The transition window between "ttlSeconds / pollIntervalMilliseconds" and the final "ttlMs / pollIntervalMs" wording is over now that the merged spec settled on the Ms suffix. Useful while the spec was in flight, noise once it stabilized. Removes the absence checks plus the surrounding comment + description + details fields. (2) Add Number.isInteger() to ttlMs and pollIntervalMs validation. Spec says integer milliseconds; the previous typeof + range check would have allowed fractional values. Now both fields fail if they're not integers. README scenario table tightened: "ttlMs + pollIntervalMs present and integer-valued; legacy ttl / pollInterval keys absent". * style: prettier column alignment on wire-fields README row * feat(tasks): align scenarios with the Final-merged SEP-2663 spec SEP-2663 merged Final on 2026-05-15. Four normative wire changes baked in at merge time. This commit updates the tasks server-conformance scenarios so the suite validates each one as a MUST rather than tolerating divergence as INFO/MAY. 1. notifications/tasks/status renamed to notifications/tasks. The status-notifications scenario now FAILs on observing the legacy method on the v2 surface, and validates the new name's payload shape when emitted. 2. notifications/progress and notifications/message MUST NOT be sent for tasks. Two new absence-asserts on the status-notifications scenario, reusing the existing SSE-observation harness. 3. requestState removed from the tasks-v2 wire. The previous request-state scenario tested the deleted "Request State Management" section; replaced with absence-asserts on CreateTaskResult, DetailedTask, and notifications/tasks payloads. SEP-2322's InputRequiredResult on the MRTR surface still carries requestState and is unchanged. 4. -32003 (Missing Required Client Capability) for required-task tools when the client did not negotiate the extension. New scenario TasksRequiredTaskErrorScenario initializes without declaring the extension, calls a TaskSupport=required tool, and asserts the rejection code plus the structured requiredCapabilities payload. Documents `failing_job` as a fixture requirement. Verified locally against panyam/mcpkit examples/tasks-v2: 8/9 scenarios pass; the request-state-removal scenario intentionally FAILs against the current mcpkit main because the implementation still emits requestState on DetailedTask. That gap is the next item on the mcpkit catch-up plan. * chore(tasks): address PR 262 review feedback - notifications.ts: skip the entire scenario; SEP-2663 routes notifications/tasks over the SEP-2575 subscriptions/listen stream, not the tools/call POST SSE the prior harness observed. Body preserved in git blame for the rewrite follow-up. - request-state.ts: keep the absence-asserts (per the asymmetric recommendation in review) and reframe the doc block around the SEP-2322 lexical-adjacency motivation rather than pre-merge SEP history. - required-task-error.ts: cite SEP-2575 §"Missing Required Capabilities" as the canonical home of -32003; add SEP_2575_REF to specReferences on the -32003 checks. - Sweep decontextualized "merged spec dropped X" / "pre-merge SEP-2663 said Y" framing from tasks/* doc blocks and inline check descriptions. * chore(tasks): prettier — strip trailing blank line in notifications.ts * chore(tasks): drop `requestState` from `tasks/update` params `taskId` is the session-continuation handle on the tasks-v2 MRTR-resume path; `requestState` lives only on the SEP-2322 ephemeral InputRequiredResult surface, not on the tasks-v2 wire. * chore(tasks): loosen strict ack equality on tasks/cancel + tasks/update The cancel-empty-ack and tasks/update-ack checks were comparing the whole response against the literal `{resultType:"complete"}` via `JSON.stringify` equality. That rejected acks carrying `_meta` or any other future result-shape metadata even though the spec permits them. Now structural: assert `resultType === "complete"` plus absence of the task-envelope fields (`taskId`, `status`, `result`, `error`, `inputRequests`). `_meta` and other metadata pass through. * chore(tasks): poll for terminal status after tasks/cancel The cancel-empty-ack check observed status via an immediate tasks/get after the cancel ack. Cancellation is eventually-consistent, so the task could still be `working` (or transitioning) at the moment of observation, making the assertion flaky against any reasonable server. Now polls via `waitForTerminal()` and asserts the terminal status is `cancelled`. README's `slow_compute` fixture row documents the matching tool contract ("MUST settle to `cancelled` when `tasks/cancel` arrives while running") so the test has a deterministic anchor. * chore(tasks): tasks/cancel on a terminal task is idempotent The previous check asserted `-32602` when cancelling an already-terminal task. That forces every client to handle the race between observing a task's terminal status and racing in a cancel request — the task can terminate between the two and the cancel then errors instead of acking. Check 8 renamed to `tasks-cancel-terminal-idempotent-ack` and now asserts the same empty-ack shape (`resultType:"complete"` + no task- envelope fields) as on an active task. README's lifecycle table row updated to match. * docs(tasks): drop `requestState` from SEP-2663 wire-field listings `requestState` belongs to the SEP-2322 ephemeral MRTR result shape (`InputRequiredResult`), not to the tasks-v2 wire. The README listed it under SEP-2663 wire fields in four spots — top blurb, the SEP-2663 row of the Specs table, the v1↔v2 wire-format diff, and the raw-fetch design note. All four now describe only the actual SEP-2663 shapes; the raw-fetch note still calls out the SEP-2322 ephemeral `requestState` separately so the rationale for stripping it via raw fetch stays intact. The SEP-2322 row of the Specs table is unchanged since `requestState` is correctly listed there as an MRTR base type. * docs(tasks): rewrite stale request-state table to match absence-asserts The README's `tasks-request-state` table still described the prior scenario shape (shape / echo / stale-tolerance checks), which had assumed `requestState` was on the tasks-v2 wire. The scenario itself was rewritten earlier into the two-check absence assertion (`tasks-create-result-no-request-state` / `tasks-get-detailed-no-request-state`); the README now matches. A motivational paragraph above the table explains why a negative test exists for a field the spec never defines (the SEP-2322 lexical-adjacency confusion vector). * chore(tasks): assert -32003 (not -32601) for gated tasks methods When a client calls tasks/get / tasks/update / tasks/cancel without having negotiated the io.modelcontextprotocol/tasks extension, the suite now asserts -32003 (Missing Required Client Capability, SEP-2575 §"Missing Required Capabilities") instead of -32601. The spec doesn't currently mandate this code for the gated-method path, so the assertion is forward-looking: it follows the SEP-2575 pattern that already governs `required` tools (and that subscriptions/listen for tasks is expected to use). README's open-questions list calls this out so readers know it's a spec-clarification ahead-of-the-curve. Drops the now-obsolete "Invalid requestState — silent ack vs -32602" open-questions item: the previous request-state scenario covered that, but the rewritten absence-asserts scenario no longer exercises that path. * chore(tasks): prettier — realign README table pipes after row edits * feat(tasks/mrtr): initRawSession helper for draft-version handshake The SDK's Client.connect() pins protocolVersion to LATEST_PROTOCOL_VERSION in the initialize body. Scenarios tagged DRAFT_PROTOCOL_VERSION would still negotiate the previous stable on the wire, which means a strict draft-only server rejects the handshake with unsupported_protocol_version. initRawSession sidesteps the SDK: a raw fetch initialize that carries the draft version, captures the session ID, sends notifications/initialized, and exposes the small surface scenarios actually use - request, requestFull, notification, close. Errors are thrown as McpError so existing instanceof / .code checks keep working. SSE response parsing folds in the per-scenario raw helper from headers.ts. All 8 SEP-2663 tasks scenarios + the MRTR ephemeral-flow scenario migrate to the raw session. tsc clean. * feat(tasks/mrtr): wire-mode matrix; SEP-2575 _meta + SEP-2243 routing headers initRawSession grows a `stateless` option that picks between the legacy session wire (initialize handshake + Mcp-Session-Id) and the SEP-2575 stateless wire (no initialize; server/discover up front; per-request `_meta.io.modelcontextprotocol/{protocolVersion, clientInfo, clientCapabilities}` envelope; `MCP-Protocol-Version` header on every call). Tasks behavior is wire-independent in spec, so the harness can run each scenario against both wires. To keep the legacy run green through this PR's refactor (and to surface stateless-mode gaps deliberately rather than as a regression), the default is legacy- only — set TASKS_WIRE_MODES=legacy,stateless (or just stateless) to opt the matrix on. Same toggle for MRTR via MRTR_WIRE_MODES. Two follow-on cleanups in the same commit so the legacy run actually passes against an SEP-2243-enforcing server: - All request paths now auto-emit Mcp-Method (and Mcp-Name for tools/call.params.name / resources/read.params.uri) on protocol versions in `SEP_2243_ENFORCED_VERSIONS` (today DRAFT-2026-v1). Scenarios that probe the mismatch path can still override via `extraHeaders`. Empty record on older versions so the helper stays silent when the spec doesn't require it. - `tasks-headers-body-method-authoritative` was written under an older SEP-2243 reading where Mcp-Method was tolerant. The current spec (and mcpkit's PR 477 implementation, plus the upstream http-header-validation scenario) says the server MUST reject mismatches with -32001 HeaderMismatch. Renamed to `tasks-headers-reject-mismatched-method` and the assertion flipped to expect the rejection. Verification: testconf-tasks-v2 + testconf-mrtr both green on legacy wire against mcpkit's tasks-v2 fixture (9/9 + 1/1). * rename TASKS_WIRE_MODES / MRTR_WIRE_MODES → MCP_WIRE_MODES Wire-mode choice is a cross-cutting conformance concern, not a per-suite knob. Operators wouldn't realistically toggle tasks and mrtr to different wires (both fixtures speak the same MCP server, either it supports both wires or one). Single env var read by both harnesses; default unchanged at ['legacy']. * default MCP_WIRE_MODES to legacy+stateless The conformance suite is brand-neutral — every SDK consuming it should validate both MCP wires for SEP-2663 / SEP-2322 behavior by default. Pinning the default to legacy-only let mcpkit's CI stay green while gaps in its stateless dispatcher pended, but quietly under-tested any other SDK that adopts the suite. Default now runs every tasks-v2 scenario × 2 wires and the mrtr ephemeral-flow scenario × 2 wires. SDKs with only one wire implemented pin via MCP_WIRE_MODES=legacy or =stateless. mcpkit's own audit will need that pin (or to close panyam/mcpkit issue 480) until the stateless dispatcher consumes per-request _meta.clientCapabilities for the tasks extension declaration. * helpers: send MCP-Protocol-Version on every post-initialize POST Per 2025-11-25 transport spec §Protocol-Version Header, clients MUST include MCP-Protocol-Version on every subsequent HTTP request after initialize. Universal post-initialize requirement; applies to both the legacy session wire and the SEP-2575 stateless wire. The helper was dropping the header on legacy follow-up calls as a workaround for our server's detectWireKind treating the header as a stateless-wire signal. That workaround was non-spec-compliant; the right fix is for our server to drop the header from its wire-detection precedence list and rely on body-level _meta.protocolVersion as the SEP-2575 signal. Filed separately. Comment thread: PR 5 review (Luca, 2026-05-27). * types/helpers: parameterize wire mode via ScenarioRunOptions Drop the module-level defaultStateless flag + setDefaultWireStateless shim that the harness was toggling in beforeEach. Wire mode now flows as an explicit parameter through ClientScenario.run, so tests can be parallelized safely (no shared mutable state between concurrent runs). ClientScenario.run gains an optional ScenarioRunOptions second arg. Existing scenarios that ignore the arg satisfy the interface as-is (TypeScript permits an implementation to declare fewer parameters than the interface). The 10 task/mrtr scenarios that consume wire mode add one line each to thread `opts?.stateless` into initRawSession. Harness loops call `scenario.run(SERVER_URL, { stateless: wire === 'stateless' })` directly; no more beforeEach setter. Comment thread: PR 5 review (Luca, 2026-05-27, comments 2 and 7 on parallelism). * harness: use describe.each + it.each for the (wire x scenario) matrix Replaces the nested for-loops in both the tasks and mrtr harnesses with vitest's table-driven describe.each + it.each idioms. Same N x M cardinality of tests, but vitest sees the parameter table directly: - Reporter labels render the wire row inline: `... > stateless wire > 'tasks-lifecycle' - all checks ...` - Each (wire, scenario) becomes a leaf test vitest can schedule independently, opening the door to test.concurrent / pool config later without restructuring. WIRE_TABLE pairs each WireMode with the boolean flag the harness needs to pass via ScenarioRunOptions, so the `it.each` callback receives the prepared `stateless` directly rather than deriving it inside every test body. No on-wire change. Behavior identical against the reference fixture (9 task scenarios + 1 mrtr scenario, each per wire). Comment thread: PR 5 review (Luca, 2026-05-27, comments 5, 6, 9, 10). * helpers: extend Mcp-Name to prompts/get + tasks/* (SEP-2243 + SEP-2663) routingHeaders now auto-emits Mcp-Name for the full standard-header surface the spec defines: - prompts/get -> params.name (SEP-2243 §Standard Headers) - tasks/get -> params.taskId (SEP-2663 §Streamable HTTP routing headers) - tasks/update -> params.taskId (SEP-2663) - tasks/cancel -> params.taskId (SEP-2663) tools/call (params.name) and resources/read (params.uri) were already covered. Switch from if/else chain to a method-name switch since the surface is now wider; cleaner to extend further. Helper doc updated with the full surface list and a TODO for SEP-2243 §Custom Headers from Tool Parameters (Mcp-Param-* mirroring from x-mcp-header annotations), which requires caching the tools/list schema. Tracked as a separate follow-up; no current tasks/mrtr scenario exercises a tool with x-mcp-header annotated inputs. Comment thread: PR 5 review (Luca, 2026-05-27, comment 4). * _shared/wire-mode: hoist parseWireModes out of the per-suite harnesses Tasks and mrtr harnesses had byte-identical copies of WireMode type, VALID_MODES, DEFAULT_WIRE_MODES, parseWireModes, and WIRE_MODES. Hoisted to src/scenarios/server/_shared/wire-mode.ts so the single parser drives both suites. Each harness now does: import { parseWireModes, type WireMode } from '../_shared/wire-mode'; const WIRE_MODES: WireMode[] = parseWireModes(); No behavior change. MCP_WIRE_MODES env var semantics identical; default still legacy+stateless. Companion TODO landed in tasks/helpers.ts: most of that module (initRawSession, RawSession, routingHeaders, SEP_2243_ENFORCED_VERSIONS, readJsonRpcResponse, AnyResult, the failureCheck/skipCheck/errMsg test helpers) isn't actually tasks-specific and belongs in _shared too. Holding that file reshuffle for a separate follow-up PR so the move gets its own focused review thread without crowding the SEP work. Comment thread: PR 5 review (Luca, 2026-05-27, comment 8). * refactor: hoist raw-session + sep-refs + checks helpers into _shared/ Picks up the TODO left in tasks/helpers.ts on PR 5: the raw fetch session machinery, the SEP-2243 routing-header builder, the SEP_* reference constants, and the failureCheck / skipCheck / errMsg / AnyResult test scaffolding were never tasks-specific. They lived in tasks/helpers.ts only because tasks/ was the first server suite to need them. mrtr/helpers.ts re-declared SEP_2322_REF + errMsg + failureCheck for the same reason. Move them to src/scenarios/server/_shared/: - sep-refs.ts: SEP_2243_REF, SEP_2322_REF, SEP_2575_REF, SEP_2663_REF - checks.ts: errMsg, failureCheck, skipCheck, AnyResult - raw-session.ts: JsonRpcResponse, RawSession, InitRawOptions, initRawSession (legacy + stateless paths), routingHeaders, SEP_2243_ENFORCED_VERSIONS, readJsonRpcResponse, nextRawId tasks/helpers.ts shrinks to the tasks-shaped pieces it actually owns: TASKS_EXTENSION_ID and the waitForTerminal / waitForStatus polling loops. mrtr/helpers.ts drops the duplicate SEP ref + the duplicate test-scaffolding wrappers and re-imports from _shared/. skipCheck and failureCheck in _shared/checks.ts make specReferences required (a shared scaffold can't pick a sensible default), so all call sites now pass the SEP they're grading against explicitly. Pure file move + import-path churn. No behavioral change. Typecheck + lint clean. * feat: emit SEP-2243 Mcp-Param-* headers from tool inputSchema annotations Closes the TODO in routingHeaders. SEP-2243 §"Custom Headers from Tool Parameters" requires the client to mirror primitive-typed tools/call arguments as `Mcp-Param-<Suffix>` HTTP headers when the tool's inputSchema annotates the property with `x-mcp-header: "<Suffix>"`. Servers behind L7 routers / WAFs use these headers to make routing or policy decisions without parsing the JSON body. Implementation: - RawSession gains a `toolSchemas: Map<string, unknown>` cache. - `initRawSession` issues a best-effort `tools/list` after the handshake (after `notifications/initialized` for legacy; after `server/discover` for SEP-2575 stateless) and populates the cache from the response. Failures are swallowed — empty cache is the safe default. - `routingHeaders` takes an optional `toolSchemas` and walks the cached schema for tools/call: for each primitive-typed property (string / number / integer / boolean) with `x-mcp-header`, it encodes the argument value per SEP-2243 §value-encoding (plain printable ASCII verbatim; everything else wrapped as `=?base64?{b64-utf8}?=`) and emits `Mcp-Param-<Suffix>`. - Both wire flavours (legacy + SEP-2575) call routingHeaders the same way, so the same Mcp-Param-* headers ship regardless of transport mode. Behavior wash for tools without `x-mcp-header` annotations — they emit only Mcp-Method + Mcp-Name as before. Behavior wash for non-tools/call methods. No new dependencies (uses `globalThis.btoa` + `TextEncoder`, both standard in Node 20+). Typecheck + lint + the existing vitest suite stay green. * chore(_shared/raw-session): drop reference-impl naming from doc comments Tightens three doc-comment passages that named or hinted at a specific server-side implementation as the canonical reference. The conformance suite is positioned brand-neutral; the doc comments now describe the wire behavior in spec-anchored language only. No behavioral change. * sep-refs: point at rendered SEP pages on modelcontextprotocol.io PR URLs freeze at merge commit; the rendered SEP pages on the spec website absorb post-merge amendments. Switch all four refs: SEP-2243 → /seps/2243-http-standardization SEP-2322 → /seps/2322-MRTR SEP-2575 → /seps/2575-stateless-mcp SEP-2663 → /seps/2663-tasks-extension * raw-session: use eventsource-parser for SSE parsing Delegate the SSE branch of readJsonRpcResponse to eventsource-parser (the same parser the MCP TS SDK uses). The hand-rolled splitter worked for the single-frame JSON-RPC response we need but didn't cover the full WHATWG SSE semantics — multi-line `data:` continuation, CR/CRLF line endings, comment lines, the `event:`/`id:`/`retry:` fields. The library covers all of it. Already a direct dependency (3.x), so no new transitive footprint. * test: pin readJsonRpcResponse contract for SSE + JSON branches 12 vitest cases against `_shared/raw-session.ts`'s content-type dispatcher: application/json (id-match, mismatch, error frames) and text/event-stream (single-frame extraction, id-filter across multi- event streams, multi-line `data:` continuation, CRLF tolerance, comment/retry/id field handling, error-frame return, empty body, non-JSON data interleaving). Builds synthetic `Response` objects locally; no fixture server required, runs in every CI. Tests pin the contract the eventsource-parser swap inherits — guards against future parser- version regressions and against any subsequent rewrite that subtly changes the SSE branch. * tasks: isolate negative-path dispatch checks (conformance#262) The removed-method (-32601) and unknown-taskId (-32602) checks in tasks-dispatch-and-envelope previously sent requests missing Mcp-Name, so strict servers validating routing headers per SEP-2243 §Server Behavior could reject before reaching method dispatch — making the asserted error code unreachable. - routingHeaders(): emit Mcp-Name on tasks/result so the removed-method check fails specifically on -32601, not -32001 HeaderMismatch. - validTasksParams() helper: baseline params per tasks-namespace method; negative checks override one field to express isolation intent declaratively. - ISOLATION_HINT on wrong-code failures points at the most likely cause (routing headers / _meta / params shape short-circuit before dispatch). Forward-safe under either reading of the open SEP-2663 client-MUST → server-MUST-reject elevation question: the added Mcp-Name matches the body, so SEP-2243's universal mismatch rule can't fire either way. Verified: 18/18 tasks scenarios pass. * tasks: rename scenario check IDs to sep-2663-* slugs Aligns 13 tasks scenario check IDs with the SEP-2484 traceability convention used by sep-2322.yaml and others: the slug emitted at runtime matches the check: row in the SEP's requirement-traceability yaml, so plan.modelcontextprotocol.io can link the SEP requirement to its implementing scenario. Rename map: tasks-tools-call-without-extension-sync → sep-2663-server-rejects-undeclared-client tasks-methods-gated-without-extension → sep-2663-tasks-methods-non-declaring-32003 tasks-server-task-creation → sep-2663-result-type-task-on-create tasks-get-during-working → sep-2663-tasks-get-status-working tasks-get-terminal-inlined-result → sep-2663-tasks-get-status-completed tasks-tool-error-completed-iserror → sep-2663-tool-error-uses-completed-status tasks-protocol-error-failed-shape → sep-2663-tasks-get-status-failed tasks-cancel-empty-ack → sep-2663-cancel-ack-empty-result tasks-removed-tasks-result → sep-2663-tasks-result-removed-method-not-found tasks-legacy-task-param-ignored → sep-2663-legacy-task-param-ignored tasks-strong-consistency-immediate-get → sep-2663-durable-create-strong-consistency tasks-get-unknown-task-id-rejected → sep-2663-tasks-get-invalid-task-id-32602 tasks-mrtr-input-requests-on-tasks-get → sep-2663-tasks-get-status-input-required Wire behavior unchanged — only the reported slugs change. Verified 18/18 tasks scenarios still pass. Untouched (intentionally): scenario-flow gates like tasks-extension-advertised, tasks-sync-tool-call, tasks-wire-field-renames, tasks-immediate-result-shortcut etc., which test end-to-end flows rather than a specific RFC-2119 sentence and surface in the traceability manifest as untracked (same pattern as sep-2322.yaml). * seps: add sep-2663.yaml requirement-traceability declaration Declares the 37 conformance checks (and 12 excluded requirements) the SEP-2663 Tasks Extension surface should produce, mapping each normative MUST/SHOULD sentence on the SEP page to either a check ID emitted by an existing scenario or a documented reason for exclusion. Exclusions are all client-internal, host-internal, or architectural requirements that aren't observable at the protocol level — confirmed during the new-sep skill's exclusion round. The 13 scenario checks renamed in the previous commit now match the check: slugs declared here; the remaining ~12 yaml rows describe requirements without a scenario emitter yet — those are gaps to fill in follow-up work (notifications via subscriptions/listen, key uniqueness across MRTR rounds, legacy-capability migration probes). spec_url points at the SEP page itself (status: Final) — SEP-2663's normative content lives on /seps/2663-tasks-extension and is mirrored in modelcontextprotocol/experimental-ext-tasks for source-control purposes. Every quoted text: field was verified copy-paste findable against the rendered SEP page after stripping markdown formatting. * tasks: enforce SEP-2663 routing-header validation on tasks surface Two related fixes: 1. tasks/headers.ts description previously said servers "MUST NOT require" the Mcp-Method / Mcp-Name routing headers on the tasks surface and "MUST NOT change dispatch behavior based on them — including when the headers disagree with the body." Both claims are spec-incorrect. SEP-2663 author confirmed in the conformance review thread that the client-MUST in §"Streamable HTTP: Routing Headers" elevates Mcp-Name to "required standard header" status for tasks/get / tasks/update / tasks/cancel, so SEP-2243's §"Server Behavior" mismatch + missing-header rejection applies. Description rewritten to match the spec: routing headers are REQUIRED, server MUST reject missing or mismatched with -32001. 2. Add a negative-path check covering the most common failure mode on the tasks surface: tasks/get with mismatched Mcp-Name MUST return -32001 HeaderMismatch. Probes tasks/get specifically; tasks/update / tasks/cancel follow by extension. Missing-header case is deferred (requires raw-session enhancement to suppress a header entirely; current extraHeaders mechanism only overrides). 3. New yaml row pinning the rule to the SEP-2243 §"Server Behavior" sentence via row-level url override (the rule itself is SEP-2243; the scope of application on the tasks surface is what's distinctively SEP-2663). Known impact: surfaces a real gap in SDKs that derived their routing-header validation from the SEP-2243 base table only (which lists tasks/* as NOT requiring Mcp-Name). Those SDKs will see this check go FAILURE until they extend their validation to cover tasks-namespace methods per SEP-2663 §540. CI against everything-server is unaffected (skips the tasks suite entirely; everything-server doesn't implement the extension). * mrtr: activate mrtr-08 composition check + fold into sep-2663.yaml Folds PR 7 (panyam/mcpconformance) into PR 262: - Cherry-picks the mrtr-tasks-composition activation from feat/sep-2663-mrtr-08-composition (ad4f267 upstream): real end-to-end check driving the SEP-2663 commit 451f5e1 promotion flow (InputRequiredResult rounds gather user_name via elicitation/create, then CreateTaskResult on the final round escalates to async; the final tasks/get result reflects the answer gathered during MRTR). - Addresses LucaButBoring's PR 7 nit: replaces the inline SEP-2663 reference literal with the shared SEP_2663_REF constant (now also pointing at the rendered SEP page). - Renames the scenario's emitted check ID from `mrtr-tasks-composition` to `sep-2663-mrtr-synchronous-before-task-creation` to match the corresponding row in sep-2663.yaml — closes the gap between declared requirement and implementing check. - Updates mrtr/README.md: refreshes the Specs-covered table and the per-check description table for the new slug, and drops the "Open issues" section (both blockers are resolved — discriminator value aligned upstream; mcpkit middleware refactored per panyam/mcpkit#347). - Updates the sep-2663.yaml flow-gate comment block to note that the ephemeral mrtr-* flow gates also fall into the untracked category for the same reason as the tasks-* flow gates. One fewer skipped check on PR 262. Verified 123/123 server-suite tests pass against mcpkit's tasks-v2 + mrtr fixtures (post panyam/mcpkit#499 + #501). Closes panyam/mcpconformance#7. * tasks/mrtr: drop legacy wire when targeting DRAFT-2026-v1 SEP-2575 (Accepted) removes the initialize handshake on DRAFT-2026-v1 and replaces it with self-contained per-request _meta. Driving the legacy initialize+Mcp-Session-Id wire against a draft fixture is therefore a logical contradiction: the harness asserts `protocolVersion: "DRAFT-2026-v1"` while doing the handshake the version removed. Servers that strictly enforce SEP-2575 correctly reject these requests with -32602 (see Randgalt's report on conformance PR 262, 2026-05-31); lenient servers silently accept them and mask the spec gap. Both effects are wrong for the harness — we should only emit spec-permitted traffic. Adds `effectiveWireModes(protocolVersion)` in _shared/wire-mode.ts. It wraps `parseWireModes()` and filters `legacy` out when the target version is in POST_SEP_2575_VERSIONS (currently just DRAFT-2026-v1; widen when a future dated release picks SEP-2575 up). If the user explicitly pinned MCP_WIRE_MODES=legacy against a post-SEP-2575 version, the helper warns and falls back to ['stateless'] so the suite still runs. Both draft-only suites (tasks, mrtr) switch to `effectiveWireModes(DRAFT_PROTOCOL_VERSION)`. The MCP_WIRE_MODES override remains honored within the spec-permitted set. Verified end-to-end with a strict mcpkit fixture (panyam/mcpkit fix/sep-2575-enforce-meta-on-draft): - pre-fix harness → 7/18 fail with the spec-required error - post-fix harness → 9/9 pass (stateless wire only) * tasks/mrtr: derive wire from spec version, drop RunContext.wire `RunContext.wire` was a parallel knob to `specVersion` that the tasks and MRTR harnesses used to loop scenarios across both the legacy session wire and the SEP-2575 stateless wire. After SEP-2575 collapsed the legacy wire on DRAFT-2026-v1 (a6f7c27), the matrix shrank to a single column on draft — and an explicit `wire` field was both redundant and unsafe: it let callers go out of sync with the `specVersion` they declared on the wire. The CLI's `runServerConformanceTest` never set the field, so every `npm start -- server --scenario tasks-* --url ...` invocation silently emitted the legacy `initialize` handshake against a draft server, producing requests with no `_meta.io.modelcontextprotocol/*` envelope. Strict SEP-2575 servers correctly rejected the traffic; lenient ones accepted it and masked the gap. Changes: - Drop `wire?` from `RunContext`. - Add `isStateless(ctx)` in `_shared/wire-mode.ts`; scenarios derive `stateless` from `POST_SEP_2575_VERSIONS.has(ctx.specVersion)`. - Extend the CLI's spec-version inference to default extension scenarios to draft (today every extension scenario in the repo lives there). Without this, the inference stayed on `LATEST_SPEC_VERSION` for tasks scenarios and kept emitting legacy traffic. - Flatten the now-1-element wire loop in the tasks + mrtr harnesses; remove the parseWireModes/effectiveWireModes/MCP_WIRE_MODES infra that drove it (no remaining consumers). Adds a vitest regression in `runner/server.test.ts` that spins up an in-process recording HTTP server, drives `runServerConformanceTest` at `tasks-lifecycle`, and asserts the first outgoing call is `server/discover` carrying `_meta.io.modelcontextprotocol/*` — the SEP-2575 fingerprint — so the CLI wire selection can't silently regress. * scenarios + connection: address conformance#262 layout review Folds the parallel mini-framework the tasks/MRTR suites had grown into the existing harness shape pcarleton called out in conformance PR 262. No new spec coverage; existing scenarios still pass 241/241 unit tests and the regression added for the wire-derivation fix still pins the CLI fingerprint. Layout scenarios/server/_shared/ is gone. The helper bundle moves to scenarios/server/tasks-mrtr-helpers.ts (matching the existing input-required-result-helpers.ts precedent); raw-session.ts and its test fold into connection/stateless.ts. Per-suite runners + READMEs (tasks/all-scenarios.test.ts, mrtr/all-scenarios.test.ts, _shared/test-runner.ts, both READMEs) are removed. The scenarios are already registered in scenarios/index.ts pendingClientScenariosList, so the CLI runner (`npm start -- server --scenario tasks-lifecycle --url ...` or `--suite pending --url ...`) is the supported entry point. Per AGENTS.md: don't reimplement the runner. Connection extension Connection grows the three things tasks/MRTR scenarios actually need beyond the existing surface: - `request(method, params, extraHeaders?)` for the SEP-2243 header-mismatch tests. - `discover(): Promise<Record<string, unknown>>` for capability-negotiation scenarios. Resolved eagerly from the SDK accessors on the stateful wire; issued lazily as `server/discover` on the stateless wire and memoized. - `ConnectOptions { capabilities?, clientInfo? }` on RunContext.connect() so scenarios can negotiate extensions (`extensions: { 'io.modelcontextprotocol/tasks': {} }`) at bootstrap on either wire. Crucially the stateless wire does NOT eagerly emit `server/discover` when capabilities are declared. SEP-2575 says per-request `_meta.io.modelcontextprotocol/clientCapabilities` is authoritative; a server that gates extensions on a prior `server/discover` is non-conformant, and the conformance suite's job is to surface that, not paper over it. Scenario migration Every tasks/MRTR scenario switches from `initRawSession(...)` to `await ctx.connect({ capabilities })`, and the capability scenario switches from `session.initializeResult.capabilities` to `await withExt.discover()`. The wire choice now flows entirely from `specVersion` — `isStateless(ctx)` (moved to connection/select.ts next to `connectFor`) is the single source of truth. Description sweep Each scenario's `description` string now spells out the required server fixtures (tool names + behavior) directly. Previously that content lived only in the file-header comment, so an implementer reading the runner's emitted description was missing it. Reviewer notes - 23 files changed, +455 / -291 (net + because the Connection abstraction now carries surface that used to live in raw-session; the raw-session deletion was 692 lines on its own). - Unit tests: 241 pass (the 12 deleted tests were redundant with connection-level coverage; the genuinely-unique SSE multi-line `data:` test pinned behavior the connection parser doesn't have). - Reference fixture runs: tasks scenarios surface a fixture-side spec gap (per-request `_meta` not honored without a prior `server/discover`). That's the suite working; the gap will be filed upstream against the fixture, not papered over here. * tasks: dedupe MRTR ephemeral-flow against #188, fold composition into tasks/ Of the 8 checks in mrtr/ephemeral-flow.ts, 7 substantially overlapped the SEP-2322 InputRequiredResult* scenarios that #188 already shipped (basic-elicitation, sampling, list-roots, request-state, multiple-input- requests, multi-round, wrong-input-key). Only sep-2663-mrtr-synchronous- before-task-creation — the SEP-2663 commit 451f5e1 composition path where the MRTR loop's final round returns a CreateTaskResult — is genuinely new versus #188's coverage, and it's SEP-2663-specific, not SEP-2322-specific. Moves that one check to its own tasks scenario in tasks/composition.ts (extensionId = io.modelcontextprotocol/tasks, registered alongside the other tasks-* scenarios in scenarios/index.ts) and deletes the entire mrtr/ directory. Reuses the existing #188 helpers (isInputRequiredResult, mockElicitResponse from input-required-result-helpers.ts) rather than reintroducing the parallel mrtr/helpers.ts ones. * address review on conformance#262 follow-up - connectStateless.discover() now reuses the request() pipeline (send + drainEvents + unwrap) instead of inlining a duplicate copy. The local memoization stays via discoverPromise ??=. - runner/server.test.ts mock server no longer silently swallows unparseable bodies. They land in a parseFailures sidecar that the test asserts is empty — a non-JSON body to this mock is exactly the kind of regression the test exists to catch. - Drop the tautological connectFor identity tests. They asserted `typeof === 'function'` and `not.toBe(connectStateful)`, both of which hold trivially once connectFor wraps in a closure. Wire-shape behavior is already covered by the connectStateless mock-fetch suite and by runner/server.test.ts. * tasks/mrtr: address PR 262 audit before review Pre-emptive cleanup against the upstream patterns established by recent merges (#318 Connection abstraction, #319 draft-spec consistency, #303 SEP-2243 check IDs, #321 MockServer surface). No behavior change for scenarios — every check is still emitted; only the shape of how they're emitted moves toward upstream's house style. Surface - Revert withRequestMeta / sendStatelessRequest signatures to match upstream byte-for-byte. The need that pushed me to widen those (per-request _meta.clientCapabilities for extension declaration on the SEP-2575 stateless wire) is now handled inside connectStateless: ConnectOptions.capabilities / clientInfo are folded into params._meta via the trailing _meta spread that withRequestMeta already supports. This keeps the new "extension declaration" concept on the Connection layer only — the lower-level helpers stay shared with every other scenario in the suite. - Document that Connection.request's extraHeaders throws on the stateful wire instead of "ignored with a warning" — the SDK transport manages headers internally, so silent drop in a conformance harness would mask test correctness. Layout - src/scenarios/server/tasks-mrtr-helpers.ts moves under tasks/ as tasks/mrtr-helpers.ts. All 10 importers live in tasks/; the file's prior placement next to input-required-result-helpers.ts no longer applies once those scenarios moved into tasks/. - Drop the unused AnyResult export + zod import. The Connection wrapper doesn't take a zod schema, so the helper was dead on arrival. - SEP-2322 spec reference now re-exports MRTR_SPEC_REFERENCES[0] from input-required-result-helpers so the codebase holds one URL per SEP instead of two for SEP-2322. Naming - All 10 tasks/*.ts switch to `readonly source = { ... } as const` to match the convention used by caching.ts, resources.ts, http-standard-headers.ts. Drops the ScenarioSource import where now unused. - Scenario-scope each scenario's connect-failure check ID (tasks-lifecycle-bootstrap, tasks-headers-bootstrap, …) instead of sharing tasks-session-bootstrap. Matches the upstream caching.ts pattern (sep-2549-caching-connection) where each scenario owns its own bootstrap-failure ID. Traceability - sep-2663.yaml gains the positive-path counterpart to the existing rejection requirement under Streamable HTTP: Routing Headers, so every sep-2663-* check ID emitted by tasks/*.ts is declared in the manifest. Verification - Conformance vitest: 270/270. - Build: clean. - mcpkit testconf-tasks-v2 (47 fork scenarios + 1 sentinel): pass. - mcpkit testconf-mrtr (3 fork scenarios + 1 sentinel): pass. * tasks/required-task-error: anchor -32003 checks to sep-2663.yaml rows The two checks in required-task-error.ts each verified a distinct normative requirement that sep-2663.yaml already declared, but the check IDs (tasks-required-task-error-code, tasks-required-task-error-data-shape) used the tasks-* flow-gate prefix and went untracked by the traceability manifest. Promote both check IDs to sep-2663-*: - tasks-required-task-error-code → sep-2663-server-returns-32003-when-required - tasks-required-task-error-data-shape → sep-2663-server-returns-32003-data-shape Split the existing YAML entry to mirror the split. The spec sentence glues two observable requirements with a trailing colon (the JSON-RPC code, and the data-shape payload that names the missing extension); each gets its own row so each is verifiable independently. Remove the now-stale tasks-required-task-error-code reference from the sep-2663.yaml flow-gate comment block — that check is no longer a flow-gate. No behavior change. 270/270 vitest still pass. * runner: forward ConnectOptions from RunContext.connect server.ts built the RunContext with `connect: () => connectFor(...)(serverUrl)`, dropping the opts argument scenarios pass to ctx.connect(). TypeScript doesn't catch this because `() => T` is assignable to `(opts?) => T`. Result: scenarios declaring capabilities sent DEFAULT_CLIENT_CAPABILITIES on the wire in CLI runs while testContext() forwarded opts correctly, so unit tests passed but `npm start -- server --scenario tasks-*` against a strict-draft server failed with missing-capability errors. Tightens the regression test to assert scenario-passed capabilities reach _meta.clientCapabilities.extensions, not just that defaults are present. * tasks: align scenario descriptions and cancel-ack check with SEP-2663 capability.ts: description said non-declaring tasks/* requests get -32601 MethodNotFound; the assertion checks MissingRequiredClientCapability per SEP-2663 §Error Handling. Reworded to the named error type and per-request _meta/server-discover framing (SEP-2575). lifecycle.ts: description claimed cancel-on-terminal MUST return -32602; Check 8 in the same file asserts an idempotent ack and the SEP has no -32602 carve-out for terminal tasks. Removed the bullet. lifecycle.ts Check 7: SEP-2663 §Task Cancellation says transition to `cancelled` is not guaranteed; stop failing the ack-shape check on the settled status and record it in details instead. * connection: fix stale docstring and drop redundant eta-expansion stateless.ts docstring referenced `connection.initializeResult`; the implementation exposes `discover()`. select.ts wrapped connectStateful in an identity arrow; pass it directly now that signatures match. --------- Co-authored-by: Paul Carleton <paulc@anthropic.com>
1 parent fe3a310 commit d70d7ad

25 files changed

Lines changed: 3647 additions & 65 deletions

src/connection/connection.test.ts

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,9 @@
11
import { describe, it, expect, vi, afterEach } from 'vitest';
2-
import {
3-
connectFor,
4-
isStatefulVersion,
5-
STATELESS_SPEC_VERSIONS
6-
} from './select';
7-
import { connectStateful } from './stateful';
2+
import { isStatefulVersion, STATELESS_SPEC_VERSIONS } from './select';
83
import { connectStateless } from './stateless';
94
import { JsonRpcError } from './index';
105
import { DRAFT_PROTOCOL_VERSION } from '../types';
116

12-
describe('connectFor', () => {
13-
it('returns stateful for dated 2025-x versions', () => {
14-
expect(connectFor('2025-03-26')).toBe(connectStateful);
15-
expect(connectFor('2025-06-18')).toBe(connectStateful);
16-
expect(connectFor('2025-11-25')).toBe(connectStateful);
17-
});
18-
it('returns stateless for the draft version', () => {
19-
// connectFor wraps connectStateless in a closure (to pass the spec
20-
// version through), so identity with connectStateless no longer holds;
21-
// assert it did not select the stateful implementation. The wire-level
22-
// behaviour of the wrapper is covered in stateless.test.ts.
23-
expect(connectFor('2026-07-28')).not.toBe(connectStateful);
24-
expect(connectFor('2026-07-28')).not.toBe(connectStateless);
25-
});
26-
});
27-
287
describe('STATELESS_SPEC_VERSIONS', () => {
298
it('contains exactly the versions isStatefulVersion rejects', () => {
309
expect(STATELESS_SPEC_VERSIONS.length).toBeGreaterThan(0);

src/connection/index.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,40 @@
1515
import type { SpecVersion } from '../types';
1616
import type { JSONRPCNotification } from '../spec-types/2025-11-25';
1717

18+
/**
19+
* Options accepted at session bootstrap. On the stateful (2025-x) wire
20+
* these flow into the `initialize` request params; on the stateless
21+
* (2026-x) wire they live in `_meta.io.modelcontextprotocol/*` on the
22+
* `server/discover` request.
23+
*/
24+
export interface ConnectOptions {
25+
/**
26+
* Capabilities declared during session bootstrap (e.g.
27+
* `{ extensions: { 'io.modelcontextprotocol/tasks': {} }, elicitation: {} }`).
28+
*/
29+
capabilities?: Record<string, unknown>;
30+
/** Client info advertised at bootstrap; defaults to the harness's own info. */
31+
clientInfo?: { name: string; version: string };
32+
}
33+
1834
export interface Connection {
1935
/**
2036
* Send a JSON-RPC request and return its result.
2137
* Throws `JsonRpcError` on JSON-RPC error responses.
38+
*
39+
* `extraHeaders` extend or override the standard headers
40+
* (Content-Type, Accept, MCP-Protocol-Version, Mcp-Method, Mcp-Name)
41+
* for this call only, used by SEP-2243 routing-header tests that
42+
* inject a mismatch. Honored on the stateless wire; throws on the
43+
* stateful wire (the SDK transport manages headers internally, so
44+
* a per-call override would require dropping to raw fetch — silently
45+
* dropping the header in a conformance harness would mask test
46+
* correctness).
2247
*/
2348
request<R = unknown>(
2449
method: string,
25-
params?: Record<string, unknown>
50+
params?: Record<string, unknown>,
51+
extraHeaders?: Record<string, string>
2652
): Promise<R>;
2753

2854
/**
@@ -33,6 +59,20 @@ export interface Connection {
3359
*/
3460
readonly notifications: JSONRPCNotification[];
3561

62+
/**
63+
* Return the server's advertised capabilities, serverInfo, and
64+
* instructions. On the stateful wire this is synthesized from the
65+
* SDK Client's post-`initialize` accessors and resolves immediately;
66+
* on the stateless wire this issues `server/discover` (SEP-2575's
67+
* equivalent of the missing handshake) on first call and memoizes
68+
* the result.
69+
*
70+
* Scenarios that don't inspect server-side state never call this —
71+
* SEP-2575 has no required handshake, so paying for the extra request
72+
* is opt-in.
73+
*/
74+
discover(): Promise<Record<string, unknown>>;
75+
3676
close(): Promise<void>;
3777
}
3878

@@ -48,7 +88,7 @@ export interface RunContext {
4888
* Scenarios that test the connection mechanics themselves (initialize,
4989
* GET-SSE, DNS rebinding) bypass this and use raw fetch.
5090
*/
51-
connect(): Promise<Connection>;
91+
connect(opts?: ConnectOptions): Promise<Connection>;
5292
}
5393

5494
export class JsonRpcError extends Error {
@@ -75,4 +115,4 @@ export {
75115
type JsonRpcResponse,
76116
type StatelessResponse
77117
} from './stateless';
78-
export { connectFor } from './select';
118+
export { connectFor, isStateless } from './select';

src/connection/sdk-client.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,36 @@ import {
99
ProgressNotificationSchema
1010
} from '@modelcontextprotocol/sdk/types.js';
1111

12+
import type { ConnectOptions } from './index';
13+
14+
const DEFAULT_CLIENT_INFO = {
15+
name: 'conformance-test-client',
16+
version: '1.0.0'
17+
} as const;
18+
19+
const DEFAULT_CAPABILITIES = {
20+
sampling: {},
21+
elicitation: {}
22+
} as const;
23+
1224
export interface MCPClientConnection {
1325
client: Client;
1426
close: () => Promise<void>;
1527
}
1628

1729
/**
18-
* Create and connect an MCP client to a server
30+
* Create and connect an MCP client to a server. `opts.capabilities` and
31+
* `opts.clientInfo` override the harness defaults — scenarios that
32+
* negotiate extensions (tasks, EMA, ...) pass them through to drive a
33+
* conformant `initialize`.
1934
*/
2035
export async function connectToServer(
21-
serverUrl: string
36+
serverUrl: string,
37+
opts: ConnectOptions = {}
2238
): Promise<MCPClientConnection> {
23-
const client = new Client(
24-
{
25-
name: 'conformance-test-client',
26-
version: '1.0.0'
27-
},
28-
{
29-
capabilities: {
30-
// Client capabilities
31-
sampling: {},
32-
elicitation: {}
33-
}
34-
}
35-
);
39+
const client = new Client(opts.clientInfo ?? DEFAULT_CLIENT_INFO, {
40+
capabilities: opts.capabilities ?? DEFAULT_CAPABILITIES
41+
});
3642

3743
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
3844

src/connection/select.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import {
33
DRAFT_PROTOCOL_VERSION,
44
type SpecVersion
55
} from '../types';
6-
import type { Connection } from './index';
6+
import type { Connection, ConnectOptions, RunContext } from './index';
77
import { connectStateful } from './stateful';
88
import { connectStateless } from './stateless';
99

1010
/**
1111
* Spec versions that use the stateful lifecycle (initialize handshake,
12-
* Mcp-Session-Id). Anything not in this list uses the stateless lifecycle.
12+
* Mcp-Session-Id). Anything not in this list uses the stateless lifecycle
13+
* — SEP-2575 (Accepted) removed the initialize handshake on 2026-07-28
14+
* and later.
1315
*/
1416
const STATEFUL_VERSIONS: ReadonlySet<string> = new Set([
1517
'2024-11-05',
@@ -40,10 +42,22 @@ export const STATELESS_SPEC_VERSIONS: readonly SpecVersion[] =
4042

4143
export function connectFor(
4244
specVersion: SpecVersion
43-
): (serverUrl: string) => Promise<Connection> {
45+
): (serverUrl: string, opts?: ConnectOptions) => Promise<Connection> {
4446
return isStatefulVersion(specVersion)
4547
? connectStateful
4648
: // Pass the version through so stateless requests declare the spec
4749
// version the run was invoked with (matters under --force).
48-
(serverUrl) => connectStateless(serverUrl, specVersion);
50+
(serverUrl, opts) => connectStateless(serverUrl, specVersion, opts);
51+
}
52+
53+
/**
54+
* True when the spec version on the context requires the SEP-2575
55+
* stateless wire (no initialize handshake; per-request `_meta` envelope).
56+
*
57+
* Mirrors `connectFor` so scenarios that drive the wire directly (not via
58+
* the SDK-wrapped Connection) pick the wire the same way the connection
59+
* factory does.
60+
*/
61+
export function isStateless(ctx: Pick<RunContext, 'specVersion'>): boolean {
62+
return !isStatefulVersion(ctx.specVersion);
4963
}

src/connection/stateful.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@ import {
1515
} from '@modelcontextprotocol/sdk/types.js';
1616
import { connectToServer } from './sdk-client';
1717
import type { JSONRPCNotification } from '../spec-types/2025-11-25';
18-
import { JsonRpcError, type Connection } from './index';
18+
import { JsonRpcError, type Connection, type ConnectOptions } from './index';
1919

20-
export async function connectStateful(serverUrl: string): Promise<Connection> {
21-
const { client, close } = await connectToServer(serverUrl);
20+
export async function connectStateful(
21+
serverUrl: string,
22+
opts: ConnectOptions = {}
23+
): Promise<Connection> {
24+
const { client, close } = await connectToServer(serverUrl, opts);
2225

2326
const notifications: JSONRPCNotification[] = [];
2427
const collect = (n: unknown) => {
@@ -45,10 +48,30 @@ export async function connectStateful(serverUrl: string): Promise<Connection> {
4548
return {
4649
notifications,
4750

51+
// Synthesize the discover-shape from the SDK Client's post-`initialize`
52+
// accessors so the stateful Connection exposes the same surface the
53+
// stateless wire's `server/discover` produces.
54+
async discover(): Promise<Record<string, unknown>> {
55+
return {
56+
capabilities: client.getServerCapabilities() ?? {},
57+
serverInfo: client.getServerVersion() ?? {},
58+
instructions: client.getInstructions()
59+
};
60+
},
61+
4862
async request<R>(
4963
method: string,
50-
params: Record<string, unknown> = {}
64+
params: Record<string, unknown> = {},
65+
extraHeaders?: Record<string, string>
5166
): Promise<R> {
67+
if (extraHeaders && Object.keys(extraHeaders).length > 0) {
68+
// The SDK Client transport manages headers internally; per-call
69+
// override would require dropping to raw fetch. No 2025-x
70+
// scenario needs this today; flag loudly if one shows up.
71+
throw new Error(
72+
'connectStateful.request: extraHeaders is unsupported on the stateful wire (per-call header overrides require raw fetch on the stateless wire only)'
73+
);
74+
}
5275
try {
5376
return (await client.request({ method, params }, ResultSchema)) as R;
5477
} catch (e) {

src/connection/stateless.ts

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
import { DRAFT_PROTOCOL_VERSION, type SpecVersion } from '../types';
2424
import type { JSONRPCNotification } from '../spec-types/2025-11-25';
25-
import { JsonRpcError, type Connection } from './index';
25+
import { JsonRpcError, type Connection, type ConnectOptions } from './index';
2626

2727
export interface JsonRpcResponse {
2828
jsonrpc: '2.0';
@@ -58,7 +58,9 @@ let nextRequestId = 1;
5858

5959
/**
6060
* The `Mcp-Name` source field per SEP-2243: `params.name` for tools/call and
61-
* prompts/get, `params.uri` for resources/read; absent otherwise.
61+
* prompts/get, `params.uri` for resources/read, `params.taskId` for the
62+
* SEP-2663 tasks methods (`tasks/get`, `tasks/update`, `tasks/cancel`).
63+
* Absent otherwise.
6264
*/
6365
export function mcpNameForRequest(
6466
method: string,
@@ -70,6 +72,13 @@ export function mcpNameForRequest(
7072
if (method === 'resources/read') {
7173
return typeof params?.uri === 'string' ? params.uri : undefined;
7274
}
75+
if (
76+
method === 'tasks/get' ||
77+
method === 'tasks/update' ||
78+
method === 'tasks/cancel'
79+
) {
80+
return typeof params?.taskId === 'string' ? params.taskId : undefined;
81+
}
7382
return undefined;
7483
}
7584

@@ -298,21 +307,52 @@ export async function sendStatelessRequest(
298307
* `sendStatelessRequest()`: classifies SSE-stream events into the notification
299308
* sink, surfaces server→client *requests* on the response stream as a spec
300309
* violation, and throws `JsonRpcError` on error responses.
310+
*
311+
* Session bootstrap on the stateless wire is `server/discover` (SEP-2575's
312+
* replacement for `initialize`). The result is exposed via
313+
* `connection.discover()` so scenarios can inspect server capabilities,
314+
* serverInfo, and supported protocol versions.
301315
*/
302316
export async function connectStateless(
303317
serverUrl: string,
304-
specVersion: SpecVersion = DRAFT_PROTOCOL_VERSION
318+
specVersion: SpecVersion = DRAFT_PROTOCOL_VERSION,
319+
opts: ConnectOptions = {}
305320
): Promise<Connection> {
306321
const notifications: JSONRPCNotification[] = [];
322+
const capabilities = opts.capabilities ?? DEFAULT_CLIENT_CAPABILITIES;
323+
const clientInfo = opts.clientInfo ?? CONFORMANCE_CLIENT_INFO;
307324

308-
async function request<R>(
309-
method: string,
325+
// The Connection layer is the single place that knows about
326+
// connect-time capabilities / clientInfo. We fold them into the
327+
// request's `_meta` here (the trailing `params._meta` spread in
328+
// `withRequestMeta` lets us override its defaults) so that
329+
// `sendStatelessRequest` and `withRequestMeta` keep their upstream
330+
// signatures untouched.
331+
function withConnectMeta(
310332
params?: Record<string, unknown>
311-
): Promise<R> {
312-
const response = await sendStatelessRequest(serverUrl, method, params, {
313-
specVersion
333+
): Record<string, unknown> {
334+
return {
335+
...params,
336+
_meta: {
337+
'io.modelcontextprotocol/clientCapabilities': capabilities,
338+
'io.modelcontextprotocol/clientInfo': clientInfo,
339+
...(params?._meta as Record<string, unknown> | undefined)
340+
}
341+
};
342+
}
343+
344+
async function send(
345+
method: string,
346+
params?: Record<string, unknown>,
347+
extraHeaders?: Record<string, string>
348+
): Promise<StatelessResponse> {
349+
return sendStatelessRequest(serverUrl, method, withConnectMeta(params), {
350+
specVersion,
351+
headers: extraHeaders
314352
});
353+
}
315354

355+
function drainEvents(response: StatelessResponse): void {
316356
for (const event of response.events ?? []) {
317357
if (typeof event !== 'object' || event === null) continue;
318358
if ('method' in event && !('id' in event)) {
@@ -324,7 +364,9 @@ export async function connectStateless(
324364
);
325365
}
326366
}
367+
}
327368

369+
function unwrap<R>(method: string, response: StatelessResponse): R {
328370
const rpcError = response.body?.error;
329371
// Only a properly-shaped JSON-RPC error becomes a JsonRpcError; anything
330372
// else (e.g. a proxy's `{"error": "upstream timeout"}`) falls through so
@@ -350,8 +392,31 @@ export async function connectStateless(
350392
return response.body.result as R;
351393
}
352394

395+
// SEP-2575 has no required handshake. `_meta.clientCapabilities` on
396+
// every request is authoritative per spec, so `server/discover` is
397+
// strictly a client-side query ("what does the server advertise?").
398+
// We deliberately do NOT run it eagerly — a server that ignores
399+
// per-request `_meta` until a prior `server/discover` has registered
400+
// the session is non-conformant, and the conformance suite's job is
401+
// to surface that, not paper over it.
402+
let discoverPromise: Promise<Record<string, unknown>> | undefined;
403+
404+
async function request<R>(
405+
method: string,
406+
params?: Record<string, unknown>,
407+
extraHeaders?: Record<string, string>
408+
): Promise<R> {
409+
const response = await send(method, params, extraHeaders);
410+
drainEvents(response);
411+
return unwrap<R>(method, response);
412+
}
413+
353414
return {
354415
notifications,
416+
discover(): Promise<Record<string, unknown>> {
417+
return (discoverPromise ??=
418+
request<Record<string, unknown>>('server/discover'));
419+
},
355420
request,
356421
close: async () => {}
357422
};

0 commit comments

Comments
 (0)