fix(mcp): 2026-07-28 clients get zero tools — results omit resultType and the cache hints - #192
Open
hsw wants to merge 4 commits into
Open
fix(mcp): 2026-07-28 clients get zero tools — results omit resultType and the cache hints#192hsw wants to merge 4 commits into
hsw wants to merge 4 commits into
Conversation
The endpoint serves zero tools to any client on 2026-07-28. That revision makes `resultType` required on the base `Result` type, and `ttlMs` + `cacheScope` required on ListToolsResult and ListPromptsResult. The Worker emitted none of them, so a conforming client rejected every result while `initialize` still looked healthy. Reported in jdevalk#186. asModernResult() applies them at the era boundary in handleMcp(), keyed by method through MODERN_CACHE_HINTS. handleRpc() stays era-agnostic, so a method added later is covered by default and no legacy response is touched. Whatever a branch already set wins, so server/discover passes through unchanged. `initialize` is excluded — it has no InitializeResult to conform to, because 2026-07-28 has no handshake. The gate is a supported protocol version, not the mere presence of the `_meta` key. validateModernRequest() returns early for a message with no usable id, so an id-less request is never validated at all — only its version is checked. 2026-07-28 also removed `ping` and `logging/setLevel`. Stamping either reports success for a method the era does not define, so the modern era now answers -32601; the legacy era answers both exactly as before. MODERN_PROTOCOL_VERSIONS is exported so the suite can pin it. Editing that array opts a revision into the era boundary's 2026-07-28 facts, so the test added in the next commit fails until they are checked against the new one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Per jdevalk#133: "a protocol handler with no tests is the obvious place for one." The esbuild devDependency that PR worried about is not needed. Node's own type stripping plus a 21-line resolve hook runs the TypeScript sources directly. 62 tests on node:test, driving the real fetch handler in-process. The method x era matrix lives in test-lib/cases.mjs, so a new protocol method is a row there — plus a case in mcpNameFor() if it mirrors a body value into Mcp-Name. package.json gains two scripts and nothing else; the lockfile is untouched. Each assertion was checked by breaking what it guards and confirming the suite goes red. Several early drafts did not: presence checks passed with an empty payload, the legacy column missed members it did not name, and the whole modern validation layer could be deleted with CI green. The legacy column now pins the exact key list in order, payloads are asserted non-empty, and cache hints are asserted absent on every row that should not carry them. One test guards the Worker's own constant rather than a copy of it: any edit to MODERN_PROTOCOL_VERSIONS fails with a message naming the three facts to check. A comment there would have been skippable. The hook needs Node 22.15 for module.registerHooks. Below that its named import fails at link time and names the missing export, so there is no version check and no engines field — the floor belongs to one script, not to the package. README states it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLAUDE.md's mcp/ row now names the test suite, and the Commands section gains the package's own scripts. Records the one non-obvious trap: typecheck fails with TS2307 on a fresh clone until build:data has run, because src/index.ts imports the generated, gitignored src/data.json. Also records why the suite is deliberately not in the pre-commit hook. The Deployment bullet said ci.yml "only runs type-check + build verification", which was already short of what it ran. It now says ci.yml verifies rather than listing jobs, so the next one added will not stale it again. mcp/README.md gains the test commands and the Node >= 22.15 floor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A proposal, and deliberately the last commit: adding a gate to this repo's CI is the maintainer's call, so drop this one and everything before it still stands. Nothing else in the branch references the job. mcp/ is a separate package with its own lockfile, so the build job never touched it and its type-check had never run in CI at all. A second job keeps a Worker failure reading distinctly from a site-build failure. The build job is unchanged. build:data is an explicit step: src/index.ts imports the generated, gitignored src/data.json, so tsc --noEmit fails with TS2307 on a clean checkout. pretest would regenerate it, but relying on that makes step order quietly load-bearing. Not added to .githooks/pre-commit: that fires on every commit, almost none of which touch mcp/, and it would need that package's separate dependency tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The new CI job references actions/checkout@v7, which is not a valid major version and will break workflow execution.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes the MCP Worker’s modern (2026-07-28) response shape so modern clients no longer reject tools/list / prompts/list responses, and adds a dedicated, dependency-free node:test suite plus CI coverage for the mcp/ package.
Changes:
- Add an era-boundary normaliser (
asModernResult) to stamp modernresultTypeand method-scoped cache hints (ttlMs,cacheScope) without altering legacy wire responses. - Add a
node:testharness that drives the Worker’s realfetch()in-process, with a TS resolve hook so tests can import the Worker’s TypeScript sources directly. - Add an
mcpjob to CI to build the generated data manifest, type-check, and run tests for themcp/subpackage.
File summaries
| File | Description |
|---|---|
mcp/src/index.ts |
Stamps modern results with resultType + cache hints at the era boundary; rejects removed methods in modern era; keeps legacy responses unchanged. |
mcp/test/result-shape.test.mjs |
Verifies modern required fields and pins legacy byte-identity key shape across methods and edge cases. |
mcp/test/cache-hints.test.mjs |
Ensures cache hints appear only where required in modern era and never leak into legacy. |
mcp/test/era-gate.test.mjs |
Pins modern-era transport rejection rules and supported modern protocol versions. |
mcp/test/harness.test.mjs |
Sanity-checks that the harness exercises the real Worker paths in both eras. |
mcp/test-lib/harness.mjs |
In-process request driver for legacy/modern message shapes and header mirroring. |
mcp/test-lib/cases.mjs |
Central method × era test case table and modern transport rejection matrix. |
mcp/ts-resolve-hook.mjs |
Node registerHooks resolver to append .ts for local extensionless imports in tests. |
mcp/package.json |
Adds pretest + test scripts to run node:test with strip-types + resolver hook. |
mcp/README.md |
Documents how to run tests/typecheck and the node/version requirement rationale. |
CLAUDE.md |
Updates repo guidance to mention the mcp/ test suite and CI job at a high level. |
.github/workflows/ci.yml |
Adds a separate mcp CI job to install, build manifest, type-check, and run tests. |
Review details
- Files reviewed: 12/12 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
mcp.specification.websiteserves zero tools to any client on revision 2026-07-28. That revision makesresultTyperequired on the baseResulttype, andttlMs+cacheScoperequired onListToolsResultandListPromptsResult. The Worker emits none of them, so clients reject every result whileinitializestill looks healthy. Reported in #186, which found half of it.The official conformance suite against production today:
The fix is
asModernResult()at the era boundary inhandleMcp(), plus aMODERN_CACHE_HINTSrecord keyed by method. 51 lines.handleRpc()stays era-agnostic, so a method added later is covered without anyone remembering to, and legacy responses never see the stamp. Whatever a branch already set wins, soserver/discoverpasses through unchanged.initializeis excluded — which is whatbasic/versioningprescribes: "A dual-era server selects its behavior from how the client opens... Aninitializerequest selects legacy semantics." The batch path is deliberately untouched.Decisions worth reviewing
Revision 2026-07-28 also removed
pingandlogging/setLevel; neither appears in itsClientRequestunion. The modern era now answers-32601/ HTTP 404 for both. Legacy is untouched.These two are rejected to satisfy scored conformance checks, not because the era's semantics demand it — the distinction matters if you are weighing it. Nothing forbids implementing a removed method: the check's own wording is conditional ("if the server does not implement the removed RPC method, it MUST respond with 404 and -32601"), and
EmptyResultsurvives at this revision, so a stamped modernpingwould have validated fine. What settles it isrequirements/2026-07-28.yaml:server-statelessis the first entry in the scoredserver:list, itsnot_scoredset contains nothing about removed methods, and the check probes five of them by name. A conditional MUST that no check can condition on is operationally unconditional. Both checks are now green; revert the two lines if you read that file differently.initializeandnotifications/initializedwere removed too and are deliberately not rejected. Forinitializethat is not a judgement call:basic/versioningsays a dual-era server answers it and serves legacy semantics, and the compatibility matrix lists Legacy-client / Dual-era-server as "Works. The server answersinitialize." For the notification, JSON-RPC forbids replying at all, so there is nothing to reject with.The price is
sep-2575-http-server-method-not-found-404-initialize, a scored check this leaves red. I read that check as testing modern-only servers — the same page gives modern-only servers their own separateSHOULDabout what to put in aninitializeerror, which would be redundant if 404 were required of everyone. Worth your eye, since it is the one scored check we knowingly fail.Also: no changelog entry.
CLAUDE.mdscopes/changelog/to what the spec says, and no spec page changes — the Worker has merely started doing whatmcp-and-tool-discoveryalready describes. #183 shipped the same way. Say the word if you want one.Tests, docs and CI
Tests. From your Verification section in #133:
The esbuild devDependency turns out not to be needed: Node's own type stripping plus a 21-line
ts-resolve-hook.mjsruns the TypeScript sources directly. The hook needs Node 22.15, above the root's declared>= 22.12. Noenginesfield, because the floor belongs to one script rather than a package, and below 22.15 the named import already fails at link time naming the missing export.mcp/README.mdsays so.62 tests,
node:test, no new dependencies, matrix intest-lib/cases.mjs. Each was checked by breaking the thing it guards and confirming the suite goes red — several early drafts did not. The suite pins the wire contract, not what the tools compute: atools/callrow asserts the result's shape and thatcontentis there, not thatsearchranked anything. Those rows are hand-maintained per tool and per branch — nothing derives how many return sites a tool has, so a tool that grows one needs a row added with it. It is a second testing idiom next totest-websub.mjs's hand-rolled runner; I kept the zero-dependency constraint and not the shape. Happy to port it if you would rather have one.Docs.
CLAUDE.md'smcp/row and Commands section, and a new section inmcp/README.md, record the suite and the job. One more line inCLAUDE.md: its Deployment bullet saidci.ymlonly runs type-check and build verification, which this PR falsifies — it now saysci.ymlverifies rather than listing jobs, so the next one does not stale it again.CI — a proposal, and the last commit, so you can drop it on its own. A second job,
mcp:npm ci,build:data,typecheck,test. Thebuildjob is untouched. Separate so a Worker failure reads distinctly, andtypecheckhad never run in CI formcp/either. Adding a gate to your CI is your call, not mine — if you would rather run the suite by hand, or not spend the minutes, drop the last commit and everything before it still stands. The one line elsewhere that names the job — a sentence inCLAUDE.md— is inside that same commit, deliberately, so it goes with it. It is deliberately not in.githooks/pre-commiteither, which fires on every commit and would need that package's dependency tree.Verification
Conformance (
@modelcontextprotocol/conformance@0.2.0-alpha.11),mainand this branch on the same host so nothing is a wrangler-versus-edge artefact. At 2026-07-28: 13 checks flip FAILURE → SUCCESS and the diff has no line in the other direction — sevenwire-schema-valid, four SEP-2549 caching, two SEP-2575. At 2025-11-25 the same pair diffs empty across all 68 checks.Six runs, totals
before-2026andmainlocal-2026are identical, which is what says the host change alone moves nothing at that revision. The one SUCCESSmainlocal-2025gains islocalhost-host-valid-accepted— a host effect, visible as one precisely because the code is the same on both sides. The 2025 pair has identical md5 and both sides are non-empty, from the same normaliser that shows the 2026 pair differing.Of the 77 still failing at 2026-07-28: 37 are not scored for this revision, 26 call the suite's own fixture tools by name, and the rest is surface this server does not implement.
Legacy byte-identity is the claim I most wanted to be sure of. Both Workers are loaded into one process and driven through 26 legacy exchanges, compared on raw response text and status so key order is inside the claim rather than normalised away: zero differences. The same script then drives both through a modern
tools/list, where the patch must change the bytes, and asserts it does — without that control, "zero differences" would be indistinguishable from a harness that compares nothing.Real client, same config both ways. At
mainClaude Code reportsFailed to fetch commandsandFailed to fetch tools: Invalid result for tools/list: missing required resultType — servers implementing protocol revision 2026-07-28 MUST include it— it loses the prompt as well as the tools. On this branch its/mcppanel readsProtocol: 2026-07-28,Capabilities: tools · prompts,6 tools, and the tools answer with real data: 10 categories,canonical-urlasrecommendedinfoundations, 8 hits for aContent-Security-Policysearch — each checked against the repository.The legacy era is exercised by that client too, not just by the harness. Putting a logging proxy in front of the Worker shows Claude Code 2.1.246 opening with
server/discover(idserver-discover-probe-1) and declaring2026-07-28in both theMCP-Protocol-Versionheader andparams._meta, never sendinginitialize. Blockserver/discoverand the same client falls back within one request:initializeat 2025-11-25,notifications/initialized, thentools/listreturning a bare{tools}with noresultTypeand no cache hints — and it still resolves all six tools. Your dual-era design does what it says on both paths.npm run build,lint,format:check,check:skill,astro checkpass, andmcp'stypecheck+npm testpass on a clean clone under Node 22.Not in this PR
Pre-existing, none a result-shape problem: OWS is not trimmed off
Mcp-Method/Mcp-Name(the edge masks it in production);Host/Origingo unvalidated; aserver/discoverwith malformed_metaanswers 200 rather than 400 /-32602, which is six scored checks; a literalnullbody is a 500;id: nullis treated as a notification. Happy to open issues.Drift this change does not cause, so I left it:
mcp/README.mdelsewhere still says MCP 2025-03-26, sayssearchcovers 96 spec pages (it is 168), and omitsget_changesfrom its table. Happy to send that as its own PR.A schema-drift guard is designed and ready —
MODERN_CACHE_HINTSis a small version of the trap this fix closes, and nothing in CI would catch a new cacheable result type. Offering it separately, for the same reason you cut your harness out of #133.Sources
resultType: "complete"returned by the following operations:server/discover,tools/list,prompts/list, …", andttlMsMUST be>= 0. The requirement is on the operation, not just the schema'srequiredlist.resultType, and thetools/listexample carriesttlMsandcacheScopealongside it.dual-eraterminology, the era-selection rule, and the compatibility matrix.schema/2026-07-28/schema.json— therequiredarrays, and the absence ofPingRequest,SetLevelRequestandInitializeRequest. The allowedresultTypevalues live only inschema.ts.Checklist
npm run buildpasses locally.mcp/package.jsongains two scripts and nothing else;package-lock.jsonis untouched.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com