Skip to content

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
jdevalk:mainfrom
hsw:fix/mcp-modern-result-type-2026-08-25
Open

fix(mcp): 2026-07-28 clients get zero tools — results omit resultType and the cache hints#192
hsw wants to merge 4 commits into
jdevalk:mainfrom
hsw:fix/mcp-modern-result-type-2026-08-25

Conversation

@hsw

@hsw hsw commented Aug 26, 2026

Copy link
Copy Markdown

What this changes

mcp.specification.website serves zero tools to any client on revision 2026-07-28. That revision makes resultType required on the base Result type, and ttlMs + cacheScope required on ListToolsResult and ListPromptsResult. The Worker emits none of them, so clients reject every result while initialize still looks healthy. Reported in #186, which found half of it.

The official conformance suite against production today:

[wire-schema-valid] FAILURE ListToolsResult: must have required property 'cacheScope';
                            must have required property 'resultType';
                            must have required property 'ttlMs'

The fix is asModernResult() at the era boundary in handleMcp(), plus a MODERN_CACHE_HINTS record 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, so server/discover passes through unchanged. initialize is excluded — which is what basic/versioning prescribes: "A dual-era server selects its behavior from how the client opens... An initialize request selects legacy semantics." The batch path is deliberately untouched.

Decisions worth reviewing

Revision 2026-07-28 also removed ping and logging/setLevel; neither appears in its ClientRequest union. 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 EmptyResult survives at this revision, so a stamped modern ping would have validated fine. What settles it is requirements/2026-07-28.yaml: server-stateless is the first entry in the scored server: list, its not_scored set 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.

initialize and notifications/initialized were removed too and are deliberately not rejected. For initialize that is not a judgement call: basic/versioning says a dual-era server answers it and serves legacy semantics, and the compatibility matrix lists Legacy-client / Dual-era-server as "Works. The server answers initialize." 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 separate SHOULD about what to put in an initialize error, 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.md scopes /changelog/ to what the spec says, and no spec page changes — the Worker has merely started doing what mcp-and-tool-discovery already describes. #183 shipped the same way. Say the word if you want one.

Tests, docs and CI

Tests. From your Verification section in #133:

I did not wire this harness into the repo — it needs an esbuild devDependency and a CI job, which felt like scope creep on a protocol change. Happy to land it separately; a protocol handler with no tests is the obvious place for one.

The esbuild devDependency turns out not to be needed: Node's own type stripping plus a 21-line ts-resolve-hook.mjs runs the TypeScript sources directly. The hook needs Node 22.15, above the root's declared >= 22.12. No engines field, 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.md says so.

62 tests, node:test, no new dependencies, matrix in test-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: a tools/call row asserts the result's shape and that content is there, not that search ranked 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 to test-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's mcp/ row and Commands section, and a new section in mcp/README.md, record the suite and the job. One more line in CLAUDE.md: its Deployment bullet said ci.yml only runs type-check and build verification, which this PR falsifies — it now says ci.yml verifies 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. The build job is untouched. Separate so a Worker failure reads distinctly, and typecheck had never run in CI for mcp/ 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 in CLAUDE.md — is inside that same commit, deliberately, so it goes with it. It is deliberately not in .githooks/pre-commit either, which fires on every commit and would need that package's dependency tree.

Verification

Conformance (@modelcontextprotocol/conformance@0.2.0-alpha.11), main and 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 — seven wire-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
                   total  SUCCESS  FAILURE  SKIPPED  WARNING  INFO
  before-2026       177     75       90       7        4       1   production / main
  mainlocal-2026    177     75       90       7        4       1   localhost   / main
  after-2026        177     88       77       7        4       1   localhost   / branch
  before-2025        68     35       26       0        2       5   production / main
  mainlocal-2025     68     36       25       0        2       5   localhost   / main
  after-2025         68     36       25       0        2       5   localhost   / branch

before-2026 and mainlocal-2026 are identical, which is what says the host change alone moves nothing at that revision. The one SUCCESS mainlocal-2025 gains is localhost-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 main Claude Code reports Failed to fetch commands and Failed 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 /mcp panel reads Protocol: 2026-07-28, Capabilities: tools · prompts, 6 tools, and the tools answer with real data: 10 categories, canonical-url as recommended in foundations, 8 hits for a Content-Security-Policy search — 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 (id server-discover-probe-1) and declaring 2026-07-28 in both the MCP-Protocol-Version header and params._meta, never sending initialize. Block server/discover and the same client falls back within one request: initialize at 2025-11-25, notifications/initialized, then tools/list returning a bare {tools} with no resultType and 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 check pass, and mcp's typecheck + npm test pass 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 / Origin go unvalidated; a server/discover with malformed _meta answers 200 rather than 400 / -32602, which is six scored checks; a literal null body is a 500; id: null is treated as a notification. Happy to open issues.

Drift this change does not cause, so I left it: mcp/README.md elsewhere still says MCP 2025-03-26, says search covers 96 spec pages (it is 168), and omits get_changes from its table. Happy to send that as its own PR.

A schema-drift guard is designed and ready — MODERN_CACHE_HINTS is 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

  • Caching"Servers MUST include caching hints on results with resultType: "complete" returned by the following operations: server/discover, tools/list, prompts/list, …", and ttlMs MUST be >= 0. The requirement is on the operation, not just the schema's required list.
  • Tools — every result example carries resultType, and the tools/list example carries ttlMs and cacheScope alongside it.
  • Versioning and Compatibility — the dual-era terminology, the era-selection rule, and the compatibility matrix.
  • schema/2026-07-28/schema.json — the required arrays, and the absence of PingRequest, SetLevelRequest and InitializeRequest. The allowed resultType values live only in schema.ts.

Checklist

  • Sources cited on every changed page.
  • Status is honest — n/a, no spec page changes.
  • Platform-agnostic — n/a, no spec page changes.
  • npm run build passes locally.
  • No accidental dependencies added — mcp/package.json gains two scripts and nothing else; package-lock.json is untouched.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com

hsw and others added 4 commits August 26, 2026 02:47
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>
Copilot AI lite review requested due to automatic review settings August 26, 2026 06:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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 modern resultType and method-scoped cache hints (ttlMs, cacheScope) without altering legacy wire responses.
  • Add a node:test harness that drives the Worker’s real fetch() in-process, with a TS resolve hook so tests can import the Worker’s TypeScript sources directly.
  • Add an mcp job to CI to build the generated data manifest, type-check, and run tests for the mcp/ 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.

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