mcptest docs GitHub

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

See also