Skip to content

feat(http): serve both protocol eras on /mcp + the 2026-07-28 conformance lane (#1025 step 2, PR 4/5) - #1117

Merged
debugmcpdev merged 10 commits into
mainfrom
feat/1025-http-2026
Nov 11, 2026
Merged

debugmcpdev merged 10 commits into
mainfrom
feat/1025-http-2026

Conversation

@debugmcpdev

@debugmcpdev debugmcpdev commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

Updated 2026-11-10 (second look: commits 996baf1e, 9e0d009c). "The 2026-07-28 conformance lane" and its tests below describe a hand-built judge (summary parser, YAML reader, checks.json reconciliation) that is deleted: the suite now judges its own baseline. The baseline has 31 single-check entries, not 29 scenario entries, and one scenario it used to pass was a real divergence, now fixed. See "Second look" at the end.

Step 2 of #1025, PR 4 of 5 (stacked on #1116; base retargets as the stack merges). The http endpoint 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. handleMcpRequest reads the session-id header as before (the pinned line the self-debug suites find by text), then asks isLegacyRequest (@modelcontextprotocol/server) which era the request speaks:

  • A request carrying the 2026 per-request envelope — or a modern MCP-Protocol-Version header — goes to createMcpHandler(factory, { legacy: 'reject' }) behind toNodeHandler: one createProtocolServer(core, { era, deliver: 'external' }) facade per request over the one shared core, sessionless, no Mcp-Session-Id. server/discover answers the supported versions, capabilities and instructions; results carry resultType and the cache fields; a missing Mcp-Method/Mcp-Name/MCP-Protocol-Version is 400 -32020; an unsupported claimed revision is 400 -32022 with data.supported; a modern header without the envelope is 400 -32602.
  • Everything 2025-shaped — 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.ts and http-host-allowlist.test.ts pass unchanged.

Notifications reach modern clients. NotificationHub gains handlerSink(modern.notify): the coalesced resources/updated pings and resources/list_changed flow onto open subscriptions/listen streams (the SDK filters by the URIs each listen named). The deliver: '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/listen is counted as a listen stream until its response closes; the idle policy's attended() predicate (PR 2) reads legacy SSE streams and modern listen streams alike. /health gains listenStreams and requests: { 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-Id and echoes a preflight's requested headers (covers Mcp-Param-*). The same 10 MiB body limit reaches express.json, toWebRequest, isLegacyRequest, createMcpHandler and toNodeHandler.

resources/templates/list is 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-28 runs @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 local http server and judges the run under scripts/conformance/expected-failures-2026-07-28.yaml: 25 whole scenarios and 4 server-stateless checks, 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 the checks.json it 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 job mcp-conformance-2026 (ubuntu), required in test-summary beside mcp-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-free input-required-result-* scenarios. The 13 tasks-*/pending scenarios are not scored by the revision and are reported as skip.

Tests

  • tests/integration/cli/http-spec-contract-2026.test.ts (new, raw HTTP against dist/index.js http): discover with a proper envelope → 200, supportedVersions ⊇ ['2026-07-28'], no session-id header, resultType, _meta serverInfo; tools/list → ttlMs/cacheScope, identical order twice; a modern POST with a stale Mcp-Session-Id → 200; header-without-envelope → 400 -32602; missing Mcp-Method → 400 -32020; claimed 2099-01-01 → 400 -32022 with supported; initialize on the same endpoint → 200 + session id; GET without session id → 400 -32600 unchanged.
  • tests/integration/cli/http-era-negotiation.test.ts (new, v2 Client): auto → modern / 2026-07-28 / 29 tools; { pin: '2026-07-28' } drives a full mock-adapter session and client.listen({ resourceSubscriptions }) receives resources/updated; legacy → 2025-11-25 and subscribeResource still 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; /health shape; shutdown order).
  • tests/unit/scripts/mcp-conformance.test.ts: the requirements summary parser (CRLF, not-scored block), the baseline reader, collectChecks over the CLI's real folder name, and the judge (unlisted failure, stale entry, missing checks.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

  • typecheck:all 0, lint clean, check-docs clean, changelog:check ok
  • unit: 416 files / 8281 tests
  • integration cli + self-debug: 11 files / 57 tests
  • e2e mcp-server-smoke-http 3/3 (incl. the modern-pinned run)
  • conformance 0.1.16: 7/7; conformance 0.2.0-alpha.12 --era 2026-07-28: 37 scored scenarios conform under the baseline
  • pre-push hook: unit 416 files / 8285 tests, integration 36 files / 177 tests

Docs

README ("Over the network", both eras, /health fields), getting-started transport table, CLAUDE.md, docs/architecture/testing-architecture.md (two contract files, two lanes, the baseline). Fragments 1025-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.ts and tests/unit/cli/http-command.test.ts:

  1. The judge never reconciled the summary with checks.json. A scenario whose summary line counted four failures while its checks.json named 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 its checks.json names, or the lane fails naming both numbers.
  2. The summary parser dropped scenario names outside [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.
  3. Modern-era attendance was overstated in the README. A 2026-07-28 client attends the process only while it holds a subscriptions/listen stream, 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-flight wait_for_stop) alone, 30 min idle on http. README and the changelog fragment now say exactly that. Whether Claude Code's v2 runtime opens a listen stream against an http server is unmeasured; PR 5 measures it (its dev proxy opens one listen to 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: toNodeHandler now receives the same 10 MiB maxRequestBodySize as 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 with NO_COLOR=1/FORCE_COLOR=0 so a coloured summary cannot break the parser; the script header carries the measured note on the alpha's tools-list; 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, as docs/architecture/testing-architecture.md now says; the changelog fragment's header-rule and "bodies included" claims are corrected; /health.requests is 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_ARGUMENT refuses a space in %TEMP% (dev-only); the handler sink's disposer is discarded (harmless). Ruled, not implemented: the plan's per-request Client identified log line on the modern http leg — /health.requests is 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-failures already 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.mjs drops 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-found was a real divergence of ours: the error for an unknown resource carried no data. It now names the requested uri. Fixed, not baselined (the scenario went from 3 passed + 1 warning to 4 passed).
  • Two input-required scenarios cannot run without the reference tools and join the baseline.

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-valid among them, so my header's "a real divergence can never hide in it" was stronger than the mechanism. 9e0d009c makes every entry scenario: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 http leg: 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 an http client 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 main only, so a stacked PR gets no automatic run and gh pr checks shows nothing): https://github.com/debugmcp/mcp-debugger/actions/runs/42879527314 (head 9e0d009c, 12 of 12 jobs green, both conformance lanes among them)

🤖 Generated with Claude Code

cynarlab and others added 2 commits November 9, 2026 22:08
…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

codecov Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.72131% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/cli/http-command.ts 96.07% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

cynarlab and others added 5 commits November 10, 2026 11:43
# 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 debugmcpdev left a comment

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.

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.

Base automatically changed from feat/1025-stdio-2026 to main November 11, 2026 11:11
Retain dependency updates and the idle-policy logging fix alongside the reviewed HTTP changes and 2026 conformance lane.

@debugmcpdev debugmcpdev left a comment

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.

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 debugmcpdev left a comment

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.

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.

@debugmcpdev
debugmcpdev merged commit 8b80c52 into main Nov 11, 2026
16 checks passed
@debugmcpdev
debugmcpdev deleted the feat/1025-http-2026 branch November 11, 2026 11:44
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.

2 participants