Scenario 15: the migration doctor
The MCP spec ships in dated revisions, and a new one occasionally changes a contract you already depend on. One such change lands in the 2026-07-28 release candidate: the JSON-RPC error code a server must return for a resources/read of a uri it does not have moves from the legacy MCP-custom -32002 to the standard -32602 (Invalid Params). A missing resource is a bad parameter, not a transport fault, so the new target uses the standard code.
If your server still answers -32002, it is conformant against an older target and out of conformance against 2026-07-28. This page walks through three moves: observe the legacy code on the hosted test server, write a test that pins the standard code (and watch it fail against the legacy endpoint), then run doctor and migrate to plan the move. The hosted test server at test.mcptest.sh exposes both behaviors so you can reproduce the whole flow without standing up a server.
See the legacy code
The hosted test server serves the conformant behavior at its default endpoint and the pre-2026-07-28 behavior under ?scenario=legacy. Read a uri that does not exist on each, and compare the error code.
The legacy endpoint returns the old -32002:
curl -s -X POST 'https://test.mcptest.sh/mcp?scenario=legacy' \
-d '{"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"items://999"}}'
{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"resource not found: items://999"}}
The conformant endpoint returns the standard -32602 for the same request:
curl -s -X POST 'https://test.mcptest.sh/mcp' \
-d '{"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"items://999"}}'
{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"invalid params: unknown resource items://999"}}
Same request, two different error codes. That is the contract change in one line. The next step pins it as a test so the difference is caught automatically rather than by eye.
The YAML (assert the standard code)
A tool or compliance test can pin the contract by asserting the error code on a resources/read of a missing uri. The error envelope a server returns is wrapped under result, so the assertion target is result.error.code and the matcher is an exact integer. Pin the standard -32602, which is the code the 2026-07-28 target requires.
Save this as tests/migration.yml:
# yaml-language-server: $schema=https://mcptest.sh/schema/v1.json
servers:
legacy:
url: "https://test.mcptest.sh/mcp?scenario=legacy"
conformant:
url: "https://test.mcptest.sh/mcp"
resources:
- name: "missing resource returns standard -32602 (legacy)"
server: legacy
uri: "items://999"
expect:
- target: "result.error.code"
matcher:
exact: -32602
- name: "missing resource returns standard -32602 (conformant)"
server: conformant
uri: "items://999"
expect:
- target: "result.error.code"
matcher:
exact: -32602
Both tests assert the same thing: a resources/read of items://999 must come back with error code -32602. The conformant server satisfies that. The legacy server does not, because it still answers -32002, so that test fails. The failing line is the signal that this server has not made the move.
Run the suite:
mcptest run tests/migration.yml
Run the doctor
mcptest doctor probes a target and flags non-conformant behavior against a spec revision you name with --target-version. Pair --url with --target-version 2026-07-28 to run the migration probe: it adds a one-shot initialize probe after the regular doctor pipeline and prints one row per breaking change from the migration pair-corpus.
mcptest doctor --url https://test.mcptest.sh/mcp?scenario=legacy \
--target-version 2026-07-28
Be precise about doctor's scope in this release. The migration probe detects the deprecated capabilities (Roots, Sampling, Logging) live; the other categories, including the missing-resource error code, surface as [SKIP] with a one-line rationale and a follow-up reference. The live tools/list probe behind the default doctor report is also still pending, so doctor reports that the token check is wired but not yet runnable. A [FAIL] row gates CI with exit 1.
For the error-code change specifically, two surfaces cover it today. The offline scan is mcptest lint, which walks your YAML and cassettes and flags a literal -32002 with -32602 as the replacement. The plan-and- rewrite step is mcptest migrate:
# Dry-run: print the per-file action plan, change nothing.
mcptest migrate tests/ --to 2026-07-28
# Apply the rewrites in place.
mcptest migrate tests/ --to 2026-07-28 --write
migrate does two things. It annotates every deprecated-feature hit with a # TODO(mcptest-migrate) comment above the offending line, pointing at the replacement guidance, and it mechanically rewrites the one change that has a safe one-to-one replacement: the legacy -32002 token becomes -32602. Without --write it is a dry-run that prints what it would do. --to 2026-07-28 is the only supported target in v1; any other value is a clear error (exit 2).
Expected output
The suite run shows the conformant server passing and the legacy server failing, with the failing assertion naming -32002 where -32602 was expected:
mcptest run tests/migration.yml
FAIL missing resource returns standard -32602 (legacy) (188ms)
result.error.code
expected (exact): -32602
actual: -32002
PASS missing resource returns standard -32602 (conformant) (172ms)
1 passed, 1 failed in 0.4s
The -32002 on the actual line is the legacy code; the contract test catches it without anyone reading the raw JSON. The dry-run migrate plan shows the mechanical rewrite it would apply:
mcptest migrate tests/ --to 2026-07-28
tests/migration.yml
line 18 legacy-error-code -32002 -> -32602 (annotate + rewrite)
1 file, 1 change (dry-run; pass --write to apply)
After migrate --write, the rewritten line carries the standard code and a # TODO(mcptest-migrate) comment recording the change, and re-running the suite against the conformant endpoint passes.
Troubleshooting
- doctor prints
[SKIP]for the error code, not[FAIL]. Expected in this release. The migration probe detects Roots, Sampling, and Logging deprecations live; the missing-resource error code surfaces as[SKIP]with a follow-up reference. Use the contract test above andmcptest lintto catch-32002today, andmcptest migrateto plan the rewrite. - The legacy test passes when you expected it to fail. Confirm the
?scenario=legacyquery string is on theurl. Without it, the hosted server returns the conformant-32602and both tests pass. mcptest migrate --to <other>errors out. v1 supports2026-07-28only. Any other--tovalue exits2. Drop the flag to take the default, which is the same value.migratereports no changes. The same deprecation catalog driveslintandmigrate, so a suite that lints clean migrates as a no-op. Runmcptest lint tests/first to confirm there is a-32002to find.- The assertion target does not resolve. A JSON-RPC error envelope is wrapped under
result, so the path isresult.error.code, noterror.code. The matcher isexactwith an integer value.
See also
docs/spec-version-pinning.md, the spec-revision pin and the cross-version breakage report, including the full-32002to-32602rationale.- Previous: Rate-limit backoff.
- Next: Record and replay.