[do not merge] Add mcp-codemod, an automated v1 to v2 migration tool#3011
Draft
maxisbey wants to merge 2 commits into
Draft
[do not merge] Add mcp-codemod, an automated v1 to v2 migration tool#3011maxisbey wants to merge 2 commits into
maxisbey wants to merge 2 commits into
Conversation
A new `mcp-codemod` workspace package (`uvx mcp-codemod v1-to-v2 ./src`) that rewrites every v1 -> v2 change whose meaning is unambiguous from the file alone, and inserts a `# mcp-codemod:` comment above every site it recognized but would not guess at. Built on libCST. Names are resolved through each file's imports, never matched as text, so an aliased import or an unrelated symbol that shares a name with an SDK one is never touched. The camelCase to snake_case rename is restricted to the field names v1's `mcp.types` actually declared. Anything whose correct rewrite depends on information that is not in the file -- the lowlevel decorator to `on_*` relocation, the transport keywords on the `MCPServer` constructor -- is left exactly as written and marked instead, so the remaining work is one grep. Re-running on the output is a no-op. The mapping tables are pinned against the installed v2 package by ratchet tests so they cannot silently drift: every rename target must resolve, every removed API must be provably absent, and no flagged constructor keyword may survive on `MCPServer.__init__`. Measured against the example files that exist on both `v1.x` and `main` (whose diff is the hand-written migration), the codemod fully reproduces 13 of the 51 with a real migration diff, improves 35 more, and makes none worse. Also adds an "Automated migration" section to docs/migration.md, a mention of the tool in README.v2.md, and the package to the publish workflow's build step (the PyPI project and its trusted publisher must exist before a release is tagged with this in it).
Three additions to mcp-codemod, closing the gaps a comparison with the TypeScript codemod surfaced: Imports of module namespaces v2 deleted outright (the experimental tasks namespaces, the WebSocket transports, `mcp.shared.progress`) are now marked with replacement guidance. A new ratchet test freezes the 107 public modules v1 shipped and asserts every one imports on v2, is renamed, or is in the removed table, so the whole v1 module namespace is provably accounted for. The codemod now also updates the `mcp` requirement in `pyproject.toml` (PEP 621 tables and dependency groups) and `requirements*.txt` to `>=2,<3` -- only where the current constraint cannot accept any v2 release, and only the version specifier: name, extras, environment marker, and spacing keep the user's spelling. Poetry tables and the removed `ws` extra are marked instead of guessed at, under the same `# mcp-codemod:` contract as source markers. `scripts/codemod-batch-test/` runs the codemod against pinned real repositories and audits the marker contract end to end: it type-checks the pristine clone against the latest v1 and the migrated copy against this workspace's v2 with identical pyright settings, then requires every error that exists only on the migrated side to sit next to a marker. Across the four repos in the manifest every migration-surface error is covered, and the audit caught two real bugs now fixed here: `Context` imported from the old `.server` submodule is rehomed to the package (the submodule holds the name at runtime, but a type checker treats a non-re-exported name as private), and `request_context` on a receiver the pre-pass proved holds a lowlevel `Server` is flagged again -- receiver-matched, so the live `ctx.request_context` idiom stays untouched.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Important
DO NOT MERGE. Opening as a draft for design review. The codemod is self-contained
(a new workspace package; nothing in
mcpdepends on it), but the scope, the mappingtables, and the publishing prerequisite below deserve eyes before any of it is real.
Adds
mcp-codemod, a libCST-based tool that automates the mechanical half of the v1 to v2 migration:It rewrites every change whose meaning is unambiguous from the file alone, and inserts a
# mcp-codemod:comment above every site it recognized but would not guess at, so theremaining work is one grep. Re-running on its own output is a no-op, and
--dry-run(optionally with
--diff) previews a run without writing anything.Motivation and Context
docs/migration.mdis ~1,600 lines, and most of what it asks for is tedium rather thanjudgment: import moves, symbol renames, and the camelCase to snake_case field renames that
touch nearly every file. More prose cannot reduce tedium. A codemod removes exactly that
half, so a reader's (or an agent's) attention goes to the changes that actually need it.
The TypeScript SDK already ships
@modelcontextprotocol/codemodas step 1 of its upgradeguide; this is the Python counterpart.
What it rewrites (each gated on resolving a name through the file's imports, never on
matching text, so an aliased import or an unrelated symbol with the same name is never
touched):
mcp.server.fastmcp->mcp.server.mcpserver,mcp.types->mcp_types(including thefrom mcp import typesform, which needs awhole-statement rewrite),
mcp.shared.version->mcp_types.version.FastMCP->MCPServer,McpError->MCPError,FastMCPError->MCPServerError,streamablehttp_client->streamable_http_client,and the removed
Content/ResourceReferencealiases.McpError(ErrorData(code=..., message=...))to the flatMCPError(...)constructor, ande.error.codetoe.codeinside anexcept McpError as e:block (only the fullthree-part chain -- a bare
e.errormay be a wholeErrorDataand is never collapsed).mcp.typesmodels, restricted to the 40 field names v1actually declared. This matters: in real v1 code most camelCase attribute accesses are
logging.getLogger,.basicConfig, or the user's own attributes, so anything broaderthan an allowlist is unusable.
streamable_http_client(...) as (read, write, _)three-tuple to the v2 two-tuple.mcprequirement inpyproject.toml(PEP 621 tables and dependency groups) andrequirements*.txt, to>=2,<3-- but only where the current constraint cannot acceptany v2 release, and only the version specifier: the name, extras, environment marker,
and spacing keep the user's exact spelling. Poetry tables and the removed
wsextraare marked instead.
Contextimported from the old.serversubmodule is rehomed to the package, itspublic v2 home -- the submodule still holds the name at runtime, but a type checker
treats a name a module does not re-export as private.
What it deliberately only marks (the design rule is: never guess at a change that
depends on information not in the file):
mcp.typesnames with no v2 home (Cursor, theTASK_*constants, thev1 type-machinery aliases).
mcp_typesis not a name-superset of v1'smcp.types,so each of these is marked with its replacement at the import and at every use --
the alternative is rewriting them into an import that cannot resolve, silently. A
test pins, against the installed package, that every public name v1's
mcp.typesdefined either exists on
mcp_typesor is explicitly accounted for, so this listcannot rot as v2 evolves.
namespaces, the WebSocket transports,
mcp.shared.progress). A companion ratchetfreezes the full list of 107 public modules v1 shipped and asserts each one imports
on v2, is renamed, or is in the removed table -- the whole v1 module namespace is
accounted for.
request_contexton a receiver the pre-pass proved holds a lowlevelServer(thatproperty is gone on v2). The same name on anything else is never matched, because
ctx.request_contextis a live, documented v2 idiom.streamablehttp_client(...)used anywhere other than directly as awithitem(
enter_async_context(...)is the common form): its result shape changed and onlythe inline
as (read, write, _)form can be rewritten rather than flagged.instructions; v2's istitle, so renaming the call alone would silently send theinstructions as the title), and a camelCase name that one of the file's own classes
declares (renaming its uses would break that class, whose declaration does not change).
@server.call_tool()decorators. They are syntactically identical to thehigh-level
@mcp.tool()ones -- only what the receiver is bound to distinguishes them --and migrating one also means reordering statements and rewriting the handler signature.
bump-pydanticwas eventually archived as "incomplete" largely because it attempted itsequivalent of this and got it wrong often enough to lose trust; the marker names the
exact
on_*=keyword instead.MCPServerconstructor (host=,stateless_http=, ...). Theright destination (
run()/sse_app()/streamable_http_app()) depends on how theserver is started and may be in another file, so the kwarg is left in place -- v2 then
fails loudly -- rather than deleted, which would silently lose configuration.
How Has This Been Tested?
(
./scripts/testis green for the whole tree), strict pyright, ruff.scripts/codemod-batch-test/(the analogue of the TypeScript codemod'sbatch-test):it clones pinned commits of real v1 projects, migrates a copy, type-checks the
pristine side against the latest v1 and the migrated side against this workspace's
v2 with identical pyright settings, and then demands that every error that exists
only on the migrated side sits next to a
# mcp-codemod:marker. Across the fourrepos in the manifest (the official reference servers, mcp-obsidian,
one server from the awslabs monorepo, android-mcp-server): every migration-surface
error is covered by a marker -- 0 uncovered -- and the migration resolved 11
baseline errors. The awslabs run also surfaces 84 errors in the repo's own untouched
test code (a duck-typed mock that no longer satisfies v2's now-defaulted
Contextgenerics, plus stricter
| Nonereturns); the harness classifies those separatelyas v2 strictness drift since no rewrite causes or could mark them.
Contextrehome above, and lowlevel
server.request_contextgoing unmarked in the officialgit server), which is the strongest argument for keeping it in the tree.
not touch and making code worse, so that is what most of the suite pins, with exact
reproductions: a file that never imports the SDK is never modified even when it spells
tempting names (a local variable named
mcp,getattr(row, "createdAt")on an ORMrow,
self.get_context()in a Django view); nothing is ever rewritten into a silentNameError(import mcpplusmcp.types.Xis marked, not half-rewritten); nothingthat works on v2 is broken (
e.error.message = ...is a write to a still-mutablefield and is left alone; only reads collapse to the new properties) or wrongly marked
(
ctx.request_contextis a live v2 idiom, so that name is deliberately NOT matched);and a re-run of the codemod over its own output is byte-for-byte identical, including
for a marker on the first statement of a module, which libCST parses back into the
module header rather than the statement.
installed v2 surface by construction: a "removed attribute" name may not be spelled by
any living public v2 API (the
request_contextlesson, now a test), and every publicname v1's
mcp.typesdefined must exist onmcp_typesor be explicitly accounted for.they cannot silently drift as v2 evolves: every rename target is
exec'd and mustresolve, every removed API must be provably absent, and no flagged constructor keyword
may survive on
MCPServer.__init__. That last one is not theoretical -- it existsbecause
debug,log_level, anddependenciesall looked removed at one point and areactually still accepted, and a marker on a keyword that works is a lie the user cannot
reconcile.
v1.xandmainwere migrated by hand, so their diff is the correct migration. Running the codemod on
the v1 side and diffing against the human result (all sides normalized through the
repo's own ruff config): of the 51 files with a real migration diff, 13 are reproduced
exactly, 35 partially, and 0 are made worse. The lowlevel examples get ~0% help by
design. Reproduce with
scratch/codemod-spike/eval_production.pyon this branch.Breaking Changes
None. The package is additive;
mcpdoes not depend on it.Types of changes
Checklist
Additional context
Needs an owner before this merges: the PR adds
mcp-codemodto the publishworkflow's build step, so the PyPI project must be registered and a trusted publisher
configured for it (the same dance
mcp-typesneeded) before this lands. Theordering matters: if a release is tagged first, the upload job dies at the unregistered
mcp-codemodwheel aftermcpis uploaded and beforemcp-types, andmcp'sexact pin on
mcp-typesmakes that half-published release uninstallable until the jobis re-run. (Without the workflow change the documented
uvx mcp-codemodfails for everyreader instead, so the docs and the publishing have to land together either way.)
Deliberately not in this PR (each is a clean follow-up, and none should gate the
design review):
docs/migration.mdcodemod-first into the two-journey split theTypeScript guide uses, with the mapping tables linked as the source of truth.
--transforms). The transformer here is oneintegrated pass rather than discrete transforms, re-runs are idempotent, and disagreeing
with a specific rewrite is handled by not committing those hunks -- deferred until
someone actually asks for it.
AI Disclaimer