Install
$ agentstack add mcp-dheerajjha-mcp-migrate ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →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.
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.
filtersis{"rule": [...], "severity": [...]}, the ids/severities
passed to --rule/--severity, empty arrays when neither was given.
rule_filteredistruewhenever--rulewas given.countsand
findings reflect the requested filters; grade and score are always null when rule_filtered is true.
fail_onechoes the--fail-onthreshold that decided the process exit
code, so a consumer doesn't have to re-derive it.
Conditional keys:
is_sdkandsdk_reasononly when the tree is a protocol SDK. In that casegradeandscorearenull.reasononly whenscannableisfalse. In that casegradeandscorearenull.gradeandscoreare alsonullwhen 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:
- 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:
mcp_session_id = req.headers["X-Sid"] # mcp-migrate: ignore[R001] -- proxy shim, not MCP session state
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-suppressionslists every one with its file, rule and reasoncheckreports 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. 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.)
No rule is Python-only any more. Issue #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:
[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:
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
- Source: 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.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.