Repository navigation
feat(http): serve both protocol eras on /mcp + the 2026-07-28 conformance lane (#1025 step 2, PR 4/5) - #1117
Conversation
…onformance lane (#1025) The http endpoint now speaks MCP protocol revision 2026-07-28 beside 2025-11-25 and earlier. handleMcpRequest reads the session-id header as before, then asks the SDK's isLegacyRequest which era the request speaks: a request carrying the 2026 per-request envelope (or a modern MCP-Protocol-Version header) goes to createMcpHandler(..., { legacy: 'reject' }) behind toNodeHandler, one deliver: 'external' facade per request over the shared core, sessionless; everything 2025-shaped lands on the sessionful app verbatim, 400/404 bodies included. NotificationHub gains handlerSink(modern.notify) so the coalesced resources/updated pings and resources/list_changed reach open subscriptions/listen streams; a listen stream counts the process as attended for the idle policy, like a legacy SSE stream. /health reports listenStreams and the requests served per era. CORS allows the 2026 headers and echoes a preflight's requested headers. Shutdown closes the modern handler, then the legacy transports, then the core. The same 10 MiB body limit reaches every SDK entry the request passes through. resources/templates/list is answered (an empty list) in both eras: the new lane found that a server advertising the resources capability must answer it, and we answered "Method not found". The 2026-07-28 conformance lane: `scripts/mcp-conformance.mjs --era 2026-07-28` runs the suite's 0.2 line (0.2.0-alpha.12, the one the SDKs run) with --requirements 2026-07-28 against a local http server and judges it under scripts/conformance/expected-failures-2026-07-28.yaml, the scenarios and checks that drive the suite's own reference fixtures. The verdict is read from the CLI's per-scenario summary lines and the checks.json it writes per scenario (server-<scenario>-<ISO stamp>/), never its exit code; an unlisted failure fails the lane and so does a listed entry that passes. CI job mcp-conformance-2026, required in test-summary. Measured: 37 scored scenarios conform under the baseline; caching 7/7 (6/7 before the templates fix); the 0.1.16 lane unchanged at 7/7. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… read every verdict line, and state the modern-era attendance rule (#1025) Three findings from the review of PR #1117. The judge never reconciled the CLI's per-scenario summary with the checks.json rows: a scenario counted at four failures whose checks.json named two, both in the baseline, passed the lane — and the unit fixture pinned that as desired. A scenario's failed count must now equal the FAILURE rows its checks.json names, or the lane fails naming both. The summary parser matched scenario names against [A-Za-z0-9-] and dropped anything else silently, so a scenario the suite adds with an underscore or a dot would fail on every run and never be judged. The name is now any non-blank, non-colon run; a verdict line in the summary block the parser cannot read is itself a failure; the header's announced scenario count must match the lines reported; a baseline entry naming a scenario the revision does not score is flagged stale. The CLI runs with NO_COLOR=1/FORCE_COLOR=0 so a coloured summary cannot break the parser. The README said nothing expires while any client holds its SSE stream; a 2026-07-28 client attends only while it holds a subscriptions/listen stream, and a 2026 client that only calls tools keeps its sessions alive by tool activity alone. README and the changelog fragment now say so. Also: toNodeHandler receives the same 10 MiB maxRequestBodySize as the other SDK entries (pinned); the 2026 contract test kills its server child when the health wait fails; both new integration files take the tool count from TOOL_NAMES; `pnpm test:conformance:2026` runs the new lane; the script header carries the measured note on the alpha's tools-list; the fragment's header-rule wording and the /health.requests description are corrected. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
# Conflicts: # CLAUDE.md # README.md # docs/getting-started.md
…gence it found, and answer refused bodies honestly (#1025) A second look at two rulings made while building step 2, on JF's review. The 2026-07-28 lane judged the run itself: a summary parser, a hand-written YAML reader, a checks.json reader and a reconciliation between them, because "the CLI's exit code is unreliable on Windows". The exit abort is a Node bug (nodejs/node#56645, fixed in 24.20.0 and 26.7.0), not the suite's, and CI runs Linux. The suite already does this job: --expected-failures reads the baseline with a real parser, takes per-check entries, and fails on an unlisted failure or warning and on a stale entry. The lane now hands it the baseline and takes its exit code; on a Windows Node that aborts at exit, and only then, it reads the suite's own closing line. The custom judge and its tests are deleted (the script drops from 396 to 274 lines). The hand-built judge was also weaker than the suite's in two ways, which the first suite-judged run showed. It matched check names where the suite matches check ids, and it counted only failures where the suite's baseline also counts warnings. Three warning-only scenarios had read as passes: - sep-2164-resource-not-found was a real SHOULD-level divergence of ours: the error for an unknown resource carried no data. It now names the requested uri. Fixed, not baselined. - two input-required scenarios cannot run without the reference tools; they join the baseline. The baseline is rewritten as what it is: 31 scenarios and checks that cannot be tested against a server without the suite's reference fixtures (18) or without an optional feature this server does not declare (13), each with the fixture or feature beside it, every name read from the suite's source (seven of the old comments named a fixture wrongly). None is a divergence; by the suite's policy a missing prerequisite is a failure, not a skip. Measured on alpha.12: "Baseline check passed: all failures are expected", exit 0; sep-2164-resource-not-found 4 passed, no warning. Also on the http leg: - A body too large to classify answers 413, as the JSON parser already does for an oversized JSON body, where it answered 500 "Internal error" and wrote an error log. - A request the SDK's modern handler refuses is logged as a warning; only a fault of the handler is an error. A scanner posting {} wrote error lines. - The modern handler's notification sink is detached from the hub at shutdown, before the handler closes. - A test pins that an http client is named once in the log however many per-request facades serve it. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…scenario; log the SDK's typed refusals as warnings (#1025) Review of the second-look pass (fresh context, range bdf402e..84d1bad). The baseline listed 27 whole scenarios. A whole-scenario entry excuses every check in the scenario, so the suite's wire-schema-valid check — every message this server sent, its error answers included, is valid per the spec's JSON schema — was excused in all of them, and the file's claim that a real divergence could not hide in it was stronger than the mechanism. Every entry is now scenario:check-id, the one check that cannot run without the fixture or feature, with the ids read from a measured run's checks.json. Same 31 entries; the other checks of those scenarios are enforced again. Measured on alpha.12: "Baseline check passed", exit 0. The header says what the mechanism does not cover (an entry for a scenario the suite does not run is ignored). The http leg logged two classes of refusal at error level after all: an unsupported protocol revision (-32022) and a missing client capability reach the hook as typed protocol errors, with no "Rejected" in front for the prefix match to find. A protocol error other than an internal one is the caller's doing; it is a warning now, and an internal error stays an error. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
debugmcpdev
left a comment
There was a problem hiding this comment.
Reviewed HTTP era routing, body limits, notification forwarding, stream attendance, shutdown and the conformance runner/baseline. No blocking findings in this PR's changes. Original head 9e0d009 passed all 12 CI jobs, including both conformance lanes. The reviewed idle-expiry correction from #1115 is included via the stack. Full CI/current-main checks will be verified before merging after #1116.
Retain dependency updates and the idle-policy logging fix alongside the reviewed HTTP changes and 2026 conformance lane.
debugmcpdev
left a comment
There was a problem hiding this comment.
Integration review after #1116: merged current main cf5163b into this branch, retaining all dependency updates and the idle-policy security fix. Independently checked the overlapping workflow/package files: the new 2026 conformance lane uses the updated pnpm action, all existing action pins and package versions are retained, and the lockfile is unchanged. The resulting 30-file diff matches the reviewed HTTP/conformance change. Fresh checks, including the new conformance lane, must pass before merging.
Assert that sink detachment, legacy transport closure, core shutdown and listener cleanup still complete when the modern handler rejects close.
debugmcpdev
left a comment
There was a problem hiding this comment.
Coverage follow-up: all tests passed, but patch coverage was 95.08% against a 95.52% target. Added one focused shutdown regression proving that a rejected modern-handler close still detaches the sink, closes legacy transports, stops the core, clears sessions, and closes the listener. All 70 HTTP unit tests and type checks pass; the previously uncovered recovery line is now executed. A temporary mutation disabling recovery made the new test fail, and production source was restored unchanged. Exact change: 28 added test lines. Fresh CI and coverage are running.
Step 2 of #1025, PR 4 of 5 (stacked on #1116; base retargets as the stack merges). The
httpendpoint now serves MCP protocol revision 2026-07-28 beside 2025-11-25 and earlier, and a second conformance lane runs the protocol org's 2026-07-28 requirements under an expected-failures baseline.What changes
One
/mcp, two eras, routed by the SDK's own classifier.handleMcpRequestreads the session-id header as before (the pinned line the self-debug suites find by text), then asksisLegacyRequest(@modelcontextprotocol/server) which era the request speaks:MCP-Protocol-Versionheader — goes tocreateMcpHandler(factory, { legacy: 'reject' })behindtoNodeHandler: onecreateProtocolServer(core, { era, deliver: 'external' })facade per request over the one shared core, sessionless, noMcp-Session-Id.server/discoveranswers the supported versions, capabilities and instructions; results carryresultTypeand the cache fields; a missingMcp-Method/Mcp-Name/MCP-Protocol-Versionis 400-32020; an unsupported claimed revision is 400-32022withdata.supported; a modern header without the envelope is 400-32602.initialize, a request with a session id, GET/DELETE, a claim-less POST — lands on the sessionful app verbatim, 400/404 bodies included.http-spec-contract.test.tsandhttp-host-allowlist.test.tspass unchanged.Notifications reach modern clients.
NotificationHubgainshandlerSink(modern.notify): the coalescedresources/updatedpings andresources/list_changedflow onto opensubscriptions/listenstreams (the SDK filters by the URIs each listen named). Thedeliver: 'external'facade registers no sink, so no per-request instance ever logs a dropped notification.Attendance counts modern clients. A POST whose body is
subscriptions/listenis counted as a listen stream until its response closes; the idle policy'sattended()predicate (PR 2) reads legacy SSE streams and modern listen streams alike./healthgainslistenStreamsandrequests: { legacy, modern }. Shutdown closes the modern handler first, then the legacy transports, then the core.CORS allows
Mcp-Protocol-Version,Mcp-Method,Mcp-Name,Last-Event-Idand echoes a preflight's requested headers (coversMcp-Param-*). The same 10 MiB body limit reachesexpress.json,toWebRequest,isLegacyRequest,createMcpHandlerandtoNodeHandler.resources/templates/listis answered (empty list) in both eras. Found by the new lane: a server advertising the resources capability must answer it; we answered "Method not found".The 2026-07-28 conformance lane
node scripts/mcp-conformance.mjs --era 2026-07-28runs@modelcontextprotocol/conformance@0.2.0-alpha.12(the 0.2 line the SDKs run; exact-pinned) with--requirements 2026-07-28 --output-dir <tmp>against a localhttpserver and judges the run underscripts/conformance/expected-failures-2026-07-28.yaml: 25 whole scenarios and 4server-statelesschecks, every one a reference fixture this server does not have (the "everything server"test_*tools,test://resources, prompts), each with its fixture named beside it. The verdict is read from the CLI's per-scenario summary lines and thechecks.jsonit writes per scenario, never from its exit code (Windows libuv abort). An unlisted failure fails the lane; so does a listed entry that passes, so the baseline only shrinks. CI jobmcp-conformance-2026(ubuntu), required intest-summarybesidemcp-conformance, which is unchanged (0.1.16, 7/7).Measured against this branch: 37 scored scenarios conform under the baseline; passing outright are
tools-list,server-sse-multiple-streams,resources-list,sep-2164-resource-not-found,prompts-list,dns-rebinding-protection,caching(7/7 after the templates fix; 6/7 before) and the four fixture-freeinput-required-result-*scenarios. The 13tasks-*/pending scenarios are not scored by the revision and are reported asskip.Tests
tests/integration/cli/http-spec-contract-2026.test.ts(new, raw HTTP againstdist/index.js http): discover with a proper envelope → 200,supportedVersions ⊇ ['2026-07-28'], no session-id header,resultType,_metaserverInfo;tools/list→ttlMs/cacheScope, identical order twice; a modern POST with a staleMcp-Session-Id→ 200; header-without-envelope → 400-32602; missingMcp-Method→ 400-32020; claimed2099-01-01→ 400-32022withsupported;initializeon the same endpoint → 200 + session id; GET without session id → 400-32600unchanged.tests/integration/cli/http-era-negotiation.test.ts(new, v2Client):auto→ modern / 2026-07-28 / 29 tools;{ pin: '2026-07-28' }drives a full mock-adapter session andclient.listen({ resourceSubscriptions })receivesresources/updated;legacy→ 2025-11-25 andsubscribeResourcestill works.tests/unit/cli/http-command.test.ts: the router split (a modern-classified request never reaches the session branch; counters; listen-stream counting; CORS list;/healthshape; shutdown order).tests/unit/scripts/mcp-conformance.test.ts: the requirements summary parser (CRLF, not-scored block), the baseline reader,collectChecksover the CLI's real folder name, and the judge (unlisted failure, stale entry, missingchecks.json).tests/e2e/mcp-server-smoke-http.test.ts: a modern-pinned Python run beside the legacy one.server-resources.test.ts/protocol-facade.test.ts/server-initialization.test.ts: the ninth handler.Verification
mcp-server-smoke-http3/3 (incl. the modern-pinned run)--era 2026-07-28: 37 scored scenarios conform under the baselineDocs
README ("Over the network", both eras,
/healthfields), getting-started transport table, CLAUDE.md,docs/architecture/testing-architecture.md(two contract files, two lanes, the baseline). Fragments1025-protocol-2026-http.added.md,1025-conformance-2026.added.md.After this PR merges, #1025 can be closed (PR 5, the dev proxy on both eras, is dogfood tooling).
Review fix pass (commit 2)
A fresh-context review of this PR found no Critical and three Important findings, all fixed here RED→GREEN in
tests/unit/scripts/mcp-conformance.test.tsandtests/unit/cli/http-command.test.ts:checks.json. A scenario whose summary line counted four failures while itschecks.jsonnamed two (both in the baseline) passed the lane — and the unit fixture pinned that hole as desired. Now a scenario's failed count must equal the FAILURE rows itschecks.jsonnames, or the lane fails naming both numbers.[A-Za-z0-9-]silently. A scenario the suite adds with an underscore or a dot would have failed on every run and never been judged. The name pattern is now any non-blank, non-colon run; a✓/✗line in the summary block the parser cannot read is itself a failure; and the header's announced scenario count must match the lines reported.subscriptions/listenstream, like a 2025 session's SSE stream; a 2026 client that only calls tools is not attending, and its sessions live by tool activity (and an in-flightwait_for_stop) alone, 30 min idle onhttp. README and the changelog fragment now say exactly that. Whether Claude Code's v2 runtime opens a listen stream against anhttpserver is unmeasured; PR 5 measures it (its dev proxy opens onelistento the backend regardless, and runs the backend idle-off).Also fixed, as plan requirements this PR had missed or wrong claims in its own files:
toNodeHandlernow receives the same 10 MiBmaxRequestBodySizeas the other four SDK entries (pinned in the unit suite); a baseline entry naming a scenario the revision does not score is flagged stale; the conformance CLI runs withNO_COLOR=1/FORCE_COLOR=0so a coloured summary cannot break the parser; the script header carries the measured note on the alpha'stools-list; the 2026 contract test kills its server child when the health wait fails; both new integration files take the tool count fromTOOL_NAMES;pnpm test:conformance:2026runs the new lane, asdocs/architecture/testing-architecture.mdnow says; the changelog fragment's header-rule and "bodies included" claims are corrected;/health.requestsis described as requests classified per era (refused ones included).Deferred minors (ledgered): a non-JSON POST above 10 MiB answers 500 where the transport used to answer 415; a claim-less POST whose body is not a JSON-RPC message now lands on the modern leg's 400
-32600(same status, different body, counted modern);SAFE_ARGUMENTrefuses a space in%TEMP%(dev-only); the handler sink's disposer is discarded (harmless). Ruled, not implemented: the plan's per-requestClient identifiedlog line on the modern http leg —/health.requestsis the server-side measure.Second look (commits
996baf1e,9e0d009c)The suite judges its own baseline. I had built a judge of my own because "the CLI's exit code is unreliable on Windows". That abort is a Node bug (nodejs/node#56645, fixed in 24.20.0 and 26.7.0), not the suite's, and CI runs Linux. The suite's
--expected-failuresalready reads the baseline with a real YAML parser, takes per-check entries, and fails on an unlisted failure or warning and on a stale entry. The lane now passes it the baseline and takes its exit code; only on a Windows Node that aborts at exit does it read the suite's own closing line.scripts/mcp-conformance.mjsdrops from 396 to 274 lines.My judge was weaker than the suite's, and the first suite-judged run showed it. It matched check names where the suite matches ids, and counted failures only where the suite's baseline also counts warnings. Three warning-only scenarios had read as passes:
sep-2164-resource-not-foundwas a real divergence of ours: the error for an unknown resource carried nodata. It now names the requesteduri. Fixed, not baselined (the scenario went from 3 passed + 1 warning to 4 passed).The baseline is what it says it is. 31 checks that cannot be tested against a server without the suite's reference fixtures (18) or an optional feature this server does not declare (13), each with the fixture or feature beside it, every name read from the suite's source (seven of the old comments were wrong). None is a divergence: by the suite's own policy a missing prerequisite is a failure, not a skip, and a baseline is its named mechanism for that. Upstream discussion: modelcontextprotocol/conformance#516.
Review of this pass. The reviewer pointed out that a whole-scenario entry excuses every check in the scenario, the suite's
wire-schema-validamong them, so my header's "a real divergence can never hide in it" was stronger than the mechanism.9e0d009cmakes every entryscenario:check-id— the one check that cannot run — so the schema validation of every message this server sends is enforced again in all 27 of those scenarios. It also fixes the other finding here: an unsupported protocol revision (-32022) and a missing client capability reach the handler's error hook as typed protocol errors, which the prefix match missed, so they were still logged at error level.Measured on alpha.12 with the per-check baseline:
Baseline check passed: all failures are expected., exit 0; the 2025-11-25 lane 7/7 unchanged.On the
httpleg: a body too large to classify answers 413 like any oversized body (it answered 500 "Internal error" with an error log); a request the SDK's handler refuses is logged as a warning, a fault as an error (a scanner posting{}wrote error lines); the modern handler's notification sink is detached at shutdown; and a test pins that anhttpclient is named once in the log however many per-request facades serve it.Verified:
typecheck:all, lint, check-docs, changelog; pre-push hook unit 418 files / 8777 tests, integration 36 files / 177 tests.CI on this branch head (dispatched by hand: the workflow triggers on PRs to
mainonly, so a stacked PR gets no automatic run andgh pr checksshows nothing): https://github.com/debugmcp/mcp-debugger/actions/runs/42879527314 (head9e0d009c, 12 of 12 jobs green, both conformance lanes among them)🤖 Generated with Claude Code