# Mcp Migrate

> Find and fix what the MCP 2026-07-28 spec revision breaks in your server. 21 rules, 5 autofixers, and a readiness board.

- **Type:** MCP server
- **Install:** `agentstack add mcp-dheerajjha-mcp-migrate`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [dheerajjha](https://agentstack.voostack.com/s/dheerajjha)
- **Installs:** 0
- **Category:** [Developer Tools](https://agentstack.voostack.com/c/developer-tools)
- **Latest version:** 0.1.0
- **License:** Apache-2.0
- **Upstream author:** [dheerajjha](https://github.com/dheerajjha)
- **Source:** https://github.com/dheerajjha/mcp-migrate
- **Website:** https://pypi.org/project/mcp-migrate/

## Install

```sh
agentstack add mcp-dheerajjha-mcp-migrate
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# mcp-migrate

`mcp-migrate` finds and fixes what the MCP 2026-07-28 spec revision breaks in
your server: protocol sessions and `Mcp-Session-Id` removed, `initialize` /
`notifications/initialized` replaced by `server/discover`, `ping` and
`logging/setLevel` gone, `resources/subscribe` replaced by
`subscriptions/listen`, required `resultType` and cache metadata on results,
server-initiated Sampling/Roots/Elicitation replaced by Multi Round-Trip
Requests, and more. The official Python SDK ships no codemod for any of
this -- only a manual migration guide. The TypeScript codemod only handles
the v1→v2 package rename, not the protocol changes. `mcp-migrate fix` is the
only thing that edits your server's code for you.

```bash
uvx mcp-migrate check .
uvx mcp-migrate fix . --write
```

## `mcp-migrate check`

```
$ uvx mcp-migrate check tests/fixtures/fixer_roundtrip

mcp-migrate v0.2.0  ->  fixer_roundtrip
2 Python files, 21 rules, spec 2026-07-28

            rule    where         what
breaking    R001    server.py:28  Mcp-Session-Id was removed from the Streamable HTTP transport.
breaking    R001    server.py:29  Mcp-Session-Id was removed from the Streamable HTTP transport.
breaking    R017    errors.py:8   -32002 for resource-not-found is the old code; 2026-07-28 uses -32602.
deprecated  R006    server.py:15  HTTP+SSE transport is deprecated.
deprecated  R006    server.py:24  HTTP+SSE transport is deprecated.
advisory    R004    server.py:32  Tools are returned without an explicit sort.
advisory    R005    server.py:16  Capabilities are declared but `extensions` is absent.
advisory    R010    (project)     This project registers MCP request handlers (tools/resources/prompts)
                                  but has no server/discover implementation anywhere in the project.
advisory    R016    server.py:32  This file implements a list/read handler but neither `ttlMs` nor
                                  `cacheScope` appears in it.

  R001  Uses Mcp-Session-Id, which no longer exists
  SEP-2567 https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567
  Sessions are gone from the transport. Mint an explicit handle server-side and take it as an
  ordinary tool argument instead.

  [... one block like this per rule that fired ...]

Grade F (26/100)  3 breaking, 2 deprecated, 4 advisory

Add your server to the board:  mcp-migrate entry --repo owner/name
```

Zero findings prints `Grade A` and a ready-to-paste badge instead. Add
`--json` for machine-readable, uncapped output (the terminal table caps each
rule at 5 rows with a "+N more" line so it stays readable; JSON always has
every finding).

`--rule R001` (repeatable, e.g. `--rule R001 --rule R017`) restricts which
rules run at all, not just what's printed -- the natural way to adopt this
tool on an existing codebase is one rule at a time. An unknown id is a usage
error (exit `2`), same as `--write --dry-run` together on `fix`. A grade
computed from part of the rule set isn't a grade, so `--rule` suppresses it
entirely rather than printing one that only ever looked at a subset.

`--severity breaking` (repeatable, composes with `--rule`) only changes what
gets *displayed* -- the grade and the exit code are always computed from every
finding, so two runs against the same code never disagree just because
someone chose to look at a narrower slice of the output.

`--fail-on {breaking,deprecated,advisory,never}` (default `breaking`) is the
minimum severity that fails the run. A team mid-migration that has decided
`deprecated` blocks a merge sets `--fail-on deprecated`; a team still
adopting the tool that wants findings reported without failing the build yet
sets `--fail-on never` -- which is not the same as `check || true`: exit `2`
("could not check it") is a refusal, not a severity, and no `--fail-on`
setting suppresses it.

### `check --json` contract

`--json` emits one JSON object with these always-present top-level keys:
`tool`, `version`, `spec`, `path`, `scannable`, `languages`, `grade`,
`score`, `rule_filtered`, `filters`, `fail_on`, `files_scanned`, `counts`,
`findings`, `suppressed`, and `unused_suppressions`.

- `filters` is `{"rule": [...], "severity": [...]}`, the ids/severities
  passed to `--rule`/`--severity`, empty arrays when neither was given.
- `rule_filtered` is `true` whenever `--rule` was given. `counts` and
  `findings` reflect the requested filters; `grade` and `score` are always
  `null` when `rule_filtered` is `true`.
- `fail_on` echoes the `--fail-on` threshold that decided the process exit
  code, so a consumer doesn't have to re-derive it.

Conditional keys:

- `is_sdk` and `sdk_reason` only when the tree is a protocol SDK. In that case `grade` and `score` are `null`.
- `reason` only when `scannable` is `false`. In that case `grade` and `score` are `null`.
- `grade` and `score` are also `null` when a tree was read but is not gradable, for example TypeScript-only trees.

`suppressed` holds findings silenced by an inline `mcp-migrate: ignore[R0NN]`
comment; they are excluded from `findings`, from `counts`, and from the grade,
and each carries the `reason` from its comment.

`unused_suppressions` holds directives that matched no finding — either it was
fixed or the code moved. Each entry has `rule`, `path`, `line`, and `reason`,
one per rule id. It is always present, empty array included: stale suppressions
accumulate in CI, and CI is the consumer that reads `--json` and never sees a
console line.

Each finding always has `rule`, `severity`, `path`, `line`, and `message`.
`path` and `line` may be `null` for project-level findings. `fix` is present
only when the rule has remediation text; it is omitted, never `null`.

The executable contract is
[`schemas/check-json.schema.json`](schemas/check-json.schema.json). Pre-1.0,
this shape may change in a breaking way; such changes are documented under
Changed in [CHANGELOG.md](CHANGELOG.md), so consumers should pin a version
and watch that section.

### `check --format sarif`

SARIF 2.1.0, for GitHub code scanning and anything else that speaks it:

```yaml
- run: mcp-migrate check . --format sarif > mcp-migrate.sarif
- uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: mcp-migrate.sarif
```

Findings land in the Security tab and annotate the diff, instead of scrolling
past in a log. Severities map to SARIF levels as `breaking` -> `error`,
`deprecated` -> `warning`, `advisory` -> `note`. `deprecated` is deliberately
not `error`: code scanning's default gate fails on `error` alone, and the spec
gives deprecated features 12+ months, so blocking a merge today over a change
that doesn't break until next year is how an integration gets switched off.

`--format {text,json,sarif}` is the general flag; `--json` remains as an alias
for `--format json`. Output is validated against the vendored
[SARIF 2.1.0 schema](schemas/sarif-2.1.0.schema.json) in CI.
### Suppressing a finding

This tool has open false-positive classes and says so above. When a finding
is wrong, or the code is deliberate and not changing, silence that one line
rather than the whole rule:

```python
mcp_session_id = req.headers["X-Sid"]  # mcp-migrate: ignore[R001] -- proxy shim, not MCP session state
```

```ts
const mcpSessionId = req.headers["x-sid"];  // mcp-migrate: ignore[R001] -- proxy shim
```

The rule id is required — a blanket `ignore` would also silence rules that
don't exist yet, and nobody ever revisits it. A reason after `--` is expected;
`check` reports directives that lack one.

A suppressed finding **doesn't count against the grade** — a suppression that
still costs you the grade isn't one, and the only move left would be to stop
running the tool. That does make the grade partly self-reported, so three
things keep it auditable:

- the count is always printed, never behind a flag
- `--show-suppressions` lists every one with its file, rule and reason
- `check` reports suppressions that matched nothing, so stale ones don't
  quietly accumulate

`--json` carries them under `suppressed`, each with its `reason`, and the stale
ones under `unused_suppressions`.

**One caveat, because "line-scoped" promises slightly less than it delivers for
three rules.** `R005`, `R015` and `R016` ask a question about a *file* — does
this file declare capabilities without `extensions`, does it build a JSON-RPC
result without `resultType`, does it implement a list/read handler without cache
metadata — so each reports at most one finding per file, on the first line that
matches. Suppressing that line therefore silences the rule **for the whole
file**, not just that line. Every other rule reports each occurrence
separately, and there suppression really is per line. If you suppress one of
these three, you are accepting the rule's verdict on that file.

Exit codes, so it drops straight into CI:

| code | meaning |
| ---- | ------- |
| `0`  | checked it, nothing `breaking` |
| `1`  | checked it, found something `breaking` |
| `2`  | **could not check it** — no readable source in a supported language |

`2` means **we did not read your code**, not "your code is fine" — an empty
finding set otherwise conflates "we read it and it's clean" with "we read
nothing", and a grade that can't tell those apart is worthless. A tree with
nothing readable in it gets silence instead of an A.

**Python is graded. TypeScript is scanned but still not graded** — and as of
R002 landing, that is no longer justified by coverage. **All 21 rules read
TypeScript.** The grade is withheld by a `PARTIAL` flag, and now that
coverage is complete the reason it gives is a decision, not a fraction:

```
$ mcp-migrate check ./my-ts-server

mcp-migrate v0.2.0  ->  my-ts-server

No grade for this one. Found 1 TypeScript. Every rule reads it now, but
whether it gets graded is still an open decision, not a coverage gap --
see https://github.com/dheerajjha/mcp-migrate/issues/172.

  breaking    R001  server.ts:4  Mcp-Session-Id was removed from the Streamable HTTP transport.
  deprecated  R006  server.ts:1  HTTP+SSE transport is deprecated.
  deprecated  R006  server.ts:5  HTTP+SSE transport is deprecated.

3 finding(s) from the rules that do cover this language. Real, and worth
fixing -- but a letter grade would be a claim about a decision that
hasn't been made.
```

Whether a language every rule reads should ever get a letter is tracked as
[#172](https://github.com/dheerajjha/mcp-migrate/issues/172). Until that is
resolved, a TypeScript tree gets findings but no grade, no badge, and no
registry entry. Findings are real and complete; only the letter is withheld.

**The exit code works on TypeScript**, so this drops into CI today regardless:
a `breaking` finding exits `1` whether or not a grade was issued. (Until
v0.1.3 it always exited `2`, so CI could not fail on a TypeScript
regression — that was [#98](https://github.com/dheerajjha/mcp-migrate/issues/98).)

No rule is Python-only any more.
[Issue #30](https://github.com/dheerajjha/mcp-migrate/issues/30) is closed on
coverage; what remains is the grading decision in #172.

By default, `check` skips test code: anything under a `tests/`, `test/`,
`testing/`, `fixtures/`, `examples/`, or `docs/` directory, plus `test_*.py`,
`*_test.py`, and `conftest.py` files. A backward-compat test that
deliberately exercises a deprecated transport, or an integration test that
posts a literal `{"method": "tools/list"}` payload, is evidence your project
is well tested -- not evidence the server itself is broken. Pass
`--include-tests` to scan those paths too.

### Project config

Everything above is a flag, which means it has to be retyped on every
invocation and can't be shared with a team or with CI. Put it in
`[tool.mcp-migrate]` in `pyproject.toml` instead:

```toml
[tool.mcp-migrate]
skip = ["vendor/", "generated/"]
include-tests = false

[tool.mcp-migrate.rules]
R008 = "off"                                          # no reason recorded
R016 = "off -- ttl is enforced by the gateway in front of this"
```

No `pyproject.toml`? Which, given this tool targets MCP servers, is most
JavaScript and TypeScript projects -- put the same keys in a standalone
`.mcp-migrate.toml` at the project root instead:

```toml
skip = ["vendor/"]

[rules]
R008 = "off -- we propagate trace context out of band"
```

If a `pyproject.toml` exists at all, it wins, whether or not it actually
sets `[tool.mcp-migrate]` -- the standalone file is only read for projects
that have no `pyproject.toml` in the first place, so the two never end up
silently disagreeing about which one applies.

**Precedence: a flag beats config, config beats the built-in default.**
`--include-tests` on the command line always wins; config can turn it on
project-wide when the flag doesn't.

**A disabled rule never runs, rather than running and being discarded.**
It costs nothing against the grade, and it is never mistaken for a pass --
`check` reports how many rules config switched off (`N rule(s) disabled by
config`, with each rule id and its reason) in every run, not behind a flag,
same as suppressions. `--json` carries the same list under `disabled_rules`.
`fix` skips the fixer for a disabled rule too.

Malformed config (bad TOML, an unrecognised rule id, `rules.R008 = "sometimes"`)
doesn't fail the run -- it falls back to defaults and reports what it
couldn't understand, under `config_warnings` in `--json` output and as a
`config warning` line in text output.

## `mcp-migrate fix`

```
$ uvx mcp-migrate fix tests/fixtures/fixer_roundtrip

errors.py
--- a/errors.py
+++ b/errors.py
@@ -5,7 +5,7 @@
 
 def read_resource(handle: str) -> dict:
     if not _exists(handle):
-        return {"code": -32002, "message": "resource not found"}
+        return {"code": -32602, "message": "resource not found"}
     return {"contents": _load(handle)}
 
 
  [R017/safe] line 8: resource-not-found error code -32002 -> -32602

server.py
--- a/server.py
+++ b/server.py
@@ -12,26 +12,29 @@
 from __future__ import annotations
 
 from mcp.server import Server
-from mcp.server.sse import SseServerTransport
+from mcp.server.streamable_http import StreamableHTTPServerTransport
 from mcp.types import ServerCapabilities, Tool, ToolsCapability
 
 server = Server("fixture-server")
 
 capabilities = ServerCapabilities(
     tools=ToolsCapability(list_changed=True),
+    extensions={},
 )
 
-transport = SseServerTransport("/messages")
+# TODO(mcp-migrate): verify no constructor args were lost moving off SSE, see https://modelcontextprotocol.io/specification/draft/changelog
+transport = StreamableHTTPServerTransport()
 
 
 def _session_for(request):
-    mcp_session_id = request.headers.get("Mcp-Session-Id")
+    # TODO(mcp-migrate): replaced by an explicit handle argument, see https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567
+    # mcp_session_id = request.headers.get("Mcp-Session-Id")
     return mcp_session_id
 
 
 @server.list_tools()
 async def list_tools() -> list[Tool]:
-    return [
+    return sorted([
         Tool(name="zeta", description="Last alphabetically."),
         Tool(name="alpha", description="First alphabetically."),
-    ]
+    ], key=lambda t: t.name)
  [R001/review] line 28: commented out Mcp-Session-Id header access, added TODO
  [R004/safe] line 35: wrapped returned tool list in sorted(key=lambda t: t.name)
  [R005/safe] line 20: added extensions={} to ServerCapabilities(...)
  [R006/review] line 15: import Streamable HTTP transport instead of SSE
  [R006/review] line 25: SseServerTransport(...) -> StreamableHTTPServerTransport(), flagged for review

2 file(s), 6 change(s): 3 safe, 3 flagged for human review
4 finding(s) still need a human after this fix -- run `mcp-migrate check tests/fixtures/fixer_roundtrip` for details.
Dry run -- nothing was written. Re-run with --write to apply.
```

`fix` runs in dry-run mode by default (that's what `--dry-run` names
explicitly; it's implied when you pass neither flag) -- it prints the exact
unified diff for e

…

## Source & license

This open-source MCP server is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [dheerajjha](https://github.com/dheerajjha)
- **Source:** [dheerajjha/mcp-migrate](https://github.com/dheerajjha/mcp-migrate)
- **License:** Apache-2.0
- **Homepage:** https://pypi.org/project/mcp-migrate/

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/mcp-dheerajjha-mcp-migrate
- Seller: https://agentstack.voostack.com/s/dheerajjha
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
