From ec8361b09c4656162fa62a26da69ec639edf04f7 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sat, 27 Jun 2026 03:58:35 +0000 Subject: [PATCH 1/2] Add mcp-codemod, an automated v1 to v2 migration tool 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). --- .github/workflows/publish-pypi.yml | 1 + README.v2.md | 2 +- docs/migration.md | 13 + pyproject.toml | 16 +- src/mcp-codemod/README.md | 72 + src/mcp-codemod/mcp_codemod/__init__.py | 23 + src/mcp-codemod/mcp_codemod/_mappings.py | 296 ++++ src/mcp-codemod/mcp_codemod/_runner.py | 130 ++ src/mcp-codemod/mcp_codemod/_transformer.py | 821 ++++++++++ src/mcp-codemod/mcp_codemod/cli.py | 93 ++ src/mcp-codemod/mcp_codemod/py.typed | 0 src/mcp-codemod/pyproject.toml | 58 + tests/codemod/__init__.py | 0 tests/codemod/test_cli.py | 167 ++ tests/codemod/test_mappings.py | 417 +++++ tests/codemod/test_runner.py | 215 +++ tests/codemod/test_transformer.py | 1531 +++++++++++++++++++ uv.lock | 195 ++- 18 files changed, 4041 insertions(+), 9 deletions(-) create mode 100644 src/mcp-codemod/README.md create mode 100644 src/mcp-codemod/mcp_codemod/__init__.py create mode 100644 src/mcp-codemod/mcp_codemod/_mappings.py create mode 100644 src/mcp-codemod/mcp_codemod/_runner.py create mode 100644 src/mcp-codemod/mcp_codemod/_transformer.py create mode 100644 src/mcp-codemod/mcp_codemod/cli.py create mode 100644 src/mcp-codemod/mcp_codemod/py.typed create mode 100644 src/mcp-codemod/pyproject.toml create mode 100644 tests/codemod/__init__.py create mode 100644 tests/codemod/test_cli.py create mode 100644 tests/codemod/test_mappings.py create mode 100644 tests/codemod/test_runner.py create mode 100644 tests/codemod/test_transformer.py diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 41b127f923..f278dc066b 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -30,6 +30,7 @@ jobs: run: | uv build --package mcp uv build --package mcp-types + uv build --package mcp-codemod - name: Upload artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/README.v2.md b/README.v2.md index 9b9971ec32..59967a07ff 100644 --- a/README.v2.md +++ b/README.v2.md @@ -17,7 +17,7 @@ > **Important: this documents v2 of the SDK, which is in alpha.** Pre-releases are published to PyPI as `2.0.0aN`, and each alpha may contain breaking changes from the previous one. > -> v2 is a major rework of the SDK, both to support the [2026-07-28 MCP specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) and to fix long-standing architectural issues. See the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/) for what's changed. We're targeting a beta on 2026-06-30 and a stable v2 on 2026-07-27, alongside the spec release. Before stable, we plan to add a significant set of backwards compatibility shims so the final upgrade is much smaller than today's diff. +> v2 is a major rework of the SDK, both to support the [2026-07-28 MCP specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) and to fix long-standing architectural issues. See the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/) for what's changed; `uvx mcp-codemod v1-to-v2 ./src` automates the mechanical half of it and marks the rest with `# mcp-codemod:` comments. We're targeting a beta on 2026-06-30 and a stable v2 on 2026-07-27, alongside the spec release. Before stable, we plan to add a significant set of backwards compatibility shims so the final upgrade is much smaller than today's diff. > > **v1.x is the only stable release line and remains recommended for production.** It is in maintenance mode and continues to receive critical bug fixes and security patches. Installers never select a pre-release unless you opt in (for example `pip install mcp==2.0.0a3`), so existing installs are unaffected. **If your package depends on `mcp`, add a `<2` upper bound to your version constraint (for example `mcp>=1.27,<2`) before the stable release lands.** > diff --git a/docs/migration.md b/docs/migration.md index 42d420bf04..c3426b7be1 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -6,6 +6,19 @@ This guide covers the breaking changes introduced in v2 of the MCP Python SDK an Version 2 of the MCP Python SDK introduces several breaking changes to improve the API, align with the MCP specification, and provide better type safety. +## Automated migration + +The `mcp-codemod` tool (published from `src/mcp-codemod` in this repository) rewrites every change in this guide whose meaning is unambiguous from the file alone -- the import moves, the symbol renames, the `MCPError` reshape, and the camelCase to snake_case field renames -- and inserts a `# mcp-codemod:` comment above every site it recognized but would not guess at. Run it on a clean branch first, then work through what it marked: + +```bash +uvx mcp-codemod v1-to-v2 ./src +grep -rn '# mcp-codemod:' ./src +``` + +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. Re-running on its own output is a no-op, so it is safe to apply again after a manual fix-up. To preview without writing anything, pass `--dry-run` (add `--diff` to see the full unified diff). + +The sections below remain the reference for the changes it cannot make for you: the lowlevel `Server` handler rewrite, relocating transport keyword arguments off the `MCPServer` constructor, and every behavioural change that has no source-level signature. + ## Breaking Changes ### `MCPServer.call_tool()` returns `CallToolResult` diff --git a/pyproject.toml b/pyproject.toml index 22ba4d4f4c..5647a876b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,9 @@ build-constraint-dependencies = [ dev = [ # We add mcp[cli] so `uv sync` considers the extras. "mcp[cli]", + # The codemod is a standalone tool, not a dependency of `mcp`; pull it in here + # so the workspace's test environment has it. + "mcp-codemod", "mcp-example-stories", "tomli>=2.0; python_version < '3.11'", "pyright>=1.1.400", @@ -135,6 +138,7 @@ packages = ["src/mcp"] typeCheckingMode = "strict" include = [ "src/mcp", + "src/mcp-codemod/mcp_codemod", "src/mcp-types/mcp_types", "tests", "docs_src", @@ -213,10 +217,18 @@ max-returns = 13 # Default is 6 max-statements = 102 # Default is 50 [tool.uv.workspace] -members = ["src/mcp-types", "examples", "examples/clients/*", "examples/servers/*", "examples/snippets"] +members = [ + "src/mcp-codemod", + "src/mcp-types", + "examples", + "examples/clients/*", + "examples/servers/*", + "examples/snippets", +] [tool.uv.sources] mcp = { workspace = true } +mcp-codemod = { workspace = true } mcp-example-stories = { workspace = true } mcp-types = { workspace = true } strict-no-cover = { git = "https://github.com/pydantic/strict-no-cover" } @@ -265,7 +277,7 @@ MD059 = false # descriptive-link-text branch = true patch = ["subprocess"] concurrency = ["multiprocessing", "thread"] -source = ["src", "src/mcp-types/mcp_types", "tests"] +source = ["src", "src/mcp-codemod/mcp_codemod", "src/mcp-types/mcp_types", "tests"] omit = [ "src/mcp/client/__main__.py", "src/mcp/server/__main__.py", diff --git a/src/mcp-codemod/README.md b/src/mcp-codemod/README.md new file mode 100644 index 0000000000..84248fe783 --- /dev/null +++ b/src/mcp-codemod/README.md @@ -0,0 +1,72 @@ +# mcp-codemod + +Automated rewrites for migrating code between major versions of the +[MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk). + +```bash +uvx mcp-codemod v1-to-v2 ./src +``` + +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. After a run, this is the complete list of what is left for a human: + +```bash +grep -rn '# mcp-codemod:' ./src +``` + +Run it on a clean branch, read the diff, and follow the markers into the +[migration guide](https://github.com/modelcontextprotocol/python-sdk/blob/main/docs/migration.md). +Re-running on its own output is a no-op, so it is safe to apply again after a +manual fix-up. + +## What it rewrites + +- Import paths that moved (`mcp.server.fastmcp` -> `mcp.server.mcpserver`, + `mcp.types` -> `mcp_types`), including `from mcp import types`. +- Renamed symbols (`FastMCP` -> `MCPServer`, `McpError` -> `MCPError`, + `streamablehttp_client` -> `streamable_http_client`), resolved through the + file's imports so an aliased import or an unrelated symbol with the same name + is never touched. +- `McpError(ErrorData(code=..., message=...))` to the flat `MCPError(...)` + constructor, and `e.error.code` / `e.error.message` / `e.error.data` to + `e.code` / `e.message` / `e.data` inside an `except McpError as e:` block. +- camelCase attribute reads on `mcp.types` models to their snake_case v2 + spellings (`.inputSchema` -> `.input_schema`), restricted to the field names + the v1 types actually declared. Other camelCase APIs (`logging.getLogger`, a + receiver that resolves to another package) are never considered, and a name + that one of your own classes declares (`inputSchema` on your own model) is + marked for you to split rather than renamed, since your declaration does not + change. +- The `streamable_http_client(...) as (read, write, _)` three-tuple to the v2 + two-tuple. + +## What it marks instead + +Some changes cannot be made safely without information that is not in the file. +The codemod never guesses at these; it leaves them exactly as written and adds a +`# mcp-codemod:` comment explaining what to do: + +- Removed APIs that have no drop-in replacement (`create_connected_server_and_client_session`, + the WebSocket transport, `mcp.shared.progress`, `get_context()`). +- The v1 `mcp.types` names with no v2 home (`Cursor`, the `TASK_*` constants, the + type-machinery aliases). `mcp_types` is not a name-superset of v1's `mcp.types`, + so these are marked with their replacement instead of being rewritten into an + import that cannot resolve. +- A `streamablehttp_client(...)` call used anywhere other than directly as a + `with` item (for example through `AsyncExitStack.enter_async_context`): it now + yields two values, not three, and only the inline `as (read, write, _)` form + can be rewritten safely, so every other form is marked. +- Transport keywords on the `MCPServer` constructor (`host=`, `port=`, + `stateless_http=`, ...), which moved to `run()` or one of the app methods. The + right destination depends on how you start the server, so the kwarg is left in + place -- v2 then fails loudly -- rather than silently dropped. +- Lowlevel `@server.call_tool()` decorators, which became `on_call_tool=` + constructor arguments with a different handler signature. Rewriting the + registration also means rewriting the handler body, which is yours to do. +- Renames the codemod applied but cannot prove are right: a camelCase rename + whose receiver could plausibly not be an mcp type gets a `# mcp-codemod: review:` + marker so you look at it instead of trusting it. + +`--dry-run` writes nothing, and `--diff` prints a unified diff of every change; +combine the two to preview a run. diff --git a/src/mcp-codemod/mcp_codemod/__init__.py b/src/mcp-codemod/mcp_codemod/__init__.py new file mode 100644 index 0000000000..3ad6a6ccc6 --- /dev/null +++ b/src/mcp-codemod/mcp_codemod/__init__.py @@ -0,0 +1,23 @@ +"""Automated rewrites for migrating code between major versions of the MCP Python SDK. + +Run it as a tool: + + uvx mcp-codemod v1-to-v2 ./src + +or call it as a library: + + from mcp_codemod import transform + + result = transform(source) + print(result.code) + +Every rewrite is conservative by construction: names are resolved through the file's +imports rather than matched as text, and anything whose correct rewrite depends on +information that is not in the file gets an inline `# mcp-codemod:` comment instead +of a guess. `grep -rn '# mcp-codemod:'` after a run is the complete list of what is +left for a human. +""" + +from mcp_codemod._transformer import MARKER, Diagnostic, Result, transform + +__all__ = ["MARKER", "Diagnostic", "Result", "transform"] diff --git a/src/mcp-codemod/mcp_codemod/_mappings.py b/src/mcp-codemod/mcp_codemod/_mappings.py new file mode 100644 index 0000000000..6382411561 --- /dev/null +++ b/src/mcp-codemod/mcp_codemod/_mappings.py @@ -0,0 +1,296 @@ +"""The v1 -> v2 rename and removal tables. + +These tables are the single source of truth for what the codemod does. Every +transform in `_transformer.py` is driven by one of them; nothing is pattern-matched +by name alone. Each entry was derived by comparing `origin/v1.x` against `main` +in this repository, and the camelCase table is additionally pinned against the +installed `mcp_types` package by `tests/codemod/test_mappings.py`, so it cannot +silently drift as v2 evolves. +""" + +import re +from typing import Literal, NamedTuple + +__all__ = [ + "CAMEL_FIELDS", + "ERRORDATA_QNAMES", + "FASTMCP_QNAMES", + "LOWLEVEL_DECORATOR_METHODS", + "LOWLEVEL_SERVER_QNAMES", + "MCPERROR_QNAMES", + "MODULE_RENAMES", + "REMOVED_APIS", + "REMOVED_ATTRS", + "REMOVED_CTOR_PARAMS", + "SYMBOL_RENAMES", + "TRANSPORT_CLIENT_QNAMES", + "TRANSPORT_CLIENT_REMOVED_PARAMS", + "TRANSPORT_CLIENT_V1_QNAMES", + "TRANSPORT_CTOR_PARAMS", + "CamelField", +] + +# Module-path renames, applied by longest prefix to `import X` / `from X import ...` +# statements and to fully-dotted usages such as `mcp.types.Tool`. Every right side +# must be importable on v2, and `tests/codemod/test_mappings.py` further pins that +# the public names of each old module are all importable from the new one (or are +# themselves renamed or removed), so a rewritten import always resolves. +MODULE_RENAMES: dict[str, str] = { + "mcp.server.fastmcp": "mcp.server.mcpserver", + "mcp.server.fastmcp.server": "mcp.server.mcpserver.server", + "mcp.shared.version": "mcp_types.version", + "mcp.types": "mcp_types", +} + +# Symbol renames, keyed by every v1 qualified name the symbol was reachable from. +# The transformer resolves a usage to its qualified name through the file's imports +# (`libcst.metadata.QualifiedNameProvider`), so an aliased import is never broken +# and a user's own symbol that happens to share a name is never touched. +SYMBOL_RENAMES: dict[str, str] = { + "mcp.server.FastMCP": "MCPServer", + "mcp.server.fastmcp.FastMCP": "MCPServer", + "mcp.server.fastmcp.server.FastMCP": "MCPServer", + "mcp.server.fastmcp.exceptions.FastMCPError": "MCPServerError", + "mcp.McpError": "MCPError", + "mcp.shared.exceptions.McpError": "MCPError", + "mcp.client.streamable_http.streamablehttp_client": "streamable_http_client", + # Removed v1 aliases whose real names survive on v2. + "mcp.types.Content": "ContentBlock", + "mcp.types.ResourceReference": "ResourceTemplateReference", +} + +# v1 public symbols that no longer exist on v2 under any name. The codemod never +# rewrites these (there is nothing correct to rewrite them to); it inserts a +# `# mcp-codemod:` marker carrying the replacement guidance. +REMOVED_APIS: dict[str, str] = { + "mcp.shared.memory.create_connected_server_and_client_session": ( + "removed: connect an in-memory pair with `mcp.Client(server)` instead" + ), + "mcp.shared.progress.progress": "removed: report progress with `ctx.report_progress()` inside a handler", + "mcp.shared.progress.Progress": "removed: `mcp.shared.progress` was deleted", + "mcp.shared.progress.ProgressContext": "removed: `mcp.shared.progress` was deleted", + "mcp.client.websocket.websocket_client": "removed: the WebSocket transport was deleted", + "mcp.server.websocket.websocket_server": "removed: the WebSocket transport was deleted", + "mcp.shared.context.RequestContext": ( + "split: use `mcp.server.context.ServerRequestContext` or `mcp.client.context.ClientRequestContext`" + ), + "mcp.os.win32.utilities.terminate_windows_process": "removed", + "mcp.shared.session.BaseSession": "removed: sessions now run on `JSONRPCDispatcher`", + "mcp.server.lowlevel.server.request_ctx": ( + "removed: the module-level ContextVar is gone; handlers now receive `ctx` explicitly" + ), + # The v1 `mcp.types` names with no same-name home in `mcp_types`. The task + # vocabulary collapsed into the literal strings on v2 and the rest were v1 + # type-machinery aliases. Enumerating every one is what keeps the + # `mcp.types` -> `mcp_types` rewrite honest: `tests/codemod/test_mappings.py` + # checks that every other public v1 name resolves on `mcp_types`, so an + # import this codemod produces is never one that cannot be imported. + "mcp.types.Cursor": "removed: it was an alias of `str`; use `str`", + # A nested class, so the per-name module check in the tests cannot see it. + "mcp.types.RequestParams.Meta": ( + "removed: request metadata is the `RequestParamsMeta` TypedDict on v2, keyed by snake_case names" + ), + "mcp.types.AnyFunction": "removed: it was an alias of `Callable[..., Any]`", + "mcp.types.MethodT": "removed: the generic request type parameters are gone", + "mcp.types.RequestParamsT": "removed: the generic request type parameters are gone", + "mcp.types.NotificationParamsT": "removed: the generic request type parameters are gone", + "mcp.types.ClientRequestType": "removed: use the `ClientRequest` union", + "mcp.types.ClientNotificationType": "removed: use the `ClientNotification` union", + "mcp.types.ClientResultType": "removed: use the `ClientResult` union", + "mcp.types.ServerRequestType": "removed: use the `ServerRequest` union", + "mcp.types.ServerNotificationType": "removed: use the `ServerNotification` union", + "mcp.types.ServerResultType": "removed: use the `ServerResult` union", + "mcp.types.TaskExecutionMode": "removed: `ToolExecution.task_support` takes the literal string on v2", + "mcp.types.TASK_REQUIRED": 'removed: use the literal string `"required"`', + "mcp.types.TASK_OPTIONAL": 'removed: use the literal string `"optional"`', + "mcp.types.TASK_FORBIDDEN": 'removed: use the literal string `"forbidden"`', + "mcp.types.TASK_STATUS_WORKING": 'removed: use the literal string `"working"`', + "mcp.types.TASK_STATUS_INPUT_REQUIRED": 'removed: use the literal string `"input_required"`', + "mcp.types.TASK_STATUS_COMPLETED": 'removed: use the literal string `"completed"`', + "mcp.types.TASK_STATUS_FAILED": 'removed: use the literal string `"failed"`', + "mcp.types.TASK_STATUS_CANCELLED": 'removed: use the literal string `"cancelled"`', +} + +# Attribute and method names that vanished from a class that still exists. These +# can only be matched by name (the codemod cannot know a receiver's type), so a +# name qualifies only when it is distinctive enough that a false match is +# implausible AND no surviving v2 API spells it. The lowlevel +# `Server.request_context` property fails the second bar -- `Context.request_context` +# is a live, documented v2 idiom -- so its removal is deliberately not flagged here. +REMOVED_ATTRS: dict[str, str] = { + "get_context": "`MCPServer.get_context()` was removed: accept a `ctx: Context` parameter on the handler instead", + "get_server_capabilities": "removed: read `session.initialize_result` instead", +} + + +class CamelField(NamedTuple): + """The v2 fate of one camelCase field name declared in v1's `mcp/types.py`.""" + + snake: str + tier: Literal["safe", "risky"] + + +def _to_snake(name: str) -> str: + return re.sub(r"(? Result` with no return auto-wrapping, +# and a codemod that guesses at that loses more trust than it saves time. +LOWLEVEL_DECORATOR_METHODS: dict[str, str] = { + "call_tool": "on_call_tool", + "completion": "on_completion", + "get_prompt": "on_get_prompt", + "list_prompts": "on_list_prompts", + "list_resource_templates": "on_list_resource_templates", + "list_resources": "on_list_resources", + "list_tools": "on_list_tools", + "progress_notification": "on_progress", + "read_resource": "on_read_resource", + "set_logging_level": "on_set_logging_level", + "subscribe_resource": "on_subscribe_resource", + "unsubscribe_resource": "on_unsubscribe_resource", +} + +# Qualified-name sets the transformer resolves callees and constructors against. +# The two that name renamed classes are DERIVED from `SYMBOL_RENAMES` rather than +# written out, so a v1 import path added there can never be silently missing here. +FASTMCP_QNAMES: frozenset[str] = frozenset(old for old, new in SYMBOL_RENAMES.items() if new == "MCPServer") +MCPERROR_QNAMES: frozenset[str] = frozenset(old for old, new in SYMBOL_RENAMES.items() if new == "MCPError") +LOWLEVEL_SERVER_QNAMES: frozenset[str] = frozenset( + { + "mcp.server.Server", + "mcp.server.lowlevel.Server", + "mcp.server.lowlevel.server.Server", + } +) +ERRORDATA_QNAMES: frozenset[str] = frozenset( + { + "mcp.ErrorData", + "mcp.types.ErrorData", + } +) +# The v1 qualified names of the streamable-HTTP client (derived, like the class +# sets above), and the same set widened with the v2 spelling. A half-migrated +# `streamable_http_client(...) as (read, write, _)` still deserves the 3-tuple +# rewrite, but only a call through the v1 NAME proves the surrounding code is +# unmigrated, so only that form is flagged for its changed yield shape. +TRANSPORT_CLIENT_V1_QNAMES: frozenset[str] = frozenset( + old for old, new in SYMBOL_RENAMES.items() if new == "streamable_http_client" +) +TRANSPORT_CLIENT_QNAMES: frozenset[str] = TRANSPORT_CLIENT_V1_QNAMES | { + "mcp.client.streamable_http.streamable_http_client" +} +# Every keyword v1's `streamablehttp_client` accepted that v2's does not -- the +# whole point of `http_client=`. `terminate_on_close` survived and is not here. +TRANSPORT_CLIENT_REMOVED_PARAMS: frozenset[str] = frozenset( + {"auth", "headers", "httpx_client_factory", "sse_read_timeout", "timeout"} +) diff --git a/src/mcp-codemod/mcp_codemod/_runner.py b/src/mcp-codemod/mcp_codemod/_runner.py new file mode 100644 index 0000000000..4e71a777e6 --- /dev/null +++ b/src/mcp-codemod/mcp_codemod/_runner.py @@ -0,0 +1,130 @@ +"""Apply the v1 -> v2 transformer to files on disk. + +`run()` walks the given paths, transforms each Python file, and returns a report. +Files are read and written as UTF-8 (Python's own source default), independent of +the host locale, and their original line endings are preserved byte for byte. +A file is only ever written when its transformation succeeded end to end, so a +read, decode, or parse failure leaves that file exactly as it was found; every +failure is recorded in the report instead of aborting the run. +""" + +import os +from collections import Counter +from collections.abc import Iterable, Iterator, Sequence +from dataclasses import dataclass +from pathlib import Path + +from libcst import ParserSyntaxError + +from mcp_codemod._transformer import Result, transform + +__all__ = ["IGNORED_DIRECTORIES", "FileReport", "RunReport", "discover", "run"] + +# Directory names that never contain a user's own source, pruned during discovery. +IGNORED_DIRECTORIES: frozenset[str] = frozenset( + { + ".eggs", + ".git", + ".mypy_cache", + ".nox", + ".pytest_cache", + ".ruff_cache", + ".tox", + ".venv", + "__pycache__", + "build", + "dist", + "node_modules", + "site-packages", + "venv", + } +) + + +@dataclass(frozen=True, slots=True) +class FileReport: + """The outcome for one file. `error` is set instead of a result when it failed.""" + + path: Path + original: str + result: Result | None + error: str | None + + @property + def changed(self) -> bool: + """Whether the transformed code differs from what was read.""" + return self.result is not None and self.result.code != self.original + + +@dataclass(frozen=True, slots=True) +class RunReport: + """Everything `run()` did, in the order the files were visited.""" + + files: list[FileReport] + + @property + def changed(self) -> list[FileReport]: + return [report for report in self.files if report.changed] + + @property + def failed(self) -> list[FileReport]: + return [report for report in self.files if report.error is not None] + + @property + def diagnostics(self) -> Counter[str]: + """Diagnostic counts across every file, keyed by severity.""" + counts: Counter[str] = Counter() + for report in self.files: + if report.result is not None: + counts.update(diagnostic.severity for diagnostic in report.result.diagnostics) + return counts + + +def discover(paths: Sequence[Path]) -> Iterator[Path]: + """Yield every Python file under `paths`, pruning vendored and build directories. + + A path that is itself a file is yielded as-is, even without a `.py` suffix, so + an explicitly named file is always honoured. Ignored directories are pruned + from the walk itself rather than filtered from its results, so a populated + `.venv` or `node_modules` is never even visited. + """ + for path in paths: + if path.is_dir(): + found: list[Path] = [] + for directory, child_directories, files in os.walk(path): + child_directories[:] = [name for name in child_directories if name not in IGNORED_DIRECTORIES] + found.extend(Path(directory, name) for name in files if name.endswith(".py")) + yield from sorted(found) + else: + yield path + + +def run(paths: Iterable[Path], *, write: bool, add_markers: bool = True) -> RunReport: + """Transform every discovered file, writing the results back unless `write` is false. + + Each file is handled in isolation: one that cannot be read, decoded, or parsed is + recorded with its error and left exactly as it was found, one whose write fails is + recorded as such, and in either case the run continues to the next file. + """ + reports: list[FileReport] = [] + for path in paths: + source = "" + try: + # Bytes plus an explicit UTF-8 codec, never `read_text()`: Python source + # is UTF-8 regardless of the host locale, and the round trip must not + # rewrite the file's own line endings. + source = path.read_bytes().decode("utf-8") + result = transform(source, add_markers=add_markers) + except (OSError, UnicodeDecodeError, ParserSyntaxError) as exc: + reports.append(FileReport(path, source, None, f"{type(exc).__name__}: {exc}")) + continue + report = FileReport(path, source, result, None) + if write and report.changed: + try: + path.write_bytes(result.code.encode("utf-8")) + except OSError as exc: + error = f"the write failed and the file on disk may be incomplete: {exc}" + reports.append(FileReport(path, source, None, error)) + continue + reports.append(report) + return RunReport(reports) diff --git a/src/mcp-codemod/mcp_codemod/_transformer.py b/src/mcp-codemod/mcp_codemod/_transformer.py new file mode 100644 index 0000000000..220d20a336 --- /dev/null +++ b/src/mcp-codemod/mcp_codemod/_transformer.py @@ -0,0 +1,821 @@ +"""The v1 -> v2 source transformer. + +`transform()` is the whole programmatic surface: it takes one module's source text +and returns the rewritten text plus a list of diagnostics. Everything else in the +package (the CLI, the file runner) is a wrapper around it. + +The transformer is built on libCST and is deliberately conservative. A construct is +rewritten only when its meaning is unambiguous from the file alone: + +* Names and dotted references are resolved through the file's imports with + `QualifiedNameProvider`, so an aliased import is never broken and a user symbol + that happens to share a name with an mcp one is never touched. +* The camelCase -> snake_case attribute rename is restricted to an allowlist of the + field names v1's `mcp.types` actually declared; nothing else is ever considered. +* Anything whose correct rewrite depends on information that is not in the file -- + a receiver's runtime type, where a relocated keyword argument should land, how a + lowlevel handler body must be reshaped -- is never guessed at. It is left exactly + as written and an inline `# mcp-codemod:` marker is inserted above it instead, so + the remaining work is a single grep away. + +Running the transformer over its own output is a no-op: every rewrite produces v2 +spellings the tables no longer match, and marker insertion deduplicates against +markers that are already present. +""" + +from collections import Counter +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Literal, TypeVar, cast + +import libcst as cst +from libcst.helpers import get_full_name_for_node +from libcst.metadata import ( + CodeRange, + ExpressionContext, + ExpressionContextProvider, + MetadataWrapper, + PositionProvider, + QualifiedNameProvider, + QualifiedNameSource, +) + +from mcp_codemod._mappings import ( + CAMEL_FIELDS, + ERRORDATA_QNAMES, + FASTMCP_QNAMES, + LOWLEVEL_DECORATOR_METHODS, + LOWLEVEL_SERVER_QNAMES, + MCPERROR_QNAMES, + MODULE_RENAMES, + REMOVED_APIS, + REMOVED_ATTRS, + REMOVED_CTOR_PARAMS, + SYMBOL_RENAMES, + TRANSPORT_CLIENT_QNAMES, + TRANSPORT_CLIENT_REMOVED_PARAMS, + TRANSPORT_CLIENT_V1_QNAMES, + TRANSPORT_CTOR_PARAMS, +) + +__all__ = ["Diagnostic", "MARKER", "Result", "transform"] + +MARKER = "mcp-codemod" +"""The prefix every inserted comment starts with: `# mcp-codemod: ...`. + +After a run, `grep -rn '# mcp-codemod:'` lists exactly the sites that still need a +human. Markers whose message starts with `review:` accompany a rewrite that was +applied heuristically; all others mark something the codemod refused to rewrite. +""" + +Severity = Literal["info", "review", "manual"] + +# Longest prefix wins, so `mcp.server.fastmcp.prompts` matches `mcp.server.fastmcp` +# rather than a shorter overlapping key, should one ever be added. +_MODULE_RENAMES_LONGEST_FIRST: tuple[tuple[str, str], ...] = tuple( + sorted(MODULE_RENAMES.items(), key=lambda item: -len(item[0])) +) + +_NodeT = TypeVar("_NodeT", bound=cst.CSTNode) +_StatementT = TypeVar("_StatementT", bound="cst.SimpleStatementLine | cst.BaseCompoundStatement") + + +@dataclass(frozen=True, slots=True) +class Diagnostic: + """One finding the codemod wants a human to see. + + `severity` says what happened at the site: `info` means a safe rewrite was + applied and is reported for the record only; `review` means a rewrite was + applied but rests on a heuristic, so an inline marker asks for a look; `manual` + means nothing was rewritten and the change is the reader's to make. + """ + + line: int + transform: str + severity: Severity + message: str + + +@dataclass(frozen=True, slots=True) +class Result: + """What `transform()` produced for one module.""" + + code: str + diagnostics: list[Diagnostic] + rewrites: Counter[str] + + +def _rename_module(dotted: str) -> str | None: + """Return the v2 spelling of a v1 module path, or None if it is unchanged.""" + for old, new in _MODULE_RENAMES_LONGEST_FIRST: + if dotted == old or dotted.startswith(old + "."): + return new + dotted[len(old) :] + return None + + +def _dotted_name(dotted: str) -> cst.Attribute | cst.Name: + # A dotted module path always parses to a Name or a chain of Attributes, which + # is the only thing import nodes accept; `parse_expression` just cannot say so. + return cast("cst.Attribute | cst.Name", cst.parse_expression(dotted)) + + +def _names_the_sdk(module: str) -> bool: + """Whether a dotted module path belongs to the SDK: `mcp`, `mcp_types`, or below.""" + return module in ("mcp", "mcp_types") or module.startswith(("mcp.", "mcp_types.")) + + +def _with_markers(statement: _StatementT, messages: Sequence[str]) -> _StatementT: + """Prepend a `# mcp-codemod:` comment per distinct message not already present.""" + existing = {line.comment.value for line in statement.leading_lines if line.comment is not None} + # `dict.fromkeys` rather than a set: two identical findings on one statement + # (`a.isError or b.isError`) must produce one comment, in first-seen order. + comments = list(dict.fromkeys(f"# {MARKER}: {message}" for message in messages)) + fresh = [comment for comment in comments if comment not in existing] + if not fresh: + return statement + inserted = [cst.EmptyLine(comment=cst.Comment(comment)) for comment in fresh] + return statement.with_changes(leading_lines=[*statement.leading_lines, *inserted]) + + +class _PrePass(cst.CSTVisitor): + """Collect the facts the transformer needs before it rewrites anything. + + `imports_mcp` gates the name-only heuristics (the camelCase renames and the + removed-attribute markers) to files that import from the SDK at all -- v1's + `mcp` or v2's `mcp_types`, since a half-migrated file is just as much the + tool's business. `plain_imports` is the set of module paths bound by an + `import a.b.c` statement, so a dotted usage is only rewritten in lockstep + with the import that backs it; `unrenamed_reference_roots` is its complement, + the roots that something other than a renamed module still resolves through. + `user_declared_camel` is every allowlisted camelCase name some class body in + the file declares itself, where a rename can never be applied blindly. + `lowlevel_server_vars` records which local names were bound to a lowlevel + `Server(...)` so its decorators can be told apart from the syntactically + identical `MCPServer` ones. + """ + + METADATA_DEPENDENCIES = (QualifiedNameProvider,) + + def __init__(self) -> None: + self.imports_mcp = False + self.plain_imports: set[str] = set() + self.unrenamed_reference_roots: set[str] = set() + self.user_declared_camel: set[str] = set() + self.lowlevel_server_vars: set[str] = set() + self._class_depth = 0 + + def visit_ClassDef(self, node: cst.ClassDef) -> None: + self._class_depth += 1 + + def leave_ClassDef(self, original_node: cst.ClassDef) -> None: + self._class_depth -= 1 + + def visit_ImportFrom(self, node: cst.ImportFrom) -> None: + if node.relative or node.module is None: + return + if _names_the_sdk(get_full_name_for_node(node.module) or ""): + self.imports_mcp = True + + def visit_Import(self, node: cst.Import) -> None: + for alias in node.names: + name = get_full_name_for_node(alias.name) or "" + self.plain_imports.add(name) + if _names_the_sdk(name): + self.imports_mcp = True + + def visit_Attribute(self, node: cst.Attribute) -> None: + # Record the root package of every dotted reference that no module rename + # covers (e.g. the `mcp` in `mcp.ClientSession`). Renaming `import mcp.types` + # to `import mcp_types` also unbinds `mcp`, which is only a problem when one + # of these still needs it. + for qualified in self.get_metadata(QualifiedNameProvider, node, frozenset()): + if qualified.source is not QualifiedNameSource.LOCAL and _rename_module(qualified.name) is None: + self.unrenamed_reference_roots.add(qualified.name.split(".")[0]) + + def _record_lowlevel_server(self, value: cst.BaseExpression | None, target: cst.BaseExpression) -> None: + """When `value` calls the lowlevel `Server(...)`, remember the name it binds.""" + if not isinstance(value, cst.Call) or not isinstance(target, cst.Name): + return + qualified = { + q.name + for q in self.get_metadata(QualifiedNameProvider, value.func, frozenset()) + if q.source is not QualifiedNameSource.LOCAL + } + if qualified & LOWLEVEL_SERVER_QNAMES: + self.lowlevel_server_vars.add(target.value) + + def _record_class_field(self, target: cst.BaseExpression) -> None: + """Remember a camelCase name a class body in this file declares as its own.""" + if self._class_depth and isinstance(target, cst.Name) and target.value in CAMEL_FIELDS: + self.user_declared_camel.add(target.value) + + def visit_Assign(self, node: cst.Assign) -> None: + for target in node.targets: + self._record_class_field(target.target) + self._record_lowlevel_server(node.value, target.target) + + def visit_AnnAssign(self, node: cst.AnnAssign) -> None: + # `server: Server = Server("x")` is a different node from `server = Server("x")`. + self._record_class_field(node.target) + self._record_lowlevel_server(node.value, node.target) + + +class _V1ToV2(cst.CSTTransformer): + METADATA_DEPENDENCIES = (QualifiedNameProvider, PositionProvider, ExpressionContextProvider) + + def __init__(self, prepass: _PrePass, *, add_markers: bool) -> None: + super().__init__() + self._imports_mcp = prepass.imports_mcp + self._plain_imports = prepass.plain_imports + self._unrenamed_reference_roots = prepass.unrenamed_reference_roots + self._user_declared_camel = prepass.user_declared_camel + self._lowlevel_server_vars = prepass.lowlevel_server_vars + self._add_markers = add_markers + # One frame per open class definition: whether it subclasses `McpError`, + # so a `super().__init__(...)` inside one gets the constructor treatment. + self._in_mcperror_class: list[bool] = [] + self.diagnostics: list[Diagnostic] = [] + self.rewrites: Counter[str] = Counter() + # Name nodes that are not references to a binding and must never be renamed + # as one: the `.attr` of an attribute access, a `kwarg=` name, a parameter. + self._not_a_reference: set[int] = set() + # One frame of pending marker texts per open statement; markers emitted while + # a statement is being visited attach to that statement on the way out. The + # bottom frame is a sentinel so the stack is never empty. + self._pending_markers: list[list[str]] = [[]] + # One frame per `except` handler we are inside: the name it binds (or "") + # and whether its type names `McpError`. An inner handler that re-binds a + # name shadows the outer binding of that name; any other inner handler is + # transparent to the lookup. + self._except_bindings: list[tuple[str, bool]] = [] + # Calls that are a `with` item bound to a three-element tuple: the one form + # whose result tuple `leave_WithItem` can rewrite rather than flag. + self._narrowable_calls: set[int] = set() + + # -------------------------------------------------------------- bookkeeping + + def _qualified(self, node: cst.CSTNode) -> set[str]: + """The dotted names `node` resolves to through an import or to a builtin. + + Names that resolve only to a LOCAL binding are deliberately excluded. + `mcp = MCPServer(...)` is the most common variable name in real MCP code, + and at module scope an attribute chain on that variable carries a qualified + name spelled exactly like a module path (`mcp.types`); only a non-local + source proves the text really names the SDK (or, for `getattr` and + `hasattr`, the builtin). Every gate in this class goes through here. + """ + return { + q.name + for q in self.get_metadata(QualifiedNameProvider, node, frozenset()) + if q.source is not QualifiedNameSource.LOCAL + } + + def _root_still_bound(self, root: str, renamed_import: str) -> bool: + """Whether a plain import other than `renamed_import` still binds `root`. + + `import mcp.client.session` alongside `import mcp.types` keeps `mcp` bound + whatever happens to `mcp.types`, so renaming the latter unbinds nothing. + """ + for plain in self._plain_imports - {renamed_import}: + survives = _rename_module(plain) or plain + if survives == root or survives.startswith(f"{root}."): + return True + return False + + def _diag(self, node: cst.CSTNode, transform: str, severity: Severity, message: str) -> None: + # Without an explicit default, pyright cannot solve `get_metadata`'s + # generic for `PositionProvider`; the provider always yields a `CodeRange`. + line = cast(CodeRange, self.get_metadata(PositionProvider, node)).start.line + self.diagnostics.append(Diagnostic(line, transform, severity, message)) + if severity != "info": + self._pending_markers[-1].append(message) + + def _camel_diag(self, node: cst.CSTNode, camel: str, rewrote: str) -> None: + """Report one camelCase rename; a risky-tier name also gets a review marker.""" + if CAMEL_FIELDS[camel].tier == "risky": + self._diag(node, "attr_snake_case", "review", f"review: {rewrote}; verify the receiver is an mcp type") + else: + self._diag(node, "attr_snake_case", "info", rewrote) + self.rewrites["attr_snake_case"] += 1 + + def on_visit(self, node: cst.CSTNode) -> bool: + if isinstance(node, cst.SimpleStatementLine | cst.BaseCompoundStatement): + self._pending_markers.append([]) + return super().on_visit(node) + + def on_leave( + self, original_node: _NodeT, updated_node: _NodeT + ) -> _NodeT | cst.RemovalSentinel | cst.FlattenSentinel[_NodeT]: + result = super().on_leave(original_node, updated_node) + if isinstance(original_node, cst.SimpleStatementLine | cst.BaseCompoundStatement): + pending = self._pending_markers.pop() + if ( + pending + and self._add_markers + and isinstance(result, cst.SimpleStatementLine | cst.BaseCompoundStatement) + ): + # `result` is the same statement node `on_leave` was about to return, + # just with the marker comments prepended to its leading lines. + result = cast(_NodeT, _with_markers(result, pending)) + return result + + def visit_ClassDef(self, node: cst.ClassDef) -> None: + self._in_mcperror_class.append(any(self._qualified(base.value) & MCPERROR_QNAMES for base in node.bases)) + + def leave_ClassDef(self, original_node: cst.ClassDef, updated_node: cst.ClassDef) -> cst.ClassDef: + self._in_mcperror_class.pop() + return updated_node + + def _is_mcperror_super_init(self, node: cst.Call) -> bool: + """Whether `node` is a `super().__init__(...)` call inside a `McpError` subclass.""" + function = node.func + return ( + bool(self._in_mcperror_class) + and self._in_mcperror_class[-1] + and isinstance(function, cst.Attribute) + and function.attr.value == "__init__" + and isinstance(function.value, cst.Call) + and isinstance(function.value.func, cst.Name) + and function.value.func.value == "super" + ) + + def visit_Attribute(self, node: cst.Attribute) -> None: + self._not_a_reference.add(id(node.attr)) + + def visit_Arg(self, node: cst.Arg) -> None: + if node.keyword is not None: + self._not_a_reference.add(id(node.keyword)) + + def visit_Param(self, node: cst.Param) -> None: + self._not_a_reference.add(id(node.name)) + + def _is_mcperror_binding(self, name: str) -> bool: + """Whether the nearest enclosing handler that binds `name` catches `McpError`. + + Handlers that bind some other name (or none) are transparent, so a nested + `try`/`except` inside an `except McpError as e:` does not hide `e`; one + that re-binds `e` itself shadows the outer binding. + """ + for bound, is_mcperror in reversed(self._except_bindings): + if bound == name: + return is_mcperror + return False + + def visit_ExceptHandler(self, node: cst.ExceptHandler) -> None: + bound = "" + if node.name is not None and isinstance(node.name.name, cst.Name): + bound = node.name.name.value + # `except (McpError, ValueError) as e:` catches a tuple of types. + if isinstance(node.type, cst.Tuple): + caught: list[cst.BaseExpression] = [element.value for element in node.type.elements] + elif node.type is not None: + caught = [node.type] + else: + caught = [] + self._except_bindings.append((bound, any(self._qualified(kind) & MCPERROR_QNAMES for kind in caught))) + + def leave_ExceptHandler( + self, original_node: cst.ExceptHandler, updated_node: cst.ExceptHandler + ) -> cst.ExceptHandler: + self._except_bindings.pop() + return updated_node + + # ------------------------------------------------------------------ imports + + def leave_ImportFrom(self, original_node: cst.ImportFrom, updated_node: cst.ImportFrom) -> cst.ImportFrom: + if updated_node.relative or updated_node.module is None: + return updated_node + module = get_full_name_for_node(updated_node.module) or "" + + # `QualifiedNameProvider` resolves *references* to a binding; the import + # alias that creates the binding gets nothing, so it is handled here: a + # renamed symbol is renamed in place, and importing a name that no longer + # exists anywhere is marked (its uses elsewhere in the file are marked by + # `leave_Name`, but an import is often the only mention). + if not isinstance(updated_node.names, cst.ImportStar): + aliases: list[cst.ImportAlias] = [] + renamed_any = False + for alias in updated_node.names: + # In a `from X import name` statement the alias is always a bare Name. + qualified = f"{module}.{cst.ensure_type(alias.name, cst.Name).value}" + if (guidance := REMOVED_APIS.get(qualified)) is not None: + self._diag(original_node, "removed_api", "manual", f"`{qualified}` {guidance}") + elif new := SYMBOL_RENAMES.get(qualified): + renamed_any = True + self.rewrites["symbol_rename"] += 1 + alias = alias.with_changes(name=cst.Name(new)) + aliases.append(alias) + if renamed_any: + updated_node = updated_node.with_changes(names=aliases) + + if (renamed_module := _rename_module(module)) is not None: + self.rewrites["module_rename"] += 1 + updated_node = updated_node.with_changes(module=_dotted_name(renamed_module)) + return updated_node + + def leave_Import(self, original_node: cst.Import, updated_node: cst.Import) -> cst.Import: + aliases: list[cst.ImportAlias] = [] + renamed_any = False + for alias in updated_node.names: + dotted = get_full_name_for_node(alias.name) or "" + if (renamed := _rename_module(dotted)) is not None: + renamed_any = True + self.rewrites["module_rename"] += 1 + root = dotted.split(".")[0] + # `import mcp.types` also bound the name `mcp`. When the renamed + # module lives under a different root package, that binding goes + # away with the rewrite -- a problem only if some other reference + # in the file, one no module rename covers, still resolves through + # it, which the pre-pass recorded. (`PositionProvider` has no entry + # for an `ImportAlias`, so the diagnostic is anchored on the whole + # import statement.) + if ( + alias.asname is None + and renamed.split(".")[0] != root + and root in self._unrenamed_reference_roots + and not self._root_still_bound(root, dotted) + ): + self._diag( + original_node, + "module_rename", + "review", + f"review: `import {dotted}` also bound the name `{root}`; add `import {root}` " + f"back if this file still uses other `{root}.` names", + ) + alias = alias.with_changes(name=_dotted_name(renamed)) + aliases.append(alias) + return updated_node.with_changes(names=aliases) if renamed_any else updated_node + + def leave_SimpleStatementLine( + self, original_node: cst.SimpleStatementLine, updated_node: cst.SimpleStatementLine + ) -> cst.SimpleStatementLine | cst.FlattenSentinel[cst.BaseStatement]: + # `from import ` where `.` is a renamed module + # (e.g. `from mcp import types`) bound the OLD module object to a local name. + # A module cannot be renamed in place, so the binding has to come from a real + # import of the new module under the same local name instead. + if len(updated_node.body) != 1: + return updated_node + imported = updated_node.body[0] + if not isinstance(imported, cst.ImportFrom) or isinstance(imported.names, cst.ImportStar): + return updated_node + if imported.relative or imported.module is None: + return updated_node + parent = get_full_name_for_node(imported.module) or "" + moved: cst.ImportAlias | None = None + kept: list[cst.ImportAlias] = [] + for alias in imported.names: + if moved is None and isinstance(alias.name, cst.Name) and f"{parent}.{alias.name.value}" in MODULE_RENAMES: + moved = alias + else: + kept.append(alias) + if moved is None: + return updated_node + self.rewrites["module_rename"] += 1 + child = cst.ensure_type(moved.name, cst.Name).value + asname = moved.asname + local = cst.ensure_type(asname.name, cst.Name).value if asname is not None else child + target = MODULE_RENAMES[f"{parent}.{child}"] + replacement = cst.ensure_type(cst.parse_statement(f"import {target} as {local}"), cst.SimpleStatementLine) + if not kept: + # The replacement takes the original line's place, so it keeps that + # line's leading lines AND its trailing comment (`# noqa`, ...). + return replacement.with_changes( + leading_lines=updated_node.leading_lines, trailing_whitespace=updated_node.trailing_whitespace + ) + kept[-1] = kept[-1].with_changes(comma=cst.MaybeSentinel.DEFAULT) + remaining = updated_node.with_changes(body=[imported.with_changes(names=kept)]) + return cst.FlattenSentinel([remaining, replacement]) + + # ------------------------------------------- references, attributes, calls + + def leave_Name(self, original_node: cst.Name, updated_node: cst.Name) -> cst.Name: + if id(original_node) in self._not_a_reference: + return updated_node + for qualified in self._qualified(original_node): + if qualified in REMOVED_APIS: + self._diag(original_node, "removed_api", "manual", f"`{qualified}` {REMOVED_APIS[qualified]}") + return updated_node + new = SYMBOL_RENAMES.get(qualified) + # An aliased import (`... import FastMCP as F`) leaves `F` as the local + # spelling; only an occurrence of the original name is rewritten. + if new is not None and original_node.value == qualified.rsplit(".", 1)[-1]: + self.rewrites["symbol_rename"] += 1 + return updated_node.with_changes(value=new) + return updated_node + + def leave_Attribute(self, original_node: cst.Attribute, updated_node: cst.Attribute) -> cst.BaseExpression: + # A READ of `e.error.code` -> `e.code` when `e` is bound by `except McpError + # as e:`. Only the full three-part chain in a load context is touched: a bare + # `e.error` may be a whole `ErrorData` being passed somewhere, and an + # ASSIGNMENT like `e.error.message = ...` must stay as written -- v2's + # `MCPError.message` is a read-only property over the still-mutable `.error`, + # so collapsing a write would break code that works on v2 today. + if ( + original_node.attr.value in ("code", "message", "data") + and isinstance(original_node.value, cst.Attribute) + and original_node.value.attr.value == "error" + and isinstance(original_node.value.value, cst.Name) + and self._is_mcperror_binding(original_node.value.value.value) + and self.get_metadata(ExpressionContextProvider, original_node, None) is ExpressionContext.LOAD + ): + self.rewrites["mcperror_attr"] += 1 + return updated_node.with_changes(value=cst.ensure_type(updated_node.value, cst.Attribute).value) + + qualified_names = self._qualified(original_node) + dotted = get_full_name_for_node(original_node) + # The exact node naming a renamed module, written out as it was imported + # (the `mcp.types` inside `mcp.types.Tool` after `import mcp.types`). Only + # this innermost node is replaced -- the chain above it rebuilds around it -- + # and only in lockstep with the import that backs it: a bare `import mcp` + # also resolves `mcp.types`, but rewriting that usage would leave nothing + # importing the new module, so it is marked instead. + if dotted in MODULE_RENAMES and dotted in qualified_names: + if dotted in self._plain_imports: + self.rewrites["module_rename"] += 1 + return _dotted_name(MODULE_RENAMES[dotted]) + # `import mcp.server.fastmcp.server` also resolves its own prefix + # `mcp.server.fastmcp`; the longer node is the one being rewritten, so + # a name that is the prefix of some plain import needs nothing here. + if not any(plain.startswith(f"{dotted}.") for plain in self._plain_imports): + self._diag( + original_node, + "module_rename", + "manual", + f"`{dotted}` no longer exists: import `{MODULE_RENAMES[dotted]}` and use it here instead", + ) + return updated_node + + # A removed API or a renamed symbol reached as an attribute of an imported + # module, whether written out in full (`mcp.shared.exceptions.McpError`) or + # through a module alias (`memory.create_connected_server_and_client_session` + # after `from mcp.shared import memory`). The mirror of `leave_Name`, which + # sees the bare-name form. + for qualified in qualified_names: + if qualified in REMOVED_APIS: + self._diag(original_node, "removed_api", "manual", f"`{qualified}` {REMOVED_APIS[qualified]}") + return updated_node + new = SYMBOL_RENAMES.get(qualified) + if new is not None and original_node.attr.value == qualified.rsplit(".", 1)[-1]: + self.rewrites["symbol_rename"] += 1 + return updated_node.with_changes(attr=cst.Name(new)) + + # The remaining checks key on nothing but the attribute's name. They only + # apply in a file that imports the SDK, and never to a receiver the file's + # imports PROVE is something else (`multiprocessing.get_context(...)`): + # only a name the imports cannot explain could be an mcp object. + if not self._imports_mcp or any(not _names_the_sdk(qualified) for qualified in qualified_names): + return updated_node + + if (guidance := REMOVED_ATTRS.get(original_node.attr.value)) is not None: + self._diag(original_node, "removed_attr", "manual", guidance) + return updated_node + + camel = original_node.attr.value + if camel in CAMEL_FIELDS: + if camel in self._user_declared_camel: + # A class in this same file declares this exact field name, so some + # of its receivers are the user's own objects, whose declaration the + # codemod is not changing. Renaming those breaks them, so nothing is + # rewritten and every use is marked instead. + self._diag( + original_node, + "attr_snake_case", + "manual", + f"`.{camel}` is declared by a class in this file and is also a renamed mcp field: " + f"rename only the reads of mcp objects to `.{CAMEL_FIELDS[camel].snake}`", + ) + return updated_node + snake = CAMEL_FIELDS[camel].snake + self._camel_diag(original_node, camel, f"renamed `.{camel}` to `.{snake}`") + return updated_node.with_changes(attr=cst.Name(snake)) + + return updated_node + + def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Call: + callee = self._qualified(original_node.func) + + # `McpError(ErrorData(code=..., message=..., data=...))` flattened to + # `MCPError(code=..., message=..., data=...)`; the name itself is renamed by + # `leave_Name`, which has already run on the inner nodes. v1's constructor + # took a single `ErrorData`; when that one argument is anything other than + # an inline `ErrorData(...)` call there is nothing safe to unpack, so the + # call is marked instead -- v2's signature is `(code, message, data=None)`. + # A subclass's `super().__init__(...)` is the same constructor spelled the + # one way a qualified name cannot reach, so it gets the same treatment. + if (callee & MCPERROR_QNAMES or self._is_mcperror_super_init(original_node)) and len(original_node.args) == 1: + wrapped = original_node.args[0].value + if isinstance(wrapped, cst.Call) and self._qualified(wrapped.func) & ERRORDATA_QNAMES: + self.rewrites["mcperror_ctor"] += 1 + return updated_node.with_changes(args=cst.ensure_type(updated_node.args[0].value, cst.Call).args) + self._diag( + original_node, + "mcperror_ctor", + "manual", + "the `MCPError` constructor is now `MCPError(code, message, data=None)`: " + "unpack the `ErrorData` being passed here into those arguments", + ) + + # camelCase keyword arguments still work on v2 (every model field also + # accepts its camelCase alias by name), so unlike an attribute READ this + # rename is cosmetic and cannot break the call -- which is why, unlike the + # attribute form, the risky tier needs no review marker here. Every + # hand-migrated example in the SDK converted them, so the codemod follows + # suit, gated on the callee resolving into the SDK. + if any(name == "mcp" or name.startswith(("mcp.", "mcp_types.")) for name in callee): + arguments: list[cst.Arg] = [] + renamed_any = False + for argument in updated_node.args: + if argument.keyword is not None and argument.keyword.value in CAMEL_FIELDS: + renamed_any = True + self.rewrites["kwarg_snake_case"] += 1 + argument = argument.with_changes(keyword=cst.Name(CAMEL_FIELDS[argument.keyword.value].snake)) + arguments.append(argument) + if renamed_any: + updated_node = updated_node.with_changes(args=arguments) + + # Transport keywords on the `MCPServer` constructor moved to `run()` or the + # app methods. Where they belong depends on how the server is started -- + # possibly in another file -- so the kwarg is left in place (v2 rejects it + # loudly) rather than deleted, which would silently lose configuration. + if callee & FASTMCP_QNAMES: + for index, argument in enumerate(original_node.args): + keyword = argument.keyword.value if argument.keyword is not None else "" + # v1's positional order was `(name, instructions, ...)`; v2's second + # parameter is `title`, so anything positional after the name would + # silently land in the wrong parameter rather than fail. + if argument.star == "*" or (argument.keyword is None and argument.star == "" and index > 0): + self._diag( + argument, + "positional_ctor_param", + "manual", + "v1's positional constructor parameters after the name do not line up with " + "v2's (`title` is now second): pass these by keyword", + ) + elif keyword in TRANSPORT_CTOR_PARAMS: + self._diag( + argument, + "transport_ctor_param", + "manual", + f"`{keyword}=` is no longer a constructor argument: pass it to " + f"`run()` / `sse_app()` / `streamable_http_app()` where the server is started", + ) + elif keyword in REMOVED_CTOR_PARAMS: + self._diag(argument, "removed_ctor_param", "manual", f"`{keyword}=` {REMOVED_CTOR_PARAMS[keyword]}") + + # The streamable-HTTP client's keyword surface and yield shape both changed. + # The keyword check lives here so that it fires however the call is used (an + # `async with` item, `enter_async_context(...)`, an intermediate variable). + # Only the `as (read, write, _)` with-item form can have its unpacking + # REWRITTEN (`leave_WithItem` does); every other use of the v1 name is + # flagged, because where its result lands is not the codemod's to guess. + if callee & TRANSPORT_CLIENT_QNAMES: + for argument in original_node.args: + keyword = argument.keyword.value if argument.keyword is not None else "" + if keyword in TRANSPORT_CLIENT_REMOVED_PARAMS: + self._diag( + argument, + "transport_client_param", + "manual", + f"`{keyword}=` is no longer accepted here: configure it on an " + f"`httpx.AsyncClient` passed as `http_client=`", + ) + if callee & TRANSPORT_CLIENT_V1_QNAMES and id(original_node) not in self._narrowable_calls: + self._diag( + original_node, + "transport_client_unpack", + "manual", + "this client now yields `(read, write)` rather than " + "`(read, write, get_session_id)`: update the unpacking", + ) + + # A camelCase field name spelled as a string in `hasattr` / `getattr` / + # `setattr` is the one string position the rename applies to. Dict keys and + # other string literals are never touched: camelCase IS the wire format. + # Like the attribute form, this only applies in a file that imports the SDK. + if ( + self._imports_mcp + and callee & {"builtins.getattr", "builtins.hasattr", "builtins.setattr"} + and len(updated_node.args) >= 2 + ): + literal = updated_node.args[1].value + if isinstance(literal, cst.SimpleString): + value = literal.evaluated_value + if isinstance(value, str) and value in CAMEL_FIELDS: + snake = CAMEL_FIELDS[value].snake + builtin = get_full_name_for_node(original_node.func) + self._camel_diag(original_node, value, f'renamed "{value}" to "{snake}" in a {builtin} call') + replacement = cst.SimpleString(f"{literal.prefix}{literal.quote}{snake}{literal.quote}") + arguments = list(updated_node.args) + arguments[1] = arguments[1].with_changes(value=replacement) + updated_node = updated_node.with_changes(args=arguments) + + return updated_node + + def leave_Decorator(self, original_node: cst.Decorator, updated_node: cst.Decorator) -> cst.Decorator: + # A lowlevel `@server.call_tool()` is syntactically identical to a high-level + # `@mcp.tool()`; only the binding of the receiver tells them apart. Migrating + # the registration also means reordering statements and rewriting the handler + # signature, which a codemod must never guess at, so this is flag-only. + decorator = original_node.decorator + if ( + isinstance(decorator, cst.Call) + and isinstance(decorator.func, cst.Attribute) + and isinstance(decorator.func.value, cst.Name) + and decorator.func.value.value in self._lowlevel_server_vars + and decorator.func.attr.value in LOWLEVEL_DECORATOR_METHODS + ): + method = decorator.func.attr.value + self._diag( + original_node, + "lowlevel_decorator", + "manual", + f"the lowlevel `@{decorator.func.value.value}.{method}()` decorator was removed: pass " + f"`{LOWLEVEL_DECORATOR_METHODS[method]}=` to the `Server(...)` constructor and rewrite " + f"the handler to take `(ctx, params)` and return a result model", + ) + return updated_node + + def visit_WithItem(self, node: cst.WithItem) -> None: + # Only the `as (a, b, c)` form can have its unpacking REWRITTEN, which + # `leave_WithItem` does; a v1 client call used any other way (no `as`, a + # single name, `enter_async_context(...)`) gets the yield-shape marker + # from `leave_Call` instead. + if ( + isinstance(node.item, cst.Call) + and node.asname is not None + and isinstance(node.asname.name, cst.Tuple) + and len(node.asname.name.elements) == 3 + ): + self._narrowable_calls.add(id(node.item)) + + def leave_WithItem(self, original_node: cst.WithItem, updated_node: cst.WithItem) -> cst.WithItem: + # The removed-keyword check for these calls lives in `leave_Call`, which + # sees every form; this narrows the one form whose unpacking is rewritable. + if not isinstance(original_node.item, cst.Call): + return updated_node + if not self._qualified(original_node.item.func) & TRANSPORT_CLIENT_QNAMES: + return updated_node + target = original_node.asname + if target is None or not isinstance(target.name, cst.Tuple): + return updated_node + elements = list(cst.ensure_type(cst.ensure_type(updated_node.asname, cst.AsName).name, cst.Tuple).elements) + if len(elements) != 3: + return updated_node + # The third element used to be `get_session_id`, which no longer exists. + # When it was bound to a real name rather than `_`, later uses will break. + third = elements[2].value + if not (isinstance(third, cst.Name) and third.value == "_"): + self._diag( + original_node, + "transport_client_unpack", + "manual", + "the third value (`get_session_id`) is gone: remove every use of it", + ) + self.rewrites["transport_client_unpack"] += 1 + kept = [elements[0], elements[1].with_changes(comma=cst.MaybeSentinel.DEFAULT)] + narrowed = cst.ensure_type(updated_node.asname, cst.AsName) + return updated_node.with_changes( + asname=narrowed.with_changes(name=cst.ensure_type(narrowed.name, cst.Tuple).with_changes(elements=kept)) + ) + + def leave_Module(self, original_node: cst.Module, updated_node: cst.Module) -> cst.Module: + # libCST parses a comment above a module's FIRST statement into + # `Module.header`, not that statement's `leading_lines`, so the dedup in + # `_with_markers` cannot see a marker a previous run put there and would + # insert it again on every run. Drop any marker that is already rendered + # in the header; everything else about the statement is left alone. + if not updated_node.body: + return updated_node + in_header = {line.comment.value for line in original_node.header if line.comment is not None} + if not in_header: + return updated_node + first = updated_node.body[0] + kept_lines = [ + line + for line in first.leading_lines + if line.comment is None + or line.comment.value not in in_header + or not line.comment.value.startswith(f"# {MARKER}:") + ] + if len(kept_lines) == len(first.leading_lines): + return updated_node + return updated_node.with_changes(body=[first.with_changes(leading_lines=kept_lines), *updated_node.body[1:]]) + + +def transform(source: str, *, add_markers: bool = True) -> Result: + """Apply every v1 -> v2 rewrite to one module's source and report the rest. + + The returned code is always syntactically valid Python and preserves the input's + formatting and comments everywhere it was not rewritten. Sites the codemod + recognized but would not rewrite are described in `Result.diagnostics`; unless + `add_markers` is false, each one also gets an inline `# mcp-codemod:` comment. + + Raises: + libcst.ParserSyntaxError: if `source` is not parseable as Python. + """ + wrapper = MetadataWrapper(cst.parse_module(source)) + prepass = _PrePass() + wrapper.visit(prepass) + transformer = _V1ToV2(prepass, add_markers=add_markers) + module = wrapper.visit(transformer) + return Result(module.code, transformer.diagnostics, transformer.rewrites) diff --git a/src/mcp-codemod/mcp_codemod/cli.py b/src/mcp-codemod/mcp_codemod/cli.py new file mode 100644 index 0000000000..856e41e1bb --- /dev/null +++ b/src/mcp-codemod/mcp_codemod/cli.py @@ -0,0 +1,93 @@ +"""The `mcp-codemod` command line.""" + +import argparse +import sys +from collections.abc import Sequence +from difflib import unified_diff +from importlib.metadata import version +from pathlib import Path + +from mcp_codemod._runner import RunReport, discover, run +from mcp_codemod._transformer import MARKER + +__all__ = ["main"] + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="mcp-codemod", + description="Automated rewrites for migrating code between major versions of the MCP Python SDK.", + ) + parser.add_argument("--version", action="version", version=f"mcp-codemod {version('mcp-codemod')}") + migrations = parser.add_subparsers(dest="migration", required=True, metavar="MIGRATION") + v1_to_v2 = migrations.add_parser( + "v1-to-v2", + help="rewrite v1 SDK usage to v2 and mark every site that needs a human", + description=( + "Rewrite every unambiguous v1 -> v2 change in place and insert a " + f"`# {MARKER}:` comment above every site that needs a human. " + "Re-running on the result is a no-op, so it is safe to apply repeatedly." + ), + ) + v1_to_v2.add_argument("paths", nargs="+", type=Path, help="files or directories to rewrite") + v1_to_v2.add_argument("--dry-run", action="store_true", help="report what would change without writing anything") + v1_to_v2.add_argument("--diff", action="store_true", help="print a unified diff for every changed file") + v1_to_v2.add_argument("--no-markers", action="store_true", help=f"do not insert `# {MARKER}:` comments") + return parser + + +def _print_diffs(report: RunReport) -> None: + for file in report.files: + if file.result is None or not file.changed: + continue + sys.stdout.writelines( + unified_diff( + file.original.splitlines(keepends=True), + file.result.code.splitlines(keepends=True), + fromfile=str(file.path), + tofile=str(file.path), + ) + ) + + +def _print_summary(report: RunReport, *, roots: Sequence[Path], dry_run: bool, markers: bool) -> None: + for file in report.files: + if file.result is None: + print(f"{file.path}: failed ({file.error})", file=sys.stderr) + continue + if not file.changed and not file.result.diagnostics: + continue + rewritten = sum(file.result.rewrites.values()) + attention = sum(1 for diagnostic in file.result.diagnostics if diagnostic.severity != "info") + print(f"{file.path}: {rewritten} rewritten, {attention} need review") + + print(f"\n{len(report.changed)} of {len(report.files)} files rewritten.") + severities = report.diagnostics + attention = severities["review"] + severities["manual"] + if attention: + if markers and not dry_run: + targets = " ".join(str(root) for root in roots) + print(f"{attention} sites still need a human. Find them with:\n grep -rn '# {MARKER}:' {targets}") + else: + # No marker comment landed on disk, so this report is the only record. + print(f"{attention} sites still need a human:") + for file in report.files: + if file.result is None: + continue + for diagnostic in file.result.diagnostics: + if diagnostic.severity != "info": + print(f" {file.path}:{diagnostic.line}: {diagnostic.message}") + if dry_run: + print("Dry run: nothing was written.") + if report.failed: + print(f"{len(report.failed)} files failed.", file=sys.stderr) + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the codemod. Returns 0, or 1 if any file failed.""" + args = _build_parser().parse_args(argv) + report = run(discover(args.paths), write=not args.dry_run, add_markers=not args.no_markers) + if args.diff: + _print_diffs(report) + _print_summary(report, roots=args.paths, dry_run=args.dry_run, markers=not args.no_markers) + return 1 if report.failed else 0 diff --git a/src/mcp-codemod/mcp_codemod/py.typed b/src/mcp-codemod/mcp_codemod/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/mcp-codemod/pyproject.toml b/src/mcp-codemod/pyproject.toml new file mode 100644 index 0000000000..4c75dcff6f --- /dev/null +++ b/src/mcp-codemod/pyproject.toml @@ -0,0 +1,58 @@ +[project] +name = "mcp-codemod" +dynamic = ["version"] +description = "Automated rewrites for migrating code between major versions of the MCP Python SDK" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +maintainers = [ + { name = "David Soria Parra", email = "davidsp@anthropic.com" }, + { name = "Marcelo Trylesinski", email = "marcelotryle@gmail.com" }, + { name = "Max Isbey", email = "maxisbey@anthropic.com" }, + { name = "Felix Weinberger", email = "fweinberger@anthropic.com" }, +] +keywords = ["mcp", "llm", "automation", "codemod", "migration"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [ + # 1.8.6 is the first release verified to parse and run on Python 3.14, which + # the SDK supports; older floors trade an untested resolution for nothing. + "libcst>=1.8.6", +] + +[project.scripts] +mcp-codemod = "mcp_codemod.cli:main" + +[project.urls] +Homepage = "https://modelcontextprotocol.io" +Documentation = "https://py.sdk.modelcontextprotocol.io/v2/" +Repository = "https://github.com/modelcontextprotocol/python-sdk" +Issues = "https://github.com/modelcontextprotocol/python-sdk/issues" + +[build-system] +requires = ["hatchling", "uv-dynamic-versioning"] +build-backend = "hatchling.build" + +[tool.hatch.version] +source = "uv-dynamic-versioning" + +[tool.uv-dynamic-versioning] +vcs = "git" +style = "pep440" +bump = true + +[tool.hatch.build.targets.sdist.force-include] +"../../LICENSE" = "LICENSE" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_codemod"] diff --git a/tests/codemod/__init__.py b/tests/codemod/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/codemod/test_cli.py b/tests/codemod/test_cli.py new file mode 100644 index 0000000000..36f46258d5 --- /dev/null +++ b/tests/codemod/test_cli.py @@ -0,0 +1,167 @@ +"""The `mcp-codemod` command line: its flags, output, and exit codes.""" + +import textwrap +from pathlib import Path + +import pytest +from mcp_codemod.cli import main + + +def test_v1_to_v2_rewrites_files_and_prints_a_summary(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """`v1-to-v2` rewrites a v1 file in place and the summary says how many files changed.""" + path = tmp_path / "server.py" + path.write_text("from mcp.server.fastmcp import FastMCP\n") + + assert main(["v1-to-v2", str(tmp_path)]) == 0 + + assert "mcp.server.mcpserver" in path.read_text() + assert "1 of 1 files rewritten" in capsys.readouterr().out + + +def test_dry_run_reports_without_writing(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """`--dry-run` reports what would change but leaves the file exactly as it was.""" + source = "from mcp.server.fastmcp import FastMCP\n" + path = tmp_path / "server.py" + path.write_text(source) + + assert main(["v1-to-v2", "--dry-run", str(tmp_path)]) == 0 + + assert path.read_text() == source + assert "Dry run" in capsys.readouterr().out + + +def test_diff_prints_a_unified_diff(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """`--diff` prints a unified diff removing the v1 import and adding the v2 one.""" + path = tmp_path / "server.py" + path.write_text("from mcp.server.fastmcp import FastMCP\n") + + main(["v1-to-v2", "--diff", str(tmp_path)]) + + out = capsys.readouterr().out + assert "-from mcp.server.fastmcp import FastMCP\n" in out + assert "+from mcp.server.mcpserver import MCPServer\n" in out + + +def test_no_markers_suppresses_comment_insertion(tmp_path: Path) -> None: + """`--no-markers` still rewrites the file but inserts no `# mcp-codemod:` comment at the site needing a human.""" + path = tmp_path / "server.py" + path.write_text( + textwrap.dedent("""\ + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("demo", mount_path="/old") + """) + ) + + main(["v1-to-v2", "--no-markers", str(tmp_path)]) + + rewritten = path.read_text() + assert "mcp.server.mcpserver" in rewritten + assert "# mcp-codemod" not in rewritten + + +def test_a_parse_failure_returns_a_nonzero_exit_and_is_reported_to_stderr( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A file that fails to parse makes `main` return 1 and is named on stderr.""" + path = tmp_path / "broken.py" + path.write_text("def broken(:\n") + + assert main(["v1-to-v2", str(tmp_path)]) == 1 + + assert str(path) in capsys.readouterr().err + + +def test_version_prints_the_installed_version(capsys: pytest.CaptureFixture[str]) -> None: + """`--version` prints `mcp-codemod ` from the installed distribution and exits.""" + with pytest.raises(SystemExit): + main(["--version"]) + assert capsys.readouterr().out.startswith("mcp-codemod ") + + +def test_a_missing_migration_argument_is_an_argparse_error() -> None: + """Invoking the CLI without naming a migration is an argparse usage error with exit code 2.""" + with pytest.raises(SystemExit) as excinfo: + main([]) + assert excinfo.value.code == 2 + + +def test_the_grep_hint_appears_only_when_there_are_markers(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """The `grep -rn '# mcp-codemod:'` follow-up hint is printed only when some site still needs a human.""" + clean = tmp_path / "clean.py" + clean.write_text('from mcp.server.mcpserver import MCPServer\n\nmcp = MCPServer("demo")\n') + assert main(["v1-to-v2", str(clean)]) == 0 + assert "grep -rn" not in capsys.readouterr().out + + flagged = tmp_path / "flagged.py" + flagged.write_text( + textwrap.dedent("""\ + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("demo", port=8000) + """) + ) + assert main(["v1-to-v2", str(flagged)]) == 0 + assert "grep -rn '# mcp-codemod:'" in capsys.readouterr().out + + +def test_the_per_file_line_reports_review_counts(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """A file whose rewrite rests on a heuristic gets a per-file line counting the sites that need review.""" + path = tmp_path / "pager.py" + path.write_text( + textwrap.dedent("""\ + from mcp.types import ListToolsResult + + def next_page(result: ListToolsResult) -> str | None: + return result.nextCursor + """) + ) + assert main(["v1-to-v2", str(path)]) == 0 + [file_line] = [line for line in capsys.readouterr().out.splitlines() if line.startswith(f"{path}:")] + assert file_line.endswith("1 need review") + + +def test_an_unchanged_file_with_no_diagnostics_produces_no_per_file_line( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """An already-v2 file is counted in the run total but never gets its own per-file count line.""" + path = tmp_path / "clean.py" + path.write_text('from mcp.server.mcpserver import MCPServer\n\nmcp = MCPServer("demo")\n') + assert main(["v1-to-v2", str(path)]) == 0 + out = capsys.readouterr().out + assert "0 of 1 files rewritten" in out + assert f"{path}:" not in out + + +def test_diff_skips_files_the_codemod_did_not_change(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """`--diff` prints a hunk only for the files that changed, so an already-migrated + file sitting next to a v1 one contributes nothing to the diff output.""" + (tmp_path / "old.py").write_text("from mcp.server.fastmcp import FastMCP\n") + (tmp_path / "new.py").write_text("from mcp.server.mcpserver import MCPServer\n") + assert main(["v1-to-v2", "--diff", str(tmp_path)]) == 0 + out = capsys.readouterr().out + assert f"--- {tmp_path / 'old.py'}" in out + assert f"--- {tmp_path / 'new.py'}" not in out + + +def test_a_dry_run_lists_every_site_instead_of_the_grep_hint( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """With `--dry-run` no marker lands on disk, so the grep hint would find + nothing; the summary lists each site that needs a human directly instead. + Renames reported only for the record (`info`) are not part of that list. + """ + target = tmp_path / "server.py" + target.write_text( + 'from mcp.server.fastmcp import FastMCP\n\nmcp = FastMCP("demo", mount_path="/x")\nprint(tool.inputSchema)\n' + ) + broken = tmp_path / "broken.py" + broken.write_text("def (\n") + code = main(["v1-to-v2", "--dry-run", str(tmp_path)]) + captured = capsys.readouterr() + assert code == 1 + assert f"{target}:3: `mount_path=`" in captured.out + assert "inputSchema" not in captured.out + assert "grep -rn" not in captured.out + assert "Dry run: nothing was written." in captured.out + assert "failed (" in captured.err diff --git a/tests/codemod/test_mappings.py b/tests/codemod/test_mappings.py new file mode 100644 index 0000000000..34911c3cde --- /dev/null +++ b/tests/codemod/test_mappings.py @@ -0,0 +1,417 @@ +"""Pin the codemod's mapping tables against the installed v2 package. + +The tables in `mcp_codemod._mappings` drive every rewrite the tool makes, so each +one is held to two bars here: an exact literal so a silently-deleted row can never +shrink the suite, and a check against the installed `mcp` / `mcp_types` packages +so a rename target or a removal claim cannot drift as v2 evolves. A failure here +means the table is wrong, not the transformer. +""" + +import inspect +from importlib import import_module + +import mcp_types +import pytest +from mcp_codemod import transform +from mcp_codemod._mappings import ( + CAMEL_FIELDS, + LOWLEVEL_DECORATOR_METHODS, + MODULE_RENAMES, + REMOVED_APIS, + REMOVED_ATTRS, + REMOVED_CTOR_PARAMS, + SYMBOL_RENAMES, + TRANSPORT_CLIENT_REMOVED_PARAMS, + TRANSPORT_CTOR_PARAMS, +) +from pydantic import BaseModel + +import mcp.client.session +import mcp.server.mcpserver +from mcp.client.streamable_http import streamable_http_client +from mcp.server.lowlevel import Server +from mcp.server.mcpserver import MCPServer + + +def _v2_resolves(qualified: str) -> bool: + """Whether a dotted name resolves on the installed v2 package.""" + module_path, _, attribute = qualified.rpartition(".") + try: + return hasattr(import_module(module_path), attribute) + except ImportError: + return False + + +def test_the_module_rename_table_is_exact_and_every_target_imports() -> None: + """The module table is exactly the known set of moves, and every target exists on v2.""" + assert MODULE_RENAMES == { + "mcp.server.fastmcp": "mcp.server.mcpserver", + "mcp.server.fastmcp.server": "mcp.server.mcpserver.server", + "mcp.shared.version": "mcp_types.version", + "mcp.types": "mcp_types", + } + for target in MODULE_RENAMES.values(): + import_module(target) + + +def test_the_symbol_rename_table_is_exact() -> None: + """The symbol table covers every v1 import path of each renamed name, and nothing else.""" + assert SYMBOL_RENAMES == { + "mcp.server.FastMCP": "MCPServer", + "mcp.server.fastmcp.FastMCP": "MCPServer", + "mcp.server.fastmcp.server.FastMCP": "MCPServer", + "mcp.server.fastmcp.exceptions.FastMCPError": "MCPServerError", + "mcp.McpError": "MCPError", + "mcp.shared.exceptions.McpError": "MCPError", + "mcp.client.streamable_http.streamablehttp_client": "streamable_http_client", + "mcp.types.Content": "ContentBlock", + "mcp.types.ResourceReference": "ResourceTemplateReference", + } + + +@pytest.mark.parametrize(("qualified", "new_name"), sorted(SYMBOL_RENAMES.items())) +def test_rewriting_an_import_of_each_renamed_symbol_resolves_on_v2(qualified: str, new_name: str) -> None: + """Transforming a v1 import of a renamed symbol yields an import the installed v2 satisfies.""" + module_path, _, old_name = qualified.rpartition(".") + rewritten = transform(f"from {module_path} import {old_name}\n").code + namespace: dict[str, object] = {} + exec(rewritten, namespace) + assert new_name in namespace + + +def test_every_removed_api_is_absent_from_the_installed_v2_package() -> None: + """Each flagged removal really is gone from v2; if one comes back, its flag becomes a lie.""" + assert set(REMOVED_APIS) == { + "mcp.client.websocket.websocket_client", + "mcp.os.win32.utilities.terminate_windows_process", + "mcp.server.websocket.websocket_server", + "mcp.shared.context.RequestContext", + "mcp.shared.memory.create_connected_server_and_client_session", + "mcp.server.lowlevel.server.request_ctx", + "mcp.shared.progress.Progress", + "mcp.shared.progress.ProgressContext", + "mcp.shared.progress.progress", + "mcp.shared.session.BaseSession", + "mcp.types.AnyFunction", + "mcp.types.ClientNotificationType", + "mcp.types.ClientRequestType", + "mcp.types.ClientResultType", + "mcp.types.Cursor", + "mcp.types.MethodT", + "mcp.types.RequestParams.Meta", + "mcp.types.NotificationParamsT", + "mcp.types.RequestParamsT", + "mcp.types.ServerNotificationType", + "mcp.types.ServerRequestType", + "mcp.types.ServerResultType", + "mcp.types.TASK_FORBIDDEN", + "mcp.types.TASK_OPTIONAL", + "mcp.types.TASK_REQUIRED", + "mcp.types.TASK_STATUS_CANCELLED", + "mcp.types.TASK_STATUS_COMPLETED", + "mcp.types.TASK_STATUS_FAILED", + "mcp.types.TASK_STATUS_INPUT_REQUIRED", + "mcp.types.TASK_STATUS_WORKING", + "mcp.types.TaskExecutionMode", + } + for qualified in REMOVED_APIS: + assert not _v2_resolves(qualified), qualified + + +def test_every_camelcase_rename_target_is_a_field_on_an_installed_v2_model() -> None: + """Each snake_case target really is a v2 field, so the rename never invents a name.""" + assert len(CAMEL_FIELDS) == 40 + v2_fields = { + name + for obj in vars(mcp_types).values() + if inspect.isclass(obj) and issubclass(obj, BaseModel) + for name in obj.model_fields + } + for camel, field in CAMEL_FIELDS.items(): + assert field.snake in v2_fields, camel + + +def test_progress_token_is_in_the_risky_tier() -> None: + """`progressToken` had two v1 homes with two v2 fates: `ProgressNotificationParams` + renamed it to `progress_token`, but `RequestParams.Meta` became a TypedDict keyed + by the camelCase wire spelling, so a rename there is wrong and needs human eyes. + """ + assert CAMEL_FIELDS["progressToken"].tier == "risky" + + +def test_the_constructor_keyword_tables_match_the_v2_signatures() -> None: + """No flagged constructor keyword survives on the v2 `MCPServer.__init__`, and every + lowlevel decorator maps to a real `on_*` keyword on the v2 `Server`. A keyword v2 + kept that the tables flag (`debug`, `log_level`, and `dependencies` all survived + one alpha or another) would tell the user a lie they cannot reconcile. + + Where each moved keyword landed is not asserted here: `MCPServer.run` forwards + `**kwargs` to the app builders, so its signature cannot show them. + """ + constructor = set(inspect.signature(MCPServer.__init__).parameters) + assert not (TRANSPORT_CTOR_PARAMS | set(REMOVED_CTOR_PARAMS)) & constructor + assert set(LOWLEVEL_DECORATOR_METHODS.values()) <= set(inspect.signature(Server.__init__).parameters) + + +# Every name defined publicly at the top level of v1's `mcp/types.py`, extracted +# from `origin/v1.x` and frozen here because v1 is closed history. See the test +# below for why the codemod must account for every single one. +_V1_TYPES_PUBLIC_NAMES = ( + "Annotations", + "AnyFunction", + "AudioContent", + "BaseMetadata", + "BlobResourceContents", + "CONNECTION_CLOSED", + "CallToolRequest", + "CallToolRequestParams", + "CallToolResult", + "CancelTaskRequest", + "CancelTaskRequestParams", + "CancelTaskResult", + "CancelledNotification", + "CancelledNotificationParams", + "ClientCapabilities", + "ClientNotification", + "ClientNotificationType", + "ClientRequest", + "ClientRequestType", + "ClientResult", + "ClientResultType", + "ClientTasksCapability", + "ClientTasksRequestsCapability", + "CompleteRequest", + "CompleteRequestParams", + "CompleteResult", + "Completion", + "CompletionArgument", + "CompletionContext", + "CompletionsCapability", + "Content", + "ContentBlock", + "CreateMessageRequest", + "CreateMessageRequestParams", + "CreateMessageResult", + "CreateMessageResultWithTools", + "CreateTaskResult", + "Cursor", + "DEFAULT_NEGOTIATED_VERSION", + "ElicitCompleteNotification", + "ElicitCompleteNotificationParams", + "ElicitRequest", + "ElicitRequestFormParams", + "ElicitRequestParams", + "ElicitRequestURLParams", + "ElicitRequestedSchema", + "ElicitResult", + "ElicitationCapability", + "ElicitationRequiredErrorData", + "EmbeddedResource", + "EmptyResult", + "ErrorData", + "FormElicitationCapability", + "GetPromptRequest", + "GetPromptRequestParams", + "GetPromptResult", + "GetTaskPayloadRequest", + "GetTaskPayloadRequestParams", + "GetTaskPayloadResult", + "GetTaskRequest", + "GetTaskRequestParams", + "GetTaskResult", + "INTERNAL_ERROR", + "INVALID_PARAMS", + "INVALID_REQUEST", + "Icon", + "ImageContent", + "Implementation", + "IncludeContext", + "InitializeRequest", + "InitializeRequestParams", + "InitializeResult", + "InitializedNotification", + "JSONRPCError", + "JSONRPCMessage", + "JSONRPCNotification", + "JSONRPCRequest", + "JSONRPCResponse", + "LATEST_PROTOCOL_VERSION", + "ListPromptsRequest", + "ListPromptsResult", + "ListResourceTemplatesRequest", + "ListResourceTemplatesResult", + "ListResourcesRequest", + "ListResourcesResult", + "ListRootsRequest", + "ListRootsResult", + "ListTasksRequest", + "ListTasksResult", + "ListToolsRequest", + "ListToolsResult", + "LoggingCapability", + "LoggingLevel", + "LoggingMessageNotification", + "LoggingMessageNotificationParams", + "METHOD_NOT_FOUND", + "MethodT", + "ModelHint", + "ModelPreferences", + "Notification", + "NotificationParams", + "NotificationParamsT", + "PARSE_ERROR", + "PaginatedRequest", + "PaginatedRequestParams", + "PaginatedResult", + "PingRequest", + "ProgressNotification", + "ProgressNotificationParams", + "ProgressToken", + "Prompt", + "PromptArgument", + "PromptListChangedNotification", + "PromptMessage", + "PromptReference", + "PromptsCapability", + "ReadResourceRequest", + "ReadResourceRequestParams", + "ReadResourceResult", + "RelatedTaskMetadata", + "Request", + "RequestId", + "RequestParams", + "RequestParamsT", + "Resource", + "ResourceContents", + "ResourceLink", + "ResourceListChangedNotification", + "ResourceReference", + "ResourceTemplate", + "ResourceTemplateReference", + "ResourceUpdatedNotification", + "ResourceUpdatedNotificationParams", + "ResourcesCapability", + "Result", + "Role", + "Root", + "RootsCapability", + "RootsListChangedNotification", + "SamplingCapability", + "SamplingContent", + "SamplingContextCapability", + "SamplingMessage", + "SamplingMessageContentBlock", + "SamplingToolsCapability", + "ServerCapabilities", + "ServerNotification", + "ServerNotificationType", + "ServerRequest", + "ServerRequestType", + "ServerResult", + "ServerResultType", + "ServerTasksCapability", + "ServerTasksRequestsCapability", + "SetLevelRequest", + "SetLevelRequestParams", + "StopReason", + "SubscribeRequest", + "SubscribeRequestParams", + "TASK_FORBIDDEN", + "TASK_OPTIONAL", + "TASK_REQUIRED", + "TASK_STATUS_CANCELLED", + "TASK_STATUS_COMPLETED", + "TASK_STATUS_FAILED", + "TASK_STATUS_INPUT_REQUIRED", + "TASK_STATUS_WORKING", + "Task", + "TaskExecutionMode", + "TaskMetadata", + "TaskStatus", + "TaskStatusNotification", + "TaskStatusNotificationParams", + "TasksCallCapability", + "TasksCancelCapability", + "TasksCreateElicitationCapability", + "TasksCreateMessageCapability", + "TasksElicitationCapability", + "TasksListCapability", + "TasksSamplingCapability", + "TasksToolsCapability", + "TextContent", + "TextResourceContents", + "Tool", + "ToolAnnotations", + "ToolChoice", + "ToolExecution", + "ToolListChangedNotification", + "ToolResultContent", + "ToolUseContent", + "ToolsCapability", + "URL_ELICITATION_REQUIRED", + "UnsubscribeRequest", + "UnsubscribeRequestParams", + "UrlElicitationCapability", +) + + +def test_every_public_name_of_a_renamed_v1_module_is_importable_or_accounted_for() -> None: + """A module rename promises that what a file imported from the old module can be + imported from the new one. For every public name v1 defined there, that has to + be literally true of the installed v2 package -- or the name must be in + `SYMBOL_RENAMES` (it gets rewritten) or `REMOVED_APIS` (it gets marked). + Anything else would let the codemod produce an import that cannot resolve, with + no diagnostic. The name lists are v1's, so they are frozen history; a new + `MODULE_RENAMES` row must bring its own list here. + """ + renamed_v1_modules = { + "mcp.types": _V1_TYPES_PUBLIC_NAMES, + # v1's `mcp/server/fastmcp/__init__.py` declared this `__all__` explicitly. + "mcp.server.fastmcp": ("FastMCP", "Context", "Image", "Audio", "Icon"), + # The names users import from the `server` module itself; its other + # module-level definitions are internals nobody imports. + "mcp.server.fastmcp.server": ("FastMCP", "Context", "Settings"), + "mcp.shared.version": ("LATEST_PROTOCOL_VERSION", "SUPPORTED_PROTOCOL_VERSIONS"), + } + assert set(renamed_v1_modules) == set(MODULE_RENAMES) + unaccounted = [ + f"{old}.{name}" + for old, names in renamed_v1_modules.items() + for name in names + if not hasattr(import_module(MODULE_RENAMES[old]), name) + and f"{old}.{name}" not in SYMBOL_RENAMES + and f"{old}.{name}" not in REMOVED_APIS + ] + assert unaccounted == [] + + +def test_no_removed_attribute_name_is_spelled_by_a_living_v2_api() -> None: + """The removed-attribute table matches by NAME alone, so a name only qualifies if + nothing public on v2 still spells it; otherwise the marker would flag working + code. `request_context` fails exactly this bar -- `Context.request_context` is the + documented v2 lifespan idiom -- which is why it is not in the table. + """ + assert set(REMOVED_ATTRS) == {"get_context", "get_server_capabilities"} + living = { + name + for module in (mcp, mcp.client.session, mcp.server.mcpserver, mcp_types) + for obj in vars(module).values() + if inspect.isclass(obj) + for name in dir(obj) + if not name.startswith("_") + } + assert "request_context" in living + assert not set(REMOVED_ATTRS) & living + + +def test_the_removed_client_keyword_set_is_exactly_v1_minus_v2() -> None: + """The flagged client keywords are exactly the ones v1's `streamablehttp_client` + accepted and v2's client does not: one it kept must not be flagged (a lie), and + one it dropped must be (a silent `TypeError`). v1's signature is frozen history; + v2's is introspected. + """ + v1_parameters = frozenset( + {"url", "headers", "timeout", "sse_read_timeout", "terminate_on_close", "httpx_client_factory", "auth"} + ) + v2_parameters = frozenset(inspect.signature(streamable_http_client).parameters) + assert v1_parameters - v2_parameters == TRANSPORT_CLIENT_REMOVED_PARAMS diff --git a/tests/codemod/test_runner.py b/tests/codemod/test_runner.py new file mode 100644 index 0000000000..1196e7dd54 --- /dev/null +++ b/tests/codemod/test_runner.py @@ -0,0 +1,215 @@ +"""File discovery, per-file isolation, and writing in `mcp_codemod._runner`.""" + +import textwrap +from pathlib import Path + +import pytest +from inline_snapshot import snapshot +from mcp_codemod._runner import discover, run + + +def test_discover_yields_every_python_file_under_a_directory_sorted(tmp_path: Path) -> None: + """`discover` over a directory yields every `.py` file beneath it, in sorted order, and nothing else.""" + (tmp_path / "b.py").write_text("") + (tmp_path / "a.py").write_text("") + (tmp_path / "nested").mkdir() + (tmp_path / "nested" / "c.py").write_text("") + (tmp_path / "notes.txt").write_text("") + + assert list(discover([tmp_path])) == [tmp_path / "a.py", tmp_path / "b.py", tmp_path / "nested" / "c.py"] + + +def test_discover_prunes_vendored_directories(tmp_path: Path) -> None: + """`discover` never yields a file under a vendored directory such as `.venv` or `node_modules`.""" + (tmp_path / ".venv" / "sub").mkdir(parents=True) + (tmp_path / ".venv" / "sub" / "vendored.py").write_text("") + (tmp_path / "node_modules").mkdir() + (tmp_path / "node_modules" / "dep.py").write_text("") + (tmp_path / "app.py").write_text("") + + assert list(discover([tmp_path])) == [tmp_path / "app.py"] + + +def test_discover_honours_an_explicitly_named_file(tmp_path: Path) -> None: + """A path that is itself a file is yielded as-is, even without a `.py` suffix.""" + script = tmp_path / "script" + script.write_text("x = 1\n") + + assert list(discover([script])) == [script] + + +def test_run_writes_only_the_files_that_changed(tmp_path: Path) -> None: + """`run(write=True)` rewrites the file the transformer changed and leaves an already-v2 file byte-identical.""" + v1_source = textwrap.dedent("""\ + from mcp.server.fastmcp import FastMCP + + server = FastMCP("legacy") + """) + v2_source = textwrap.dedent("""\ + from mcp.server.mcpserver import MCPServer + + app = MCPServer("already migrated") + """) + v1_path = tmp_path / "v1_module.py" + v2_path = tmp_path / "v2_module.py" + v1_path.write_text(v1_source) + v2_path.write_text(v2_source) + + run([v1_path, v2_path], write=True) + + assert v1_path.read_text() == snapshot("""\ +from mcp.server.mcpserver import MCPServer + +server = MCPServer("legacy") +""") + assert v2_path.read_text() == v2_source + + +def test_a_dry_run_leaves_every_file_untouched(tmp_path: Path) -> None: + """`run(write=False)` reports a file as changed without writing the transformed code back to disk.""" + source = textwrap.dedent("""\ + from mcp.server.fastmcp import FastMCP + + server = FastMCP("legacy") + """) + path = tmp_path / "module.py" + path.write_text(source) + + report = run([path], write=False) + + assert path.read_text() == source + assert [file.path for file in report.changed] == [path] + + +def test_a_file_that_fails_to_parse_is_left_untouched_and_reported(tmp_path: Path) -> None: + """A parse failure is recorded on that file's report with `error` set and no result, + leaves that file byte-identical on disk, and does not stop other files being rewritten. + """ + broken_source = "def (\n" + broken_path = tmp_path / "broken.py" + broken_path.write_text(broken_source) + valid_path = tmp_path / "valid.py" + valid_path.write_text( + textwrap.dedent("""\ + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("demo") + """) + ) + + report = run([broken_path, valid_path], write=True) + + broken_report = report.files[0] + assert broken_report.error is not None + assert broken_report.result is None + assert broken_path.read_text() == broken_source + assert valid_path.read_text() == snapshot( + """\ +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("demo") +""" + ) + + +def test_the_report_aggregates_diagnostic_counts_by_severity(tmp_path: Path) -> None: + """`RunReport.diagnostics` sums every file's diagnostics into per-severity counts, so + flag-only (manual) and heuristic-rewrite (review) sites are both visible after a run. + """ + (tmp_path / "lowlevel.py").write_text( + textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + server = Server("demo") + + + @server.list_tools() + async def handle_list_tools(): + return [] + """) + ) + (tmp_path / "pagination.py").write_text( + textwrap.dedent("""\ + from mcp.types import ListResourcesResult + + + def cursor(result: ListResourcesResult) -> str | None: + return result.nextCursor + """) + ) + + report = run(discover([tmp_path]), write=False) + + assert report.diagnostics["manual"] >= 1 + assert report.diagnostics["review"] >= 1 + + +def test_file_report_changed_is_false_for_an_untouched_file(tmp_path: Path) -> None: + """`FileReport.changed` is true only when the transform succeeded and produced different + code: an already-v2 file is unchanged, and a file that failed to parse has no result. + """ + rewritten_path = tmp_path / "v1.py" + rewritten_path.write_text("from mcp.types import Tool\n") + untouched_source = "from mcp_types import Tool\n" + untouched_path = tmp_path / "v2.py" + untouched_path.write_text(untouched_source) + broken_path = tmp_path / "broken.py" + broken_path.write_text("def (\n") + + rewritten, untouched, broken = run([rewritten_path, untouched_path, broken_path], write=False).files + + assert rewritten.changed is True + assert untouched.changed is False + assert untouched.result is not None + assert untouched.result.code == untouched_source + assert broken.result is None + assert broken.changed is False + + +def test_a_file_that_cannot_be_decoded_is_left_untouched_and_reported(tmp_path: Path) -> None: + """A legal Python file in a non-UTF-8 encoding must not abort the run after other + files were already rewritten; it is recorded as failed and left exactly as found. + """ + good = tmp_path / "aaa.py" + good.write_text("from mcp.server.fastmcp import FastMCP\n") + weird = tmp_path / "bbb.py" + weird.write_bytes(b"# -*- coding: latin-1 -*-\n# caf\xe9\nX = 1\n") + report = run([good, weird], write=True) + assert "mcp.server.mcpserver" in good.read_text() + assert weird.read_bytes() == b"# -*- coding: latin-1 -*-\n# caf\xe9\nX = 1\n" + failed = report.files[1] + assert failed.result is None + assert failed.error is not None and "UnicodeDecodeError" in failed.error + + +def test_a_file_whose_write_fails_is_reported_without_aborting_the_run( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A failure while writing one file back is recorded as exactly that -- never as + a parse failure -- and the rest of the run still happens. + """ + first = tmp_path / "aaa.py" + first.write_text("from mcp.server.fastmcp import FastMCP\n") + second = tmp_path / "bbb.py" + second.write_text("from mcp import McpError\n") + real_write = Path.write_bytes + + def failing_write(self: Path, data: bytes) -> int: + if self.name == "aaa.py": + raise OSError(28, "No space left on device") + return real_write(self, data) + + monkeypatch.setattr(Path, "write_bytes", failing_write) + report = run([first, second], write=True) + failed = report.files[0] + assert failed.result is None + assert failed.error is not None and "write failed" in failed.error + assert "MCPError" in second.read_text() + + +def test_crlf_line_endings_survive_a_rewrite(tmp_path: Path) -> None: + """Files are read and written as bytes, so a CRLF file stays a CRLF file.""" + path = tmp_path / "win.py" + path.write_bytes(b'from mcp.server.fastmcp import FastMCP\r\n\r\nmcp = FastMCP("demo")\r\n') + run([path], write=True) + assert path.read_bytes() == b'from mcp.server.mcpserver import MCPServer\r\n\r\nmcp = MCPServer("demo")\r\n' diff --git a/tests/codemod/test_transformer.py b/tests/codemod/test_transformer.py new file mode 100644 index 0000000000..2a846c03aa --- /dev/null +++ b/tests/codemod/test_transformer.py @@ -0,0 +1,1531 @@ +"""Behaviour of `transform()`, the whole programmatic surface of the codemod. + +Every test feeds one module's source through the public API. A property that +must NOT change is asserted as byte-identity against the input; a rewrite is +asserted as the exact v2 output. +""" + +import textwrap + +import libcst +import pytest +from inline_snapshot import snapshot +from mcp_codemod import transform + + +def test_from_import_of_a_renamed_module_is_rewritten() -> None: + """A `from mcp.server.fastmcp import ...` statement is rewritten to import from `mcp.server.mcpserver`.""" + source = "from mcp.server.fastmcp import Context\n" + assert transform(source).code == snapshot("from mcp.server.mcpserver import Context\n") + + +def test_from_import_of_a_renamed_submodule_is_rewritten() -> None: + """A submodule under a renamed package matches by longest prefix, so only the renamed prefix changes + and the rest of the dotted path is kept.""" + source = "from mcp.server.fastmcp.prompts.base import UserMessage\n" + assert transform(source).code == snapshot("from mcp.server.mcpserver.prompts.base import UserMessage\n") + + +def test_plain_import_of_a_renamed_module_is_rewritten() -> None: + """`import mcp.types` is rewritten to `import mcp_types`, the module's v2 home.""" + source = "import mcp.types\n" + assert transform(source).code == snapshot("import mcp_types\n") + + +def test_dotted_usage_of_a_renamed_module_follows_its_import() -> None: + """A fully dotted reference such as `mcp.types.Tool` is rewritten together with the + `import mcp.types` statement that binds it, so the rewritten module still resolves.""" + source = textwrap.dedent("""\ + import mcp.types + + tool = mcp.types.Tool(name="x") + """) + assert transform(source).code == snapshot( + """\ +import mcp_types + +tool = mcp_types.Tool(name="x") +""" + ) + + +def test_an_aliased_module_import_keeps_the_local_name() -> None: + """`import mcp.types as t` is rewritten to `import mcp_types as t`; references through the + alias `t` already name the right module and are left exactly as written.""" + source = textwrap.dedent("""\ + import mcp.types as t + + tool = t.Tool(name="x") + """) + assert transform(source).code == snapshot( + """\ +import mcp_types as t + +tool = t.Tool(name="x") +""" + ) + + +def test_from_mcp_import_types_becomes_a_real_import() -> None: + """`from mcp import types` bound the deleted `mcp.types` submodule, so the codemod + replaces it with a real `import mcp_types as types` that produces the same local name.""" + result = transform("from mcp import types\n") + assert result.code == snapshot("import mcp_types as types\n") + + +def test_from_mcp_import_types_with_an_alias_keeps_the_alias() -> None: + """`from mcp import types as t` is rewritten to `import mcp_types as t`, preserving + the local name the rest of the module refers to.""" + result = transform("from mcp import types as t\n") + assert result.code == snapshot("import mcp_types as t\n") + + +def test_types_is_split_off_from_other_imported_names() -> None: + """When `types` is imported alongside other names from `mcp`, only it is split out into + a separate `import mcp_types as types`; the remaining names stay in the `from mcp import`.""" + result = transform("from mcp import ClientSession, types\n") + assert result.code == snapshot( + """\ +from mcp import ClientSession +import mcp_types as types +""" + ) + + +def test_a_from_mcp_import_without_types_is_untouched() -> None: + """A `from mcp import ...` that does not name `types` is not an import of the deleted + submodule, so the module is returned byte-for-byte identical.""" + source = textwrap.dedent("""\ + from mcp import ClientSession, StdioServerParameters + + params = StdioServerParameters(command="python") + session: ClientSession | None = None + """) + assert transform(source).code == source + + +def test_a_star_import_from_mcp_is_untouched() -> None: + """`from mcp import *` names no specific binding, so there is nothing for the codemod + to split out and the source is returned identical.""" + source = "from mcp import *\n" + assert transform(source).code == source + + +def test_a_relative_import_is_never_touched() -> None: + """A relative import refers to the user's own package, never the SDK, so + `from . import types` and `from .types import Tool` come back exactly as written. + """ + source = textwrap.dedent("""\ + from . import types + from .types import Tool + + + def make() -> Tool: + return types.Tool(name="echo") + """) + assert transform(source).code == source + + +def test_an_already_migrated_import_is_a_noop() -> None: + """Running the codemod over code that is already on v2 is a no-op: the v2 import + paths match none of the rename tables, so nothing is rewritten or reported. + """ + source = textwrap.dedent("""\ + import mcp_types + from mcp.server.mcpserver import MCPServer + + mcp = MCPServer("demo") + + + @mcp.tool() + def greet(name: str) -> mcp_types.TextContent: + return mcp_types.TextContent(type="text", text=f"hi {name}") + """) + result = transform(source) + assert result.code == source + assert result.diagnostics == [] + + +def test_an_unrelated_third_party_import_is_untouched() -> None: + """Imports of and references to non-mcp packages are outside every rename table, + so a module built on pydantic and httpx is returned exactly as written. + """ + source = textwrap.dedent("""\ + import httpx + from pydantic import BaseModel + + + class Settings(BaseModel): + url: str + + + def fetch(settings: Settings) -> httpx.Response: + return httpx.get(settings.url) + """) + assert transform(source).code == source + + +def test_a_file_with_no_mcp_usage_is_returned_byte_identical() -> None: + """A module that never mentions mcp is the do-no-harm contract: the source comes + back byte-identical with no diagnostics and no rewrites recorded. + """ + source = textwrap.dedent("""\ + # Shared logging setup for the example application. + + import logging + + + def get_logger(name: str) -> logging.Logger: + \"\"\"Return the logger for `name`.\"\"\" + return logging.getLogger(name) + """) + result = transform(source) + assert result.code == source + assert result.diagnostics == [] + assert dict(result.rewrites) == {} + + +def test_an_unchanged_mcp_module_path_is_not_renamed() -> None: + """An mcp import path that did not move between v1 and v2 is not rewritten, so + `mcp.client.streamable_http` and `mcp.server.lowlevel` survive untouched. + """ + source = textwrap.dedent("""\ + from mcp.client.streamable_http import streamable_http_client + from mcp.server.lowlevel import Server + + server = Server("demo") + + + async def connect(url: str) -> None: + async with streamable_http_client(url) as (read, write): + await server.run(read, write) + """) + assert transform(source).code == source + + +def test_a_renamed_class_import_and_every_use_are_rewritten() -> None: + """Importing `FastMCP` from `mcp.server.fastmcp` rewrites the module path, the imported + name, and every call site to the v2 `mcp.server.mcpserver.MCPServer` spelling.""" + source = textwrap.dedent("""\ + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("demo") + """) + assert transform(source).code == snapshot("""\ +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("demo") +""") + + +def test_an_aliased_import_of_a_renamed_symbol_keeps_the_local_alias() -> None: + """`from mcp.server.fastmcp import FastMCP as F` renames only the imported name; the local + alias `F` and every use of it are left exactly as written.""" + source = textwrap.dedent("""\ + from mcp.server.fastmcp import FastMCP as F + + mcp = F("demo") + """) + assert transform(source).code == snapshot("""\ +from mcp.server.mcpserver import MCPServer as F + +mcp = F("demo") +""") + + +def test_a_fully_dotted_reference_to_a_renamed_symbol_is_rewritten() -> None: + """A fully dotted use such as `mcp.shared.exceptions.McpError` has only its final segment + renamed to `MCPError`; the `import` statement and the module prefix are untouched.""" + source = textwrap.dedent("""\ + import mcp.shared.exceptions + + raise mcp.shared.exceptions.McpError(1, "x") + """) + assert transform(source).code == snapshot("""\ +import mcp.shared.exceptions + +raise mcp.shared.exceptions.MCPError(1, "x") +""") + + +def test_a_user_class_sharing_a_renamed_name_is_never_touched() -> None: + """A user-defined `FastMCP` class in a module with no mcp imports is left identical: the + rename is keyed on the qualified name resolved through imports, never the bare token.""" + source = textwrap.dedent("""\ + class FastMCP: + def __init__(self, name): + self.name = name + + + app = FastMCP("demo") + """) + assert transform(source).code == source + + +def test_non_reference_positions_of_a_renamed_name_are_never_rewritten() -> None: + """Only the import alias is renamed to `MCPServer`; an attribute access `obj.FastMCP` and a + keyword argument `FastMCP=` are name positions, not references, and keep the v1 spelling.""" + source = textwrap.dedent("""\ + from mcp.server.fastmcp import FastMCP + + + def use(obj, g): + obj.FastMCP + g(FastMCP=1) + """) + assert transform(source).code == snapshot("""\ +from mcp.server.mcpserver import MCPServer + + +def use(obj, g): + obj.FastMCP + g(FastMCP=1) +""") + + +def test_a_removed_function_import_gets_a_marker_and_is_not_rewritten() -> None: + """`create_connected_server_and_client_session` has no v2 spelling, so the call site + keeps its v1 name and gains a `manual` diagnostic plus an inline marker comment. + """ + source = textwrap.dedent("""\ + from mcp.shared.memory import create_connected_server_and_client_session + + + async def main(server): + async with create_connected_server_and_client_session(server) as session: + await session.list_tools() + """) + result = transform(source) + assert "create_connected_server_and_client_session" in result.code + assert any(diagnostic.severity == "manual" for diagnostic in result.diagnostics) + assert "# mcp-codemod:" in result.code + + +def test_the_websocket_client_import_is_flagged() -> None: + """The WebSocket transport was deleted from v2, so a `websocket_client` use is flagged + `manual` at the import and at the call, and the only change to the module is the + inserted marker comments. + """ + source = textwrap.dedent("""\ + from mcp.client.websocket import websocket_client + + + async def main() -> None: + async with websocket_client("ws://localhost:3000/ws") as (read, write): + pass + """) + result = transform(source) + assert any(d.severity == "manual" and "WebSocket" in d.message for d in result.diagnostics) + assert result.code == snapshot("""\ +# mcp-codemod: `mcp.client.websocket.websocket_client` removed: the WebSocket transport was deleted +from mcp.client.websocket import websocket_client + + +async def main() -> None: + # mcp-codemod: `mcp.client.websocket.websocket_client` removed: the WebSocket transport was deleted + async with websocket_client("ws://localhost:3000/ws") as (read, write): + pass +""") + + +def test_a_removed_attribute_is_flagged_regardless_of_receiver() -> None: + """`get_server_capabilities` is matched by attribute name alone -- the codemod cannot + see a receiver's type -- so the access is flagged `manual` and left exactly as written. + """ + source = textwrap.dedent("""\ + from mcp import ClientSession + + + def capabilities(session: ClientSession) -> object: + return session.get_server_capabilities() + """) + result = transform(source) + assert any(diagnostic.severity == "manual" for diagnostic in result.diagnostics) + assert "# mcp-codemod:" in result.code + assert "session.get_server_capabilities()" in result.code + + +def test_a_lowlevel_server_decorator_is_flagged_with_its_constructor_kwarg() -> None: + """A lowlevel `@server.call_tool()` registration cannot be migrated mechanically, so it + is flagged `manual` with the `on_call_tool=` guidance and the handler is not touched. + """ + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + server = Server("s") + + + @server.call_tool() + async def handle(name: str, arguments: dict): + return [] + """) + result = transform(source) + (diagnostic,) = result.diagnostics + assert diagnostic.severity == "manual" + assert "on_call_tool=" in diagnostic.message + assert "@server.call_tool()\nasync def handle(name: str, arguments: dict):\n return []\n" in result.code + assert "# mcp-codemod:" in result.code + + +def test_a_high_level_decorator_is_never_flagged() -> None: + """`@mcp.tool()` is syntactically identical to a lowlevel decorator and only the + receiver's binding tells them apart: the `MCPServer` form gets no diagnostic or marker. + """ + source = textwrap.dedent("""\ + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("d") + + + @mcp.tool() + def add(a: int, b: int) -> int: + return a + b + """) + result = transform(source) + assert result.diagnostics == [] + assert "# mcp-codemod" not in result.code + + +def test_a_safe_camelcase_attribute_read_is_renamed() -> None: + """A safe-tier camelCase field read as an attribute is rewritten to its snake_case spelling. + + The rewrite is reported as a single info diagnostic and never earns an inline marker. + """ + source = textwrap.dedent("""\ + from mcp.types import CallToolResult + + + def show(result: CallToolResult) -> None: + print(result.structuredContent) + """) + result = transform(source) + assert result.code == snapshot("""\ +from mcp_types import CallToolResult + + +def show(result: CallToolResult) -> None: + print(result.structured_content) +""") + assert [diagnostic.severity for diagnostic in result.diagnostics] == ["info"] + assert "# mcp-codemod" not in result.code + + +def test_a_risky_camelcase_attribute_read_is_renamed_with_a_review_marker() -> None: + """A risky-tier camelCase field is still renamed, but the rewrite rests on a heuristic. + + It is reported as a single review diagnostic and an inline review marker is inserted above the site. + """ + source = textwrap.dedent("""\ + from mcp import ClientSession + + + async def page(session: ClientSession) -> None: + result = await session.list_tools() + print(result.nextCursor) + """) + result = transform(source) + assert result.code == snapshot("""\ +from mcp import ClientSession + + +async def page(session: ClientSession) -> None: + result = await session.list_tools() + # mcp-codemod: review: renamed `.nextCursor` to `.next_cursor`; verify the receiver is an mcp type + print(result.next_cursor) +""") + assert [diagnostic.severity for diagnostic in result.diagnostics] == ["review"] + assert "# mcp-codemod: review:" in result.code + + +def test_camelcase_attributes_are_untouched_in_a_file_that_never_imports_mcp() -> None: + """A file that never imports mcp keeps every camelCase attribute exactly as written. + + The whole camelCase rename is gated on the file importing the SDK at all. + """ + source = textwrap.dedent("""\ + import json + + + def describe(result: object) -> str: + return json.dumps(result.inputSchema) + """) + assert transform(source).code == source + + +def test_camelcase_names_outside_the_allowlist_are_never_renamed() -> None: + """camelCase attribute names that v1 `mcp.types` never declared are left exactly as written. + + Only the allowlisted field names are ever considered, so stdlib and user camelCase APIs survive. + """ + source = textwrap.dedent("""\ + import logging + + import mcp + + + def configure(obj: object, level: int) -> None: + logging.getLogger(__name__).setLevel(level) + obj.basicConfig() + """) + assert transform(source).code == source + + +def test_camelcase_strings_outside_a_getattr_call_are_never_renamed() -> None: + """An allowlisted camelCase name spelled as a string -- a dict key, a subscript index, a bare + literal -- is left exactly as written even though the file imports mcp: camelCase is the wire format. + """ + source = textwrap.dedent("""\ + from mcp import ClientSession + + + def wire(session: ClientSession, schema: object, d: dict[str, object]) -> object: + payload = {"inputSchema": schema} + raw = d["inputSchema"] + name = "inputSchema" + return payload, raw, name + """) + assert transform(source).code == source + + +def test_camelcase_keywords_on_an_mcp_constructor_are_renamed() -> None: + """camelCase keyword arguments on a call that resolves into the SDK are rewritten to + their snake_case spellings, alongside the `mcp.types` -> `mcp_types` import rename.""" + source = textwrap.dedent("""\ + from mcp.types import Tool + + tool = Tool(name="x", inputSchema={}, outputSchema={}) + """) + assert transform(source).code == snapshot("""\ +from mcp_types import Tool + +tool = Tool(name="x", input_schema={}, output_schema={}) +""") + + +def test_camelcase_keywords_on_a_call_outside_mcp_are_untouched() -> None: + """The keyword rename fires only when the callee resolves into the SDK, so an allowlisted + camelCase keyword passed to the user's own function is left exactly as written.""" + source = textwrap.dedent("""\ + import mcp + + + def build(**fields: object) -> dict[str, object]: + return dict(fields) + + + schema = build(inputSchema={}) + """) + assert transform(source).code == source + + +def test_a_camelcase_field_in_a_hasattr_string_is_renamed() -> None: + """An allowlisted camelCase field spelled as a string literal in a `hasattr` call is + renamed to its snake_case form and reported as an info diagnostic, with no inline marker.""" + source = textwrap.dedent("""\ + from mcp import ClientSession + + + def has_structured(result: object) -> bool: + return hasattr(result, "structuredContent") + """) + result = transform(source) + assert result.code == snapshot("""\ +from mcp import ClientSession + + +def has_structured(result: object) -> bool: + return hasattr(result, "structured_content") +""") + assert [(diagnostic.severity, diagnostic.transform) for diagnostic in result.diagnostics] == [ + ("info", "attr_snake_case") + ] + + +def test_a_string_outside_the_allowlist_in_a_getattr_call_is_untouched() -> None: + """A `getattr` string naming an attribute outside the camelCase allowlist is never + rewritten, so ordinary attribute names survive byte for byte.""" + source = textwrap.dedent("""\ + import mcp + + + def tool_name(result: object) -> object: + return getattr(result, "name") + """) + assert transform(source).code == source + + +def test_a_dynamic_attribute_argument_to_getattr_is_untouched() -> None: + """A `getattr` whose attribute argument is a variable rather than a string literal is + left exactly as written: the codemod only rewrites names it can read from the source.""" + source = textwrap.dedent("""\ + import mcp + + + def field(result: object, key: str) -> object: + return getattr(result, key) + """) + assert transform(source).code == source + + +def test_mcperror_wrapping_errordata_is_flattened_to_keyword_arguments() -> None: + """An `McpError(ErrorData(...))` raise is rewritten to `MCPError(...)` with the + `ErrorData` fields promoted to direct keyword arguments, and both imports renamed.""" + source = textwrap.dedent("""\ + from mcp.shared.exceptions import McpError + from mcp.types import ErrorData + + raise McpError(ErrorData(code=1, message="x", data=None)) + """) + assert transform(source).code == snapshot("""\ +from mcp.shared.exceptions import MCPError +from mcp_types import ErrorData + +raise MCPError(code=1, message="x", data=None) +""") + + +def test_mcperror_with_a_non_errordata_argument_is_renamed_and_marked() -> None: + """`McpError(err)` cannot be unpacked into v2's flat `MCPError(code, message, data)` + constructor, so the call is renamed and the site is marked rather than left to + fail with a confusing `TypeError` at the raise.""" + source = textwrap.dedent("""\ + from mcp.shared.exceptions import McpError + + + def reraise(err): + raise McpError(err) + """) + result = transform(source) + assert [diagnostic.severity for diagnostic in result.diagnostics] == ["manual"] + assert "MCPError(code, message, data=None)" in result.diagnostics[0].message + assert " # mcp-codemod: " in result.code + assert " raise MCPError(err)" in result.code + + +def test_error_attribute_chains_on_a_caught_mcperror_are_flattened() -> None: + """Inside `except McpError as e:`, the v1 `e.error.code` / `e.error.message` / + `e.error.data` chains each collapse to the v2 direct attribute on `e`.""" + source = textwrap.dedent("""\ + from mcp.shared.exceptions import McpError + + try: + run() + except McpError as e: + print(e.error.code, e.error.message, e.error.data) + """) + assert transform(source).code == snapshot("""\ +from mcp.shared.exceptions import MCPError + +try: + run() +except MCPError as e: + print(e.code, e.message, e.data) +""") + + +def test_a_bare_error_attribute_on_a_caught_mcperror_is_not_collapsed() -> None: + """A bare `e.error` inside `except McpError as e:` may be a whole `ErrorData` + being passed somewhere, so it is never collapsed to `e`.""" + source = textwrap.dedent("""\ + from mcp.shared.exceptions import McpError + + try: + run() + except McpError as e: + handle(e.error) + """) + assert "handle(e.error)" in transform(source).code + + +def test_error_chains_outside_a_mcperror_handler_are_untouched() -> None: + """An `e.error.code` chain only collapses inside an `except McpError as e:` handler; + at module level and inside an `except ValueError as e:` it is left as written.""" + source = textwrap.dedent("""\ + from mcp.shared.exceptions import McpError + + e = current_error() + top = e.error.code + try: + run() + except ValueError as e: + low = e.error.code + """) + result = transform(source) + assert "top = e.error.code" in result.code + assert "low = e.error.code" in result.code + + +def test_a_mcperror_handler_without_a_binding_does_not_flatten() -> None: + """An `except McpError:` clause with no `as` name leaves an `.error.` chain in its + body byte-unchanged: without a bound name there is nothing to key the flatten on. + """ + source = textwrap.dedent("""\ + from mcp import McpError + + try: + run() + except McpError: + log(err.error.code) + """) + result = transform(source) + # The handler type itself was recognized (and renamed), so the non-flatten is not vacuous. + assert "except MCPError:" in result.code + assert "err.error.code" in result.code + + +def test_nested_handlers_track_the_innermost_binding() -> None: + """Only the name bound by the innermost enclosing `except McpError as ...:` is flattened; once + that nested handler is left, the enclosing non-McpError handler's binding is not treated as one. + """ + source = textwrap.dedent("""\ + from mcp import McpError + + try: + run() + except ValueError as e: + try: + run() + except McpError as inner: + log(inner.error.code) + log(e.error.code) + """) + assert transform(source).code == snapshot("""\ +from mcp import MCPError + +try: + run() +except ValueError as e: + try: + run() + except MCPError as inner: + log(inner.code) + log(e.error.code) +""") + + +def test_a_syntax_error_raises_parser_syntax_error() -> None: + """Source that is not parseable as Python raises `libcst.ParserSyntaxError`, the one exception + `transform()` documents. + """ + with pytest.raises(libcst.ParserSyntaxError): + transform("def (") + + +def test_the_three_tuple_unpack_is_narrowed_to_two() -> None: + """The v1 `streamable_http_client` context manager yielded a third `get_session_id` value that v2 no longer + returns, so a three-element `as` tuple is narrowed to the first two. + """ + source = textwrap.dedent("""\ + from mcp.client.streamable_http import streamable_http_client + + + async def main(url: str) -> None: + async with streamable_http_client(url) as (read, write, _): + pass + """) + assert transform(source).code == snapshot( + """\ +from mcp.client.streamable_http import streamable_http_client + + +async def main(url: str) -> None: + async with streamable_http_client(url) as (read, write): + pass +""" + ) + + +def test_a_named_third_element_gets_a_marker_when_dropped() -> None: + """When the dropped third element was bound to a real name rather than `_`, later uses of that name will break, + so the narrowing also raises a manual diagnostic naming the removed `get_session_id` value. + """ + source = textwrap.dedent("""\ + from mcp.client.streamable_http import streamable_http_client + + + async def main(url: str) -> None: + async with streamable_http_client(url) as (read, write, get_id): + pass + """) + result = transform(source) + assert "as (read, write):" in result.code + [diagnostic] = result.diagnostics + assert diagnostic.severity == "manual" + assert "get_session_id" in diagnostic.message + + +def test_removed_client_keywords_each_get_a_marker() -> None: + """v2's `streamable_http_client` no longer accepts `headers=`, `timeout=`, or `auth=`. Each one gets its own + manual diagnostic, and the keywords are left in place rather than silently deleted. + """ + source = textwrap.dedent("""\ + from mcp.client.streamable_http import streamable_http_client + + + async def main(url: str, h: dict[str, str], a: object) -> None: + async with streamable_http_client(url, headers=h, timeout=5, auth=a) as (read, write): + pass + """) + result = transform(source) + assert [(diagnostic.severity, diagnostic.message.partition(" ")[0]) for diagnostic in result.diagnostics] == [ + ("manual", "`headers=`"), + ("manual", "`timeout=`"), + ("manual", "`auth=`"), + ] + assert "streamable_http_client(url, headers=h, timeout=5, auth=a)" in result.code + + +def test_the_deprecated_streamablehttp_client_alias_is_renamed() -> None: + """The old `streamablehttp_client` spelling becomes `streamable_http_client` at both the import and the call + site, and the same with-item's three-element `as` tuple is narrowed in the same pass. + """ + source = textwrap.dedent("""\ + from mcp.client.streamable_http import streamablehttp_client + + + async def main(url: str) -> None: + async with streamablehttp_client(url) as (a, b, _): + pass + """) + assert transform(source).code == snapshot( + """\ +from mcp.client.streamable_http import streamable_http_client + + +async def main(url: str) -> None: + async with streamable_http_client(url) as (a, b): + pass +""" + ) + + +def test_a_two_tuple_unpack_is_already_correct() -> None: + """A two-element `as` tuple is already the v2 shape, so the module round-trips byte-for-byte: re-running the + codemod on already-migrated code is a no-op for this transform. + """ + source = textwrap.dedent("""\ + from mcp.client.streamable_http import streamable_http_client + + + async def main(url: str) -> None: + async with streamable_http_client(url) as (read, write): + pass + """) + assert transform(source).code == source + + +def test_a_non_tuple_as_target_is_untouched() -> None: + """A transport client with-item bound to a single name rather than a tuple is left exactly as written. + + Only the 3-tuple `as (read, write, get_session_id)` shape has a third element to drop. + """ + source = textwrap.dedent("""\ + from mcp.client.streamable_http import streamable_http_client + + + async def main(url: str) -> None: + async with streamable_http_client(url) as transport: + print(transport) + """) + assert transform(source).code == source + + +def test_an_unrelated_context_manager_is_untouched() -> None: + """A with-statement whose item is not an mcp transport client is never rewritten. + + `open()` resolves to a builtin and a bare lock is not even a call, so both round-trip unchanged. + """ + source = textwrap.dedent("""\ + import threading + + import mcp + + lock = threading.Lock() + + + def main(path: str) -> None: + with open(path) as f: + f.read() + with lock: + pass + """) + assert transform(source).code == source + + +def test_an_unimported_transport_name_is_never_touched() -> None: + """A bare `streamable_http_client` that was never imported does not resolve to the mcp transport client. + + The codemod refuses to act on a name it cannot resolve, so the 3-tuple with-item is left exactly as written. + """ + source = textwrap.dedent("""\ + from mcp import ClientSession + + + async def main(url: str) -> None: + async with streamable_http_client(url) as (read, write, get_session_id): + print(read, write, get_session_id) + """) + assert transform(source).code == source + + +def test_a_transport_keyword_on_the_constructor_gets_a_marker_and_stays() -> None: + """A transport keyword on the constructor is flagged as manual work but never deleted. + + Where the kwarg belongs on v2 depends on how the server is started, so the codemod + leaves the configuration in place rather than silently dropping it. + """ + source = textwrap.dedent("""\ + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("d", stateless_http=True, port=1) + """) + result = transform(source) + assert [diagnostic.severity for diagnostic in result.diagnostics] == ["manual", "manual"] + assert "stateless_http=True" in result.code + assert "port=1" in result.code + + +def test_a_removed_constructor_keyword_gets_a_marker() -> None: + """A constructor keyword that v2 removed outright gets a manual diagnostic naming it.""" + source = textwrap.dedent("""\ + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("d", mount_path="/x") + """) + result = transform(source) + assert [diagnostic.severity for diagnostic in result.diagnostics] == ["manual"] + assert "mount_path" in result.diagnostics[0].message + + +def test_surviving_constructor_keywords_are_not_flagged() -> None: + """A constructor keyword that still exists on the v2 `MCPServer` produces no diagnostic. + + `dependencies`, `debug`, and `log_level` are here deliberately: a flag on a + keyword that still works tells the user a lie they cannot reconcile, so the + keywords v2 kept must never be in the moved or removed tables. + """ + source = textwrap.dedent("""\ + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("d", instructions="hi", dependencies=["a"], debug=False, log_level="INFO") + """) + assert transform(source).diagnostics == [] + + +def test_a_lowlevel_server_bound_to_an_attribute_is_not_tracked() -> None: + """Only a plain-name binding of a lowlevel `Server(...)` is tracked, so a registration + on a server held in an instance attribute is left alone with no diagnostic.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + + class Holder: + def __init__(self) -> None: + self.s = Server("x") + + @self.s.call_tool() + async def handle(name, arguments): + return [] + """) + assert transform(source).diagnostics == [] + + +def test_transforming_already_transformed_code_is_a_noop() -> None: + """Running the codemod over its own output changes nothing, even for a source that exercises + a module rename, a symbol rename, a camelCase attribute rename, and a flag-only diagnostic. + """ + source = textwrap.dedent("""\ + from mcp import McpError + from mcp.types import Tool + + + def describe(tool: Tool, server: object) -> object: + server.get_context() + schema = tool.inputSchema + if schema is None: + raise McpError("missing schema") + return schema + """) + once = transform(source) + assert once.code != source + assert transform(once.code).code == once.code + + +def test_a_marker_is_not_duplicated_on_a_second_run() -> None: + """A second run over already-marked output recognises the existing `# mcp-codemod:` comment + and does not insert it again. + """ + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + server = Server("demo") + result = server.get_server_capabilities() + """) + once = transform(source) + assert transform(once.code).code.count("# mcp-codemod:") == 1 + + +def test_add_markers_false_reports_without_inserting_comments() -> None: + """With `add_markers=False` a flag-only finding still appears in `diagnostics`, but no + `# mcp-codemod` comment is written into the code. + """ + source = textwrap.dedent("""\ + from mcp.server.fastmcp import FastMCP + + app = FastMCP("demo", port=9000) + """) + result = transform(source, add_markers=False) + assert "# mcp-codemod" not in result.code + assert result.diagnostics + + +def test_a_marker_on_a_decorated_function_lands_above_the_decorators() -> None: + """The marker for a flagged lowlevel `@server.call_tool()` registration is inserted above the + decorator line, not between the decorator and the `def`. + """ + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + server = Server("example") + + + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict[str, str]) -> list[str]: + return [name] + """) + lines = transform(source).code.splitlines() + marker_index = next(i for i, line in enumerate(lines) if "# mcp-codemod:" in line) + assert marker_index < lines.index("@server.call_tool()") + + +def test_info_diagnostics_never_produce_a_marker() -> None: + """A safe camelCase attribute rename is reported as an `info` diagnostic only; no + `# mcp-codemod` comment is added for it. + """ + source = textwrap.dedent("""\ + from mcp.types import Tool + + + def schema_of(tool: Tool) -> object: + return tool.inputSchema + """) + result = transform(source) + assert result.diagnostics + assert all(diagnostic.severity == "info" for diagnostic in result.diagnostics) + assert "# mcp-codemod" not in result.code + + +def test_a_dotted_module_usage_is_counted_as_one_rewrite() -> None: + """`import mcp.types` plus one `mcp.types.X` reference is two logical rewrites, not + three: only the innermost node naming the module is replaced, so the visitor never + double-counts the attribute chain that encloses it. + """ + result = transform("import mcp.types\n\nx: mcp.types.Tool\n") + assert result.code == "import mcp_types\n\nx: mcp_types.Tool\n" + assert result.rewrites["module_rename"] == 2 + + +def test_a_local_variable_named_mcp_is_never_treated_as_the_package() -> None: + """`mcp = MCPServer(...)` is the most common variable name in real MCP code, so an + attribute chain on it that happens to spell a module path must never be rewritten. + Only a name that resolves through an import is. + """ + source = "mcp = build()\nprint(mcp.types)\n" + assert transform(source).code == source + + +def test_a_semicolon_joined_statement_line_is_left_as_written() -> None: + """A `from mcp import types` joined to another statement by a semicolon cannot be + split out into its own `import mcp_types as types` line, so the whole statement + is left exactly as written rather than half-rewritten. + """ + source = "DEBUG = True; from mcp import types\n" + assert transform(source).code == source + + +def test_camelcase_keywords_on_a_local_variable_named_mcp_are_untouched() -> None: + """A local variable named `mcp` is the most common name in real MCP code; keyword + arguments on a method call through it must never be renamed when nothing in the + file actually imports the SDK. + """ + source = 'mcp = Router()\nmcp.register(inputSchema={"a": 1}, isError=False)\n' + result = transform(source) + assert result.code == source + assert result.diagnostics == [] + + +def test_a_getattr_string_in_a_file_that_never_imports_mcp_is_untouched() -> None: + """The string form of the camelCase rename is gated on the file importing the SDK, + exactly like the attribute form, so an ORM lookup elsewhere is never rewritten. + """ + source = 'value = getattr(row, "createdAt", None)\n' + assert transform(source).code == source + + +def test_a_risky_camelcase_getattr_string_gets_a_review_marker() -> None: + """A risky-tier name renamed inside a `getattr` string is marked for review, the + same way the equivalent attribute access is. + """ + source = 'import mcp\n\ncursor = getattr(result, "nextCursor", None)\n' + result = transform(source) + assert '"next_cursor"' in result.code + assert "# mcp-codemod: review:" in result.code + + +def test_removed_attribute_names_are_untouched_in_a_file_that_never_imports_mcp() -> None: + """`get_context` is a common method name well outside MCP; a file that never + imports the SDK must never have a removal marker written into it. + """ + source = textwrap.dedent("""\ + class DetailView(View): + def render(self): + return self.get_context() + """) + result = transform(source) + assert result.code == source + assert result.diagnostics == [] + + +def test_renaming_a_plain_import_still_needed_for_other_names_gets_a_review_marker() -> None: + """`import mcp.types` also bound the name `mcp`. When another reference still + needs that binding (and no other import provides it), the rewrite to + `import mcp_types` is marked for review. + """ + source = textwrap.dedent("""\ + import httpx + import mcp.types + + tool = mcp.types.Tool(name="x", input_schema={}) + session = mcp.ClientSession(read, write, client=httpx.AsyncClient()) + """) + result = transform(source) + assert "import mcp_types\n" in result.code + assert "mcp_types.Tool" in result.code + assert "# mcp-codemod: review:" in result.code + assert "add `import mcp` back" in result.code + + +def test_renaming_a_plain_import_whose_binding_nothing_else_needs_is_silent() -> None: + """When every reference through `import mcp.types` is itself being rewritten, + losing the `mcp` binding breaks nothing, so no review marker is added. + """ + source = 'import mcp.types\n\ntool = mcp.types.Tool(name="x", input_schema={})\n' + result = transform(source) + assert result.code == 'import mcp_types\n\ntool = mcp_types.Tool(name="x", input_schema={})\n' + assert result.diagnostics == [] + + +def test_a_dotted_usage_through_a_bare_import_mcp_is_marked_not_rewritten() -> None: + """`import mcp` plus `mcp.types.X` is valid v1, but rewriting the usage would leave + nothing importing `mcp_types`, so the site is marked and left exactly as written. + """ + source = 'import mcp\n\ntool = mcp.types.Tool(name="x")\n' + result = transform(source) + assert "mcp.types.Tool" in result.code + assert "mcp_types.Tool" not in result.code + assert [diagnostic.severity for diagnostic in result.diagnostics] == ["manual"] + assert "import `mcp_types`" in result.diagnostics[0].message + + +def test_a_renamed_module_imported_from_its_parent_package_is_split_out() -> None: + """`from mcp.server import fastmcp` bound the renamed module to a local name, the + same shape as `from mcp import types`, so it becomes a real import of the new + module under the same local name. + """ + assert transform("from mcp.server import fastmcp\n").code == snapshot("import mcp.server.mcpserver as fastmcp\n") + + +def test_constructor_flags_fire_for_every_import_path_of_the_renamed_class() -> None: + """`from mcp.server import FastMCP` is a real v1 spelling, so its constructor gets + the same moved- and removed-keyword markers as the `mcp.server.fastmcp` spelling. + """ + source = textwrap.dedent("""\ + from mcp.server import FastMCP + + mcp = FastMCP("demo", port=8000, mount_path="/old") + """) + result = transform(source) + assert "MCPServer" in result.code + assert [diagnostic.severity for diagnostic in result.diagnostics] == ["manual", "manual"] + + +def test_a_renamed_symbol_reached_through_a_module_alias_is_rewritten() -> None: + """A renamed class accessed as an attribute of an aliased module import is still + resolved through the import, so both the import and the access are rewritten. + """ + source = textwrap.dedent("""\ + import mcp.server.fastmcp as fm + + mcp = fm.FastMCP("demo") + """) + assert transform(source).code == snapshot( + """\ +import mcp.server.mcpserver as fm + +mcp = fm.MCPServer("demo") +""" + ) + + +def test_an_import_of_a_types_name_with_no_v2_home_is_marked() -> None: + """`mcp_types` is not a name-superset of v1's `mcp.types`: a name with no v2 + home (`Cursor`) is marked at the import and at every use, never silently + rewritten into an import that cannot resolve. + """ + source = textwrap.dedent("""\ + from mcp.types import Cursor, Tool + + cursor: Cursor | None = None + """) + result = transform(source) + assert "from mcp_types import Cursor, Tool" in result.code + assert [diagnostic.severity for diagnostic in result.diagnostics] == ["manual", "manual"] + assert all("`mcp.types.Cursor` removed" in diagnostic.message for diagnostic in result.diagnostics) + + +def test_a_removed_api_reached_through_its_module_is_marked() -> None: + """A removed API spelled `module.symbol` gets the same marker as the bare + imported name; `leave_Name` only ever sees the latter. + """ + source = textwrap.dedent("""\ + from mcp.shared import memory + + streams = memory.create_connected_server_and_client_session(server) + """) + result = transform(source) + assert "# mcp-codemod:" in result.code + assert [diagnostic.severity for diagnostic in result.diagnostics] == ["manual"] + assert "create_connected_server_and_client_session" in result.diagnostics[0].message + + +def test_a_plain_import_of_a_deeper_renamed_module_is_not_double_flagged() -> None: + """`import mcp.server.fastmcp.server` also resolves its own `mcp.server.fastmcp` + prefix; only the full path is rewritten and the prefix must not be flagged. + """ + source = "import mcp.server.fastmcp.server\n\nctx = mcp.server.fastmcp.server.Context()\n" + result = transform(source) + assert result.code == "import mcp.server.mcpserver.server\n\nctx = mcp.server.mcpserver.server.Context()\n" + assert result.diagnostics == [] + + +def test_transport_client_kwargs_are_flagged_in_any_call_form() -> None: + """The removed client keywords and the narrower yield are marked even when the + call is not itself the `with` item; `enter_async_context` is the common form. + """ + source = textwrap.dedent("""\ + from mcp.client.streamable_http import streamablehttp_client + + + async def connect(stack, url): + return await stack.enter_async_context(streamablehttp_client(url, headers={"x": "y"})) + """) + result = transform(source) + assert "streamable_http_client(url, headers" in result.code + assert sorted(d.transform for d in result.diagnostics) == ["transport_client_param", "transport_client_unpack"] + + +def test_an_already_migrated_client_call_outside_a_with_is_never_flagged() -> None: + """A call through the v2 name proves nothing about its surroundings being v1, + so already-migrated code never gets the yield-shape marker. + """ + source = textwrap.dedent("""\ + from mcp.client.streamable_http import streamable_http_client + + + async def connect(stack, url): + return await stack.enter_async_context(streamable_http_client(url)) + """) + result = transform(source) + assert result.code == source + assert result.diagnostics == [] + + +def test_two_identical_findings_on_one_statement_produce_one_marker() -> None: + """Two findings with the same message on one statement collapse into a single + inline comment; each is still reported as its own diagnostic. + """ + source = "import mcp\n\nflag = a.isError or b.isError\n" + result = transform(source) + assert result.code.count("# mcp-codemod:") == 1 + assert len(result.diagnostics) == 2 + + +def test_an_assignment_to_a_caught_error_field_is_never_collapsed() -> None: + """`e.error.message = ...` works on v2 (`MCPError.error` is still a mutable + `ErrorData`), but `e.message = ...` would not -- `message` became a read-only + property -- so only the READ of the chain is collapsed, never a write target. + """ + source = textwrap.dedent("""\ + from mcp import McpError + + try: + run() + except McpError as e: + e.error.message = "while syncing: " + e.error.message + raise + """) + result = transform(source) + assert 'e.error.message = "while syncing: " + e.message' in result.code + assert result.diagnostics == [] + + +def test_a_nested_handler_does_not_hide_the_caught_mcperror() -> None: + """A nested `try`/`except` inside an `except McpError as e:` handler does not + re-bind `e`, so `e.error.code` in the nested body is still collapsed. + """ + source = textwrap.dedent("""\ + from mcp import McpError + + try: + run() + except McpError as e: + try: + cleanup() + except: + log(e.error.code) + """) + assert "log(e.code)" in transform(source).code + + +def test_a_tuple_except_clause_binding_mcperror_is_recognized() -> None: + """`except (McpError, ValueError) as e:` binds `e` to a possible `McpError`, so the + exception types and the `e.error.code` read are both rewritten. + """ + source = textwrap.dedent("""\ + from mcp import McpError + + try: + run() + except (McpError, ValueError) as e: + log(e.error.code) + """) + result = transform(source) + assert "except (MCPError, ValueError) as e:" in result.code + assert "log(e.code)" in result.code + + +def test_a_v1_client_with_item_bound_to_a_single_name_is_flagged() -> None: + """`async with streamablehttp_client(...) as streams:` cannot have its unpacking + rewritten (it happens somewhere else), so the call gets the yield-shape marker. + """ + source = textwrap.dedent("""\ + from mcp.client.streamable_http import streamablehttp_client + + + async def connect(url): + async with streamablehttp_client(url) as streams: + read, write, _ = streams + """) + result = transform(source) + assert "streamable_http_client(url) as streams:" in result.code + assert [diagnostic.transform for diagnostic in result.diagnostics] == ["transport_client_unpack"] + + +def test_an_annotated_lowlevel_server_assignment_is_recognized() -> None: + """`server: Server = Server(...)` binds the server exactly like the un-annotated + form, so its decorators get the same lowlevel registration marker. + """ + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + server: Server = Server("demo") + + + @server.call_tool() + async def handle(name, arguments): + return [] + """) + result = transform(source) + assert [diagnostic.transform for diagnostic in result.diagnostics] == ["lowlevel_decorator"] + assert "on_call_tool=" in result.diagnostics[0].message + + +def test_camelcase_attributes_are_renamed_in_a_file_importing_only_mcp_types() -> None: + """A half-migrated file whose only SDK import is already `mcp_types` still gets + the attribute renames; `import mcp_types` is as much the SDK as `import mcp`. + """ + source = textwrap.dedent("""\ + import mcp_types + + + def show(result: mcp_types.CallToolResult) -> None: + print(result.structuredContent) + """) + assert "result.structured_content" in transform(source).code + + +def test_the_v2_request_context_idiom_is_never_flagged() -> None: + """`ctx.request_context.lifespan_context` is a live, documented v2 idiom. The + lowlevel `Server.request_context` property was also removed, but a name-only + match cannot tell the two apart, so neither is flagged. + """ + source = textwrap.dedent("""\ + from mcp.server.fastmcp import Context, FastMCP + + + async def query(ctx: Context) -> object: + return ctx.request_context.lifespan_context.db + """) + result = transform(source) + assert "ctx.request_context.lifespan_context.db" in result.code + assert result.diagnostics == [] + + +def test_a_trailing_comment_on_a_split_import_is_kept() -> None: + """The whole-statement rewrite of `from mcp import types` keeps the original + line's trailing comment -- a `# noqa` there is load-bearing. + """ + assert transform("from mcp import types # noqa: F401\n").code == snapshot( + "import mcp_types as types # noqa: F401\n" + ) + + +def test_a_marker_on_the_first_statement_is_not_duplicated_on_a_rerun() -> None: + """A comment above a module's FIRST statement parses into the module header, not + the statement, so the re-run dedup has to look there too. + """ + source = "# Application entrypoint.\nfrom mcp.client.websocket import websocket_client\n" + once = transform(source).code + assert once.count("# mcp-codemod:") == 1 + assert transform(once).code == once + + +def test_an_empty_module_is_returned_unchanged() -> None: + """An empty file is valid input and comes back empty with nothing reported.""" + result = transform("") + assert result.code == "" + assert result.diagnostics == [] + + +def test_positional_constructor_arguments_after_the_name_are_flagged() -> None: + """v1's second positional was `instructions`; v2's is `title`. Renaming the call + and leaving the argument would silently send the instructions as the title, so + every positional after the name is marked instead. + """ + source = textwrap.dedent("""\ + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("demo", "Use these instructions to call my tools.") + """) + result = transform(source) + assert "MCPServer(" in result.code + assert [diagnostic.severity for diagnostic in result.diagnostics] == ["manual"] + assert "`title` is now second" in result.diagnostics[0].message + + +def test_an_attribute_also_declared_by_a_class_in_the_file_is_marked_not_renamed() -> None: + """A file can declare an allowlisted camelCase name on its own model (mirroring + the wire format). Renaming its uses would break that class, so nothing is + rewritten and each use is marked for the reader to split. + """ + source = textwrap.dedent("""\ + from pydantic import BaseModel + + import mcp_types + + + class Row(BaseModel): + inputSchema: dict[str, object] + + + def show(row: Row) -> None: + print(row.inputSchema) + """) + result = transform(source) + assert "row.inputSchema" in result.code + assert "row.input_schema" not in result.code + assert [diagnostic.severity for diagnostic in result.diagnostics] == ["manual"] + assert "declared by a class in this file" in result.diagnostics[0].message + + +def test_a_super_init_call_in_an_mcperror_subclass_is_flattened() -> None: + """`super().__init__(ErrorData(...))` inside a `McpError` subclass is the same v1 + constructor reached the one way a qualified name cannot see, so it gets the same + flatten as a direct `McpError(ErrorData(...))` call. + """ + source = textwrap.dedent("""\ + from mcp import McpError + from mcp.types import INVALID_PARAMS, ErrorData + + + class ToolInputError(McpError): + def __init__(self, message: str) -> None: + super().__init__(ErrorData(code=INVALID_PARAMS, message=message)) + """) + result = transform(source) + assert "super().__init__(code=INVALID_PARAMS, message=message)" in result.code + assert "class ToolInputError(MCPError):" in result.code + + +def test_a_super_init_call_with_a_variable_argument_is_marked() -> None: + """`super().__init__(err)` in a `McpError` subclass cannot be unpacked, so it is + marked exactly like `McpError(err)` rather than left to fail when first raised. + """ + source = textwrap.dedent("""\ + from mcp import McpError + + + class WrappedError(McpError): + def __init__(self, err) -> None: + super().__init__(err) + """) + result = transform(source) + assert [diagnostic.severity for diagnostic in result.diagnostics] == ["manual"] + assert "MCPError(code, message, data=None)" in result.diagnostics[0].message + + +def test_a_removed_nested_class_reached_through_its_parent_is_marked() -> None: + """`RequestParams.Meta` is a nested class with no v2 home; the qualified-name + check sees the whole dotted path even though the per-module name tests cannot. + """ + source = textwrap.dedent("""\ + from mcp.types import RequestParams + + meta = RequestParams.Meta(progressToken="t") + """) + result = transform(source) + severities = [diagnostic.severity for diagnostic in result.diagnostics] + assert "manual" in severities + assert any("RequestParamsMeta" in diagnostic.message for diagnostic in result.diagnostics) + + +def test_the_server_submodule_import_targets_the_v2_submodule() -> None: + """`mcp.server.fastmcp.server` maps to the literal v2 submodule, where every one + of its public names (`Settings` is the giveaway -- the package does not export + it) still lives. + """ + source = "from mcp.server.fastmcp.server import Context, Settings\n" + assert transform(source).code == snapshot("from mcp.server.mcpserver.server import Context, Settings\n") + + +def test_a_resolvable_non_mcp_receiver_is_never_flagged() -> None: + """A receiver the imports prove is another package (`multiprocessing.get_context`) + is never name-matched, however mcp-flavoured the attribute name looks. + """ + source = textwrap.dedent("""\ + import multiprocessing + + from mcp.server.mcpserver import MCPServer + + ctx = multiprocessing.get_context("spawn") + """) + result = transform(source) + assert result.code == source + assert result.diagnostics == [] + + +def test_no_unbind_marker_when_another_import_keeps_the_root_bound() -> None: + """Renaming `import mcp.types` cannot unbind `mcp` while another plain import + of an `mcp.` module survives, so no review marker is added. + """ + source = textwrap.dedent("""\ + import mcp.client.session + import mcp.types + + session = mcp.client.session.ClientSession(read, write) + tool = mcp.types.Tool(name="x", input_schema={}) + """) + result = transform(source) + assert "import mcp_types" in result.code + assert "mcp_types.Tool" in result.code + assert result.diagnostics == [] diff --git a/uv.lock b/uv.lock index a1e8a7e356..40685a2ea6 100644 --- a/uv.lock +++ b/uv.lock @@ -3,12 +3,14 @@ revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14'", - "python_full_version < '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", ] [manifest] members = [ "mcp", + "mcp-codemod", "mcp-everything-server", "mcp-example-stories", "mcp-simple-auth", @@ -551,7 +553,8 @@ dependencies = [ { name = "isort" }, { name = "jinja2" }, { name = "pydantic" }, - { name = "pyyaml" }, + { name = "pyyaml", version = "6.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "pyyaml", version = "6.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5d/44/87d5980f813a1e323c5d726b3ac5fec8c915ce8a77fcdceaf9c00457dbae/datamodel_code_generator-0.57.0.tar.gz", hash = "sha256:0eda778ea06eaa476e542a5f1fe1d14cc3bbf686edb33a0ad6151c7d19089906", size = 932941, upload-time = "2026-05-07T16:21:55.819Z" } @@ -805,6 +808,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "libcst" +version = "1.8.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml", version = "6.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "pyyaml", version = "6.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "pyyaml-ft", marker = "python_full_version == '3.13.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/cd/337df968b38d94c5aabd3e1b10630f047a2b345f6e1d4456bd9fe7417537/libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b", size = 891354, upload-time = "2025-11-03T22:33:30.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/52/97d5454dee9d014821fe0c88f3dc0e83131b97dd074a4d49537056a75475/libcst-1.8.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a20c5182af04332cc94d8520792befda06d73daf2865e6dddc5161c72ea92cb9", size = 2211698, upload-time = "2025-11-03T22:31:50.117Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a4/d1205985d378164687af3247a9c8f8bdb96278b0686ac98ab951bc6d336a/libcst-1.8.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36473e47cb199b7e6531d653ee6ffed057de1d179301e6c67f651f3af0b499d6", size = 2093104, upload-time = "2025-11-03T22:31:52.189Z" }, + { url = "https://files.pythonhosted.org/packages/9e/de/1338da681b7625b51e584922576d54f1b8db8fc7ff4dc79121afc5d4d2cd/libcst-1.8.6-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:06fc56335a45d61b7c1b856bfab4587b84cfe31e9d6368f60bb3c9129d900f58", size = 2237419, upload-time = "2025-11-03T22:31:53.526Z" }, + { url = "https://files.pythonhosted.org/packages/50/06/ee66f2d83b870534756e593d464d8b33b0914c224dff3a407e0f74dc04e0/libcst-1.8.6-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6b23d14a7fc0addd9795795763af26b185deb7c456b1e7cc4d5228e69dab5ce8", size = 2300820, upload-time = "2025-11-03T22:31:55.995Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ca/959088729de8e0eac8dd516e4fb8623d8d92bad539060fa85c9e94d418a5/libcst-1.8.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:16cfe0cfca5fd840e1fb2c30afb628b023d3085b30c3484a79b61eae9d6fe7ba", size = 2301201, upload-time = "2025-11-03T22:31:57.347Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4c/2a21a8c452436097dfe1da277f738c3517f3f728713f16d84b9a3d67ca8d/libcst-1.8.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:455f49a93aea4070132c30ebb6c07c2dea0ba6c1fde5ffde59fc45dbb9cfbe4b", size = 2408213, upload-time = "2025-11-03T22:31:59.221Z" }, + { url = "https://files.pythonhosted.org/packages/3e/26/8f7b671fad38a515bb20b038718fd2221ab658299119ac9bcec56c2ced27/libcst-1.8.6-cp310-cp310-win_amd64.whl", hash = "sha256:72cca15800ffc00ba25788e4626189fe0bc5fe2a0c1cb4294bce2e4df21cc073", size = 2119189, upload-time = "2025-11-03T22:32:00.696Z" }, + { url = "https://files.pythonhosted.org/packages/5b/bf/ffb23a48e27001165cc5c81c5d9b3d6583b21b7f5449109e03a0020b060c/libcst-1.8.6-cp310-cp310-win_arm64.whl", hash = "sha256:6cad63e3a26556b020b634d25a8703b605c0e0b491426b3e6b9e12ed20f09100", size = 2001736, upload-time = "2025-11-03T22:32:02.986Z" }, + { url = "https://files.pythonhosted.org/packages/dc/15/95c2ecadc0fb4af8a7057ac2012a4c0ad5921b9ef1ace6c20006b56d3b5f/libcst-1.8.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3649a813660fbffd7bc24d3f810b1f75ac98bd40d9d6f56d1f0ee38579021073", size = 2211289, upload-time = "2025-11-03T22:32:04.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/c3/7e1107acd5ed15cf60cc07c7bb64498a33042dc4821874aea3ec4942f3cd/libcst-1.8.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0cbe17067055829607c5ba4afa46bfa4d0dd554c0b5a583546e690b7367a29b6", size = 2092927, upload-time = "2025-11-03T22:32:06.209Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ff/0d2be87f67e2841a4a37d35505e74b65991d30693295c46fc0380ace0454/libcst-1.8.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:59a7e388c57d21d63722018978a8ddba7b176e3a99bd34b9b84a576ed53f2978", size = 2237002, upload-time = "2025-11-03T22:32:07.559Z" }, + { url = "https://files.pythonhosted.org/packages/69/99/8c4a1b35c7894ccd7d33eae01ac8967122f43da41325223181ca7e4738fe/libcst-1.8.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b6c1248cc62952a3a005792b10cdef2a4e130847be9c74f33a7d617486f7e532", size = 2301048, upload-time = "2025-11-03T22:32:08.869Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8b/d1aa811eacf936cccfb386ae0585aa530ea1221ccf528d67144e041f5915/libcst-1.8.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6421a930b028c5ef4a943b32a5a78b7f1bf15138214525a2088f11acbb7d3d64", size = 2300675, upload-time = "2025-11-03T22:32:10.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/6b/7b65cd41f25a10c1fef2389ddc5c2b2cc23dc4d648083fa3e1aa7e0eeac2/libcst-1.8.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6d8b67874f2188399a71a71731e1ba2d1a2c3173b7565d1cc7ffb32e8fbaba5b", size = 2407934, upload-time = "2025-11-03T22:32:11.856Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/401cfff374bb3b785adfad78f05225225767ee190997176b2a9da9ed9460/libcst-1.8.6-cp311-cp311-win_amd64.whl", hash = "sha256:b0d8c364c44ae343937f474b2e492c1040df96d94530377c2f9263fb77096e4f", size = 2119247, upload-time = "2025-11-03T22:32:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/085f59eaa044b6ff6bc42148a5449df2b7f0ba567307de7782fe85c39ee2/libcst-1.8.6-cp311-cp311-win_arm64.whl", hash = "sha256:5dcaaebc835dfe5755bc85f9b186fb7e2895dda78e805e577fef1011d51d5a5c", size = 2001774, upload-time = "2025-11-03T22:32:14.647Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3c/93365c17da3d42b055a8edb0e1e99f1c60c776471db6c9b7f1ddf6a44b28/libcst-1.8.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c13d5bd3d8414a129e9dccaf0e5785108a4441e9b266e1e5e9d1f82d1b943c9", size = 2206166, upload-time = "2025-11-03T22:32:16.012Z" }, + { url = "https://files.pythonhosted.org/packages/1d/cb/7530940e6ac50c6dd6022349721074e19309eb6aa296e942ede2213c1a19/libcst-1.8.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1472eeafd67cdb22544e59cf3bfc25d23dc94058a68cf41f6654ff4fcb92e09", size = 2083726, upload-time = "2025-11-03T22:32:17.312Z" }, + { url = "https://files.pythonhosted.org/packages/1b/cf/7e5eaa8c8f2c54913160671575351d129170db757bb5e4b7faffed022271/libcst-1.8.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:089c58e75cb142ec33738a1a4ea7760a28b40c078ab2fd26b270dac7d2633a4d", size = 2235755, upload-time = "2025-11-03T22:32:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/55/54/570ec2b0e9a3de0af9922e3bb1b69a5429beefbc753a7ea770a27ad308bd/libcst-1.8.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c9d7aeafb1b07d25a964b148c0dda9451efb47bbbf67756e16eeae65004b0eb5", size = 2301473, upload-time = "2025-11-03T22:32:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/11/4c/163457d1717cd12181c421a4cca493454bcabd143fc7e53313bc6a4ad82a/libcst-1.8.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:207481197afd328aa91d02670c15b48d0256e676ce1ad4bafb6dc2b593cc58f1", size = 2298899, upload-time = "2025-11-03T22:32:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/35/1d/317ddef3669883619ef3d3395ea583305f353ef4ad87d7a5ac1c39be38e3/libcst-1.8.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:375965f34cc6f09f5f809244d3ff9bd4f6cb6699f571121cebce53622e7e0b86", size = 2408239, upload-time = "2025-11-03T22:32:23.275Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a1/f47d8cccf74e212dd6044b9d6dbc223636508da99acff1d54786653196bc/libcst-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:da95b38693b989eaa8d32e452e8261cfa77fe5babfef1d8d2ac25af8c4aa7e6d", size = 2119660, upload-time = "2025-11-03T22:32:24.822Z" }, + { url = "https://files.pythonhosted.org/packages/19/d0/dd313bf6a7942cdf951828f07ecc1a7695263f385065edc75ef3016a3cb5/libcst-1.8.6-cp312-cp312-win_arm64.whl", hash = "sha256:bff00e1c766658adbd09a175267f8b2f7616e5ee70ce45db3d7c4ce6d9f6bec7", size = 1999824, upload-time = "2025-11-03T22:32:26.131Z" }, + { url = "https://files.pythonhosted.org/packages/90/01/723cd467ec267e712480c772aacc5aa73f82370c9665162fd12c41b0065b/libcst-1.8.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7445479ebe7d1aff0ee094ab5a1c7718e1ad78d33e3241e1a1ec65dcdbc22ffb", size = 2206386, upload-time = "2025-11-03T22:32:27.422Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/b944944f910f24c094f9b083f76f61e3985af5a376f5342a21e01e2d1a81/libcst-1.8.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fc3fef8a2c983e7abf5d633e1884c5dd6fa0dcb8f6e32035abd3d3803a3a196", size = 2083945, upload-time = "2025-11-03T22:32:28.847Z" }, + { url = "https://files.pythonhosted.org/packages/36/a1/bd1b2b2b7f153d82301cdaddba787f4a9fc781816df6bdb295ca5f88b7cf/libcst-1.8.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1a3a5e4ee870907aa85a4076c914ae69066715a2741b821d9bf16f9579de1105", size = 2235818, upload-time = "2025-11-03T22:32:30.504Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ab/f5433988acc3b4d188c4bb154e57837df9488cc9ab551267cdeabd3bb5e7/libcst-1.8.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6609291c41f7ad0bac570bfca5af8fea1f4a27987d30a1fa8b67fe5e67e6c78d", size = 2301289, upload-time = "2025-11-03T22:32:31.812Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/89f4ba7a6f1ac274eec9903a9e9174890d2198266eee8c00bc27eb45ecf7/libcst-1.8.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25eaeae6567091443b5374b4c7d33a33636a2d58f5eda02135e96fc6c8807786", size = 2299230, upload-time = "2025-11-03T22:32:33.242Z" }, + { url = "https://files.pythonhosted.org/packages/f2/36/0aa693bc24cce163a942df49d36bf47a7ed614a0cd5598eee2623bc31913/libcst-1.8.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04030ea4d39d69a65873b1d4d877def1c3951a7ada1824242539e399b8763d30", size = 2408519, upload-time = "2025-11-03T22:32:34.678Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/6dd055b5f15afa640fb3304b2ee9df8b7f72e79513814dbd0a78638f4a0e/libcst-1.8.6-cp313-cp313-win_amd64.whl", hash = "sha256:8066f1b70f21a2961e96bedf48649f27dfd5ea68be5cd1bed3742b047f14acde", size = 2119853, upload-time = "2025-11-03T22:32:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ed/5ddb2a22f0b0abdd6dcffa40621ada1feaf252a15e5b2733a0a85dfd0429/libcst-1.8.6-cp313-cp313-win_arm64.whl", hash = "sha256:c188d06b583900e662cd791a3f962a8c96d3dfc9b36ea315be39e0a4c4792ebf", size = 1999808, upload-time = "2025-11-03T22:32:38.1Z" }, + { url = "https://files.pythonhosted.org/packages/25/d3/72b2de2c40b97e1ef4a1a1db4e5e52163fc7e7740ffef3846d30bc0096b5/libcst-1.8.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c41c76e034a1094afed7057023b1d8967f968782433f7299cd170eaa01ec033e", size = 2190553, upload-time = "2025-11-03T22:32:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/0d/20/983b7b210ccc3ad94a82db54230e92599c4a11b9cfc7ce3bc97c1d2df75c/libcst-1.8.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5432e785322aba3170352f6e72b32bea58d28abd141ac37cc9b0bf6b7c778f58", size = 2074717, upload-time = "2025-11-03T22:32:41.373Z" }, + { url = "https://files.pythonhosted.org/packages/13/f2/9e01678fedc772e09672ed99930de7355757035780d65d59266fcee212b8/libcst-1.8.6-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:85b7025795b796dea5284d290ff69de5089fc8e989b25d6f6f15b6800be7167f", size = 2225834, upload-time = "2025-11-03T22:32:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/4a/0d/7bed847b5c8c365e9f1953da274edc87577042bee5a5af21fba63276e756/libcst-1.8.6-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:536567441182a62fb706e7aa954aca034827b19746832205953b2c725d254a93", size = 2287107, upload-time = "2025-11-03T22:32:44.549Z" }, + { url = "https://files.pythonhosted.org/packages/02/f0/7e51fa84ade26c518bfbe7e2e4758b56d86a114c72d60309ac0d350426c4/libcst-1.8.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f04d3672bde1704f383a19e8f8331521abdbc1ed13abb349325a02ac56e5012", size = 2288672, upload-time = "2025-11-03T22:32:45.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cd/15762659a3f5799d36aab1bc2b7e732672722e249d7800e3c5f943b41250/libcst-1.8.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f04febcd70e1e67917be7de513c8d4749d2e09206798558d7fe632134426ea4", size = 2392661, upload-time = "2025-11-03T22:32:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6b/b7f9246c323910fcbe021241500f82e357521495dcfe419004dbb272c7cb/libcst-1.8.6-cp313-cp313t-win_amd64.whl", hash = "sha256:1dc3b897c8b0f7323412da3f4ad12b16b909150efc42238e19cbf19b561cc330", size = 2105068, upload-time = "2025-11-03T22:32:49.145Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0b/4fd40607bc4807ec2b93b054594373d7fa3d31bb983789901afcb9bcebe9/libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42", size = 1985181, upload-time = "2025-11-03T22:32:50.597Z" }, + { url = "https://files.pythonhosted.org/packages/3a/60/4105441989e321f7ad0fd28ffccb83eb6aac0b7cfb0366dab855dcccfbe5/libcst-1.8.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b188e626ce61de5ad1f95161b8557beb39253de4ec74fc9b1f25593324a0279c", size = 2204202, upload-time = "2025-11-03T22:32:52.311Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/51a6f285c3a183e50cfe5269d4a533c21625aac2c8de5cdf2d41f079320d/libcst-1.8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87e74f7d7dfcba9efa91127081e22331d7c42515f0a0ac6e81d4cf2c3ed14661", size = 2083581, upload-time = "2025-11-03T22:32:54.269Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/921b1c19b638860af76cdb28bc81d430056592910b9478eea49e31a7f47a/libcst-1.8.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a926a4b42015ee24ddfc8ae940c97bd99483d286b315b3ce82f3bafd9f53474", size = 2236495, upload-time = "2025-11-03T22:32:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/12/a8/b00592f9bede618cbb3df6ffe802fc65f1d1c03d48a10d353b108057d09c/libcst-1.8.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fbb7f569e69fd9e89d9d9caa57ca42c577c28ed05062f96a8c207594e75b8", size = 2301466, upload-time = "2025-11-03T22:32:57.337Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/790d9002f31580fefd0aec2f373a0f5da99070e04c5e8b1c995d0104f303/libcst-1.8.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08bd63a8ce674be431260649e70fca1d43f1554f1591eac657f403ff8ef82c7a", size = 2300264, upload-time = "2025-11-03T22:32:58.852Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/dc3f10e65bab461be5de57850d2910a02c24c3ddb0da28f0e6e4133c3487/libcst-1.8.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e00e275d4ba95d4963431ea3e409aa407566a74ee2bf309a402f84fc744abe47", size = 2408572, upload-time = "2025-11-03T22:33:00.552Z" }, + { url = "https://files.pythonhosted.org/packages/20/3b/35645157a7590891038b077db170d6dd04335cd2e82a63bdaa78c3297dfe/libcst-1.8.6-cp314-cp314-win_amd64.whl", hash = "sha256:fea5c7fa26556eedf277d4f72779c5ede45ac3018650721edd77fd37ccd4a2d4", size = 2193917, upload-time = "2025-11-03T22:33:02.354Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a2/1034a9ba7d3e82f2c2afaad84ba5180f601aed676d92b76325797ad60951/libcst-1.8.6-cp314-cp314-win_arm64.whl", hash = "sha256:bb9b4077bdf8857b2483879cbbf70f1073bc255b057ec5aac8a70d901bb838e9", size = 2078748, upload-time = "2025-11-03T22:33:03.707Z" }, + { url = "https://files.pythonhosted.org/packages/95/a1/30bc61e8719f721a5562f77695e6154e9092d1bdf467aa35d0806dcd6cea/libcst-1.8.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:55ec021a296960c92e5a33b8d93e8ad4182b0eab657021f45262510a58223de1", size = 2188980, upload-time = "2025-11-03T22:33:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/2c/14/c660204532407c5628e3b615015a902ed2d0b884b77714a6bdbe73350910/libcst-1.8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ba9ab2b012fbd53b36cafd8f4440a6b60e7e487cd8b87428e57336b7f38409a4", size = 2074828, upload-time = "2025-11-03T22:33:06.864Z" }, + { url = "https://files.pythonhosted.org/packages/82/e2/c497c354943dff644749f177ee9737b09ed811b8fc842b05709a40fe0d1b/libcst-1.8.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c0a0cc80aebd8aa15609dd4d330611cbc05e9b4216bcaeabba7189f99ef07c28", size = 2225568, upload-time = "2025-11-03T22:33:08.354Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/45999676d07bd6d0eefa28109b4f97124db114e92f9e108de42ba46a8028/libcst-1.8.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:42a4f68121e2e9c29f49c97f6154e8527cd31021809cc4a941c7270aa64f41aa", size = 2286523, upload-time = "2025-11-03T22:33:10.206Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6c/517d8bf57d9f811862f4125358caaf8cd3320a01291b3af08f7b50719db4/libcst-1.8.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a434c521fadaf9680788b50d5c21f4048fa85ed19d7d70bd40549fbaeeecab1", size = 2288044, upload-time = "2025-11-03T22:33:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/24d7d49478ffb61207f229239879845da40a374965874f5ee60f96b02ddb/libcst-1.8.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a65f844d813ab4ef351443badffa0ae358f98821561d19e18b3190f59e71996", size = 2392605, upload-time = "2025-11-03T22:33:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/39/c3/829092ead738b71e96a4e96896c96f276976e5a8a58b4473ed813d7c962b/libcst-1.8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb14bc4d4d83a57062fed2c5da93ecb426ff65b0dc02ddf3481040f5f074a82", size = 2181581, upload-time = "2025-11-03T22:33:14.514Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/5d6a790a02eb0d9d36c4aed4f41b277497e6178900b2fa29c35353aa45ed/libcst-1.8.6-cp314-cp314t-win_arm64.whl", hash = "sha256:819c8081e2948635cab60c603e1bbdceccdfe19104a242530ad38a36222cb88f", size = 2065000, upload-time = "2025-11-03T22:33:16.257Z" }, +] + [[package]] name = "logfire" version = "4.31.0" @@ -944,6 +1016,7 @@ dev = [ { name = "inline-snapshot" }, { name = "logfire" }, { name = "mcp", extra = ["cli"] }, + { name = "mcp-codemod" }, { name = "mcp-example-stories" }, { name = "opentelemetry-sdk" }, { name = "pillow" }, @@ -1001,6 +1074,7 @@ dev = [ { name = "inline-snapshot", specifier = ">=0.23.0" }, { name = "logfire", specifier = ">=3.0.0" }, { name = "mcp", extras = ["cli"], editable = "." }, + { name = "mcp-codemod", editable = "src/mcp-codemod" }, { name = "mcp-example-stories", editable = "examples" }, { name = "opentelemetry-sdk", specifier = ">=1.39.1" }, { name = "pillow", specifier = ">=12.0" }, @@ -1024,6 +1098,16 @@ docs = [ { name = "mkdocstrings-python", specifier = ">=2.0.1" }, ] +[[package]] +name = "mcp-codemod" +source = { editable = "src/mcp-codemod" } +dependencies = [ + { name = "libcst" }, +] + +[package.metadata] +requires-dist = [{ name = "libcst", specifier = ">=1.8.6" }] + [[package]] name = "mcp-everything-server" version = "0.1.0" @@ -1518,7 +1602,8 @@ dependencies = [ { name = "mkdocs-get-deps" }, { name = "packaging" }, { name = "pathspec" }, - { name = "pyyaml" }, + { name = "pyyaml", version = "6.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "pyyaml", version = "6.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "pyyaml-env-tag" }, { name = "watchdog" }, ] @@ -1560,7 +1645,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mergedeep" }, { name = "platformdirs" }, - { name = "pyyaml" }, + { name = "pyyaml", version = "6.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "pyyaml", version = "6.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } wheels = [ @@ -2139,7 +2225,8 @@ version = "10.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, - { name = "pyyaml" }, + { name = "pyyaml", version = "6.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "pyyaml", version = "6.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/b3/6d2b3f149bc5413b0a29761c2c5832d8ce904a1d7f621e86616d96f505cc/pymdown_extensions-10.16.1.tar.gz", hash = "sha256:aace82bcccba3efc03e25d584e6a22d27a8e17caa3f4dd9f207e49b787aa9a91", size = 853277, upload-time = "2025-07-28T16:19:34.167Z" } wheels = [ @@ -2324,6 +2411,10 @@ wheels = [ name = "pyyaml" version = "6.0.2" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, @@ -2364,18 +2455,110 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", +] +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "pyyaml-env-tag" version = "1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyyaml" }, + { name = "pyyaml", version = "6.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "pyyaml", version = "6.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] +[[package]] +name = "pyyaml-ft" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/eb/5a0d575de784f9a1f94e2b1288c6886f13f34185e13117ed530f32b6f8a8/pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab", size = 141057, upload-time = "2025-06-10T15:32:15.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/ba/a067369fe61a2e57fb38732562927d5bae088c73cb9bb5438736a9555b29/pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6", size = 187027, upload-time = "2025-06-10T15:31:48.722Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c5/a3d2020ce5ccfc6aede0d45bcb870298652ac0cf199f67714d250e0cdf39/pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69", size = 176146, upload-time = "2025-06-10T15:31:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bb/23a9739291086ca0d3189eac7cd92b4d00e9fdc77d722ab610c35f9a82ba/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0", size = 746792, upload-time = "2025-06-10T15:31:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c2/e8825f4ff725b7e560d62a3609e31d735318068e1079539ebfde397ea03e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42", size = 786772, upload-time = "2025-06-10T15:31:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/35/be/58a4dcae8854f2fdca9b28d9495298fd5571a50d8430b1c3033ec95d2d0e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b", size = 778723, upload-time = "2025-06-10T15:31:56.093Z" }, + { url = "https://files.pythonhosted.org/packages/86/ed/fed0da92b5d5d7340a082e3802d84c6dc9d5fa142954404c41a544c1cb92/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254", size = 758478, upload-time = "2025-06-10T15:31:58.314Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/ac02afe286275980ecb2dcdc0156617389b7e0c0a3fcdedf155c67be2b80/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8", size = 799159, upload-time = "2025-06-10T15:31:59.675Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ac/c492a9da2e39abdff4c3094ec54acac9747743f36428281fb186a03fab76/pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96", size = 158779, upload-time = "2025-06-10T15:32:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9b/41998df3298960d7c67653669f37710fa2d568a5fc933ea24a6df60acaf6/pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb", size = 191331, upload-time = "2025-06-10T15:32:02.602Z" }, + { url = "https://files.pythonhosted.org/packages/0f/16/2710c252ee04cbd74d9562ebba709e5a284faeb8ada88fcda548c9191b47/pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1", size = 182879, upload-time = "2025-06-10T15:32:04.466Z" }, + { url = "https://files.pythonhosted.org/packages/9a/40/ae8163519d937fa7bfa457b6f78439cc6831a7c2b170e4f612f7eda71815/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49", size = 811277, upload-time = "2025-06-10T15:32:06.214Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/28d82dbff7f87b96f0eeac79b7d972a96b4980c1e445eb6a857ba91eda00/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b", size = 831650, upload-time = "2025-06-10T15:32:08.076Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/161c4566facac7d75a9e182295c223060373d4116dead9cc53a265de60b9/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a", size = 815755, upload-time = "2025-06-10T15:32:09.435Z" }, + { url = "https://files.pythonhosted.org/packages/05/10/f42c48fa5153204f42eaa945e8d1fd7c10d6296841dcb2447bf7da1be5c4/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e", size = 810403, upload-time = "2025-06-10T15:32:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/e369064aa51009eb9245399fd8ad2c562bd0bcd392a00be44b2a824ded7c/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255", size = 835581, upload-time = "2025-06-10T15:32:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579, upload-time = "2025-06-10T15:32:14.34Z" }, +] + [[package]] name = "referencing" version = "0.36.2" From 86c012ccee96fe9cc7bd5657e60f310b1ebf573f Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:37:59 +0000 Subject: [PATCH 2/2] Flag removed modules, update dependency files, add a batch-test harness 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. --- docs/migration.md | 2 +- scripts/codemod-batch-test/.gitignore | 1 + scripts/codemod-batch-test/README.md | 42 ++ scripts/codemod-batch-test/repos.json | 30 ++ scripts/codemod-batch-test/run.py | 325 +++++++++++++++ src/mcp-codemod/README.md | 10 +- src/mcp-codemod/mcp_codemod/_dependencies.py | 353 ++++++++++++++++ src/mcp-codemod/mcp_codemod/_mappings.py | 54 +++ src/mcp-codemod/mcp_codemod/_transformer.py | 117 +++++- src/mcp-codemod/mcp_codemod/cli.py | 39 +- src/mcp-codemod/pyproject.toml | 2 + tests/codemod/test_cli.py | 46 +++ tests/codemod/test_dependencies.py | 405 +++++++++++++++++++ tests/codemod/test_mappings.py | 179 +++++++- tests/codemod/test_transformer.py | 165 ++++++-- uv.lock | 6 +- 16 files changed, 1727 insertions(+), 49 deletions(-) create mode 100644 scripts/codemod-batch-test/.gitignore create mode 100644 scripts/codemod-batch-test/README.md create mode 100644 scripts/codemod-batch-test/repos.json create mode 100644 scripts/codemod-batch-test/run.py create mode 100644 src/mcp-codemod/mcp_codemod/_dependencies.py create mode 100644 tests/codemod/test_dependencies.py diff --git a/docs/migration.md b/docs/migration.md index c3426b7be1..0e2f307903 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -8,7 +8,7 @@ Version 2 of the MCP Python SDK introduces several breaking changes to improve t ## Automated migration -The `mcp-codemod` tool (published from `src/mcp-codemod` in this repository) rewrites every change in this guide whose meaning is unambiguous from the file alone -- the import moves, the symbol renames, the `MCPError` reshape, and the camelCase to snake_case field renames -- and inserts a `# mcp-codemod:` comment above every site it recognized but would not guess at. Run it on a clean branch first, then work through what it marked: +The `mcp-codemod` tool (published from `src/mcp-codemod` in this repository) rewrites every change in this guide whose meaning is unambiguous from the file alone -- the import moves, the symbol renames, the `MCPError` reshape, the camelCase to snake_case field renames, and the `mcp` requirement in `pyproject.toml` / `requirements*.txt` -- and inserts a `# mcp-codemod:` comment above every site it recognized but would not guess at. Run it on a clean branch first, then work through what it marked: ```bash uvx mcp-codemod v1-to-v2 ./src diff --git a/scripts/codemod-batch-test/.gitignore b/scripts/codemod-batch-test/.gitignore new file mode 100644 index 0000000000..9d931c43c2 --- /dev/null +++ b/scripts/codemod-batch-test/.gitignore @@ -0,0 +1 @@ +work/ diff --git a/scripts/codemod-batch-test/README.md b/scripts/codemod-batch-test/README.md new file mode 100644 index 0000000000..9ad2342506 --- /dev/null +++ b/scripts/codemod-batch-test/README.md @@ -0,0 +1,42 @@ +# Codemod batch test + +Runs the `mcp-codemod` v1 -> v2 migration against real, pinned repositories and +audits the result with pyright, to find silent misses the unit tests and the +in-repo example corpus cannot. + +## How it works + +For each repository in `repos.json`: + +1. Clone the pinned commit (shallow). +2. Run the codemod (sources and dependency files) over a copy. +3. Type-check the pristine clone against an environment holding the latest v1 + SDK, and the migrated copy against this workspace's v2 environment, with + identical pyright settings. +4. Diff the two error sets. Errors only on the migrated side are the migration + surface; baseline noise (the repo's own issues, missing third-party stubs) + appears on both sides and cancels out. +5. Correlate each new error with the inserted `# mcp-codemod:` markers. + +The codemod's contract is that the markers are the complete list of remaining +manual work, so every new error should sit on or next to a marker. **A new +error with no nearby marker is a silent miss** -- those are printed, written to +`work/results/.json`, and make the run exit 1. + +## Usage + +From the repository root (the v1 environment is created on first run): + +```bash +uv run --frozen python scripts/codemod-batch-test/run.py # all repos +uv run --frozen python scripts/codemod-batch-test/run.py --repo mcp-obsidian +uv run --frozen python scripts/codemod-batch-test/run.py --fresh # re-clone +``` + +## Adding a repository + +Add an entry to `repos.json` with a pinned `sha` (never a branch), an +`include` list when only part of the repository uses the SDK (empty means the +whole tree), and a one-line `note`. Prefer repositories that depend on the +`mcp` package directly; servers built on the external FastMCP library exercise +that library's surface, not this SDK's. diff --git a/scripts/codemod-batch-test/repos.json b/scripts/codemod-batch-test/repos.json new file mode 100644 index 0000000000..6beb547684 --- /dev/null +++ b/scripts/codemod-batch-test/repos.json @@ -0,0 +1,30 @@ +[ + { + "slug": "official-servers", + "url": "https://github.com/modelcontextprotocol/servers", + "sha": "7b1170d1da1e36bc9f553f51e76e64cbfd652b3e", + "include": ["src/fetch", "src/git", "src/time"], + "note": "The official reference servers; lowlevel Server and FastMCP usage." + }, + { + "slug": "mcp-obsidian", + "url": "https://github.com/MarkusPfundstein/mcp-obsidian", + "sha": "32285e9ac07049a8a23ea7d7903603a3e48a1bf7", + "include": [], + "note": "Popular community server; lowlevel Server with mcp.types throughout." + }, + { + "slug": "awslabs-aws-documentation", + "url": "https://github.com/awslabs/mcp", + "sha": "3a5294539de4de3a91d0ee72d5487bc8b8b1fcd7", + "include": ["src/aws-documentation-mcp-server"], + "note": "One server from the awslabs monorepo; production FastMCP usage." + }, + { + "slug": "android-mcp-server", + "url": "https://github.com/minhalvp/android-mcp-server", + "sha": "451d255a7305e6efef8a1a2b7374a21c512bba45", + "include": [], + "note": "Small community FastMCP server." + } +] diff --git a/scripts/codemod-batch-test/run.py b/scripts/codemod-batch-test/run.py new file mode 100644 index 0000000000..aba4ddbc8a --- /dev/null +++ b/scripts/codemod-batch-test/run.py @@ -0,0 +1,325 @@ +"""Run the v1 -> v2 codemod against real pinned repositories and audit the result. + +For each repository in `repos.json` this script clones the pinned commit, runs +the codemod over a copy, and type-checks both sides with pyright: the pristine +clone against an environment holding the latest v1 SDK, the migrated copy +against this workspace's v2 environment. Errors that appear only on the +migrated side are the migration surface; each one is then correlated with the +`# mcp-codemod:` markers the codemod inserted. + +The codemod's headline contract is that the markers are the complete list of +remaining manual work, so every new error should sit on or next to a marker. A +new error with no nearby marker is a silent miss -- the exit code is 1 when any +exists, and each is printed for triage. + +Usage, from the repository root: + + uv run --frozen python scripts/codemod-batch-test/run.py [--repo SLUG] [--fresh] +""" + +import argparse +import json +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +from mcp_codemod._dependencies import update_dependencies +from mcp_codemod._runner import discover +from mcp_codemod._runner import run as run_codemod +from mcp_codemod._transformer import MARKER + +HARNESS_DIR = Path(__file__).resolve().parent +WORKSPACE_ROOT = HARNESS_DIR.parents[1] +WORK_DIR = HARNESS_DIR / "work" + +# The marker-to-error distance (in lines) still counted as "this error is +# explained by that marker". Markers sit on the line above their site; a small +# allowance covers multi-line statements. +MARKER_RADIUS = 3 + +# Uncovered errors default to actionable. Only these pyright rules, in a file +# the codemod did not touch and with no mcp symbol in the message, are written +# off as v2's own typing getting stricter about the repo's code (mocks no +# longer satisfying defaulted generics, narrower `| None` returns). Notably +# `reportAttributeAccessIssue` is NOT here: a removed attribute the codemod +# failed to flag looks exactly like that. +DRIFT_RULES = frozenset({"reportArgumentType", "reportOptionalSubscript", "reportOptionalMemberAccess"}) + +# The v1 environment lives OUTSIDE the SDK checkout: inside it, uv resolves the +# SDK workspace itself no matter the cwd, and the env would silently hold v2. +V1_ENV_DIR = Path.home() / ".cache" / "mcp-codemod-batch-test" / "v1env" + +V1_ENV_PYPROJECT = """\ +[project] +name = "codemod-batch-test-v1-env" +version = "0" +requires-python = ">=3.10" +dependencies = ["mcp[cli,ws]>=1.9,<2"] + +# Belt and braces: never resolve as a member of some enclosing workspace. +[tool.uv.workspace] +""" + + +@dataclass(frozen=True, slots=True) +class Repo: + slug: str + url: str + sha: str + include: tuple[str, ...] + note: str + + +@dataclass(frozen=True, slots=True) +class PyrightError: + file: str + line: int + rule: str + message: str + + @property + def key(self) -> tuple[str, str, str]: + """Line-independent identity, so unrelated baseline noise cancels out.""" + return (self.file, self.rule, self.message) + + +def _run(command: list[str], *, cwd: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, cwd=cwd, capture_output=True, text=True, check=False) + + +def _load_repos(only: str | None) -> list[Repo]: + raw: object = json.loads((HARNESS_DIR / "repos.json").read_text()) + assert isinstance(raw, list) + repos: list[Repo] = [] + for entry in raw: + assert isinstance(entry, dict) + repo = Repo( + slug=str(entry["slug"]), + url=str(entry["url"]), + sha=str(entry["sha"]), + include=tuple(str(item) for item in entry["include"]), + note=str(entry["note"]), + ) + if only is None or repo.slug == only: + repos.append(repo) + return repos + + +def _ensure_v1_environment() -> Path: + """Create (once) an environment holding the latest v1 SDK; return its python. + + The returned interpreter is verified to really import a v1 `mcp.types` -- + a baseline accidentally type-checked against v2 reports no migration delta + at all, so this fails loudly instead. + """ + env_dir = V1_ENV_DIR + python = env_dir / ".venv" / "bin" / "python" + if not python.is_file(): + env_dir.mkdir(parents=True, exist_ok=True) + (env_dir / "pyproject.toml").write_text(V1_ENV_PYPROJECT) + print("setting up the v1 environment (one-time)...") + sync = _run(["uv", "sync"], cwd=env_dir) + if sync.returncode != 0: + sys.exit(f"v1 environment setup failed:\n{sync.stderr}") + probe = _run([str(python), "-c", "import mcp.types"], cwd=env_dir) + if probe.returncode != 0: + sys.exit(f"the v1 environment does not hold a v1 SDK:\n{probe.stderr}") + return python + + +def _clone_pinned(repo: Repo, destination: Path, *, fresh: bool) -> None: + if destination.is_dir(): + if not fresh: + return + shutil.rmtree(destination) + destination.mkdir(parents=True) + for command in ( + ["git", "init", "-q"], + ["git", "remote", "add", "origin", repo.url], + ["git", "fetch", "-q", "--depth", "1", "origin", repo.sha], + ["git", "checkout", "-q", "FETCH_HEAD"], + ): + result = _run(command, cwd=destination) + if result.returncode != 0: + sys.exit(f"{repo.slug}: `{' '.join(command)}` failed:\n{result.stderr}") + + +def _side_roots(repo: Repo, side: Path) -> list[Path]: + return [side / sub for sub in repo.include] if repo.include else [side] + + +def _pyright_errors(repo: Repo, *, python: Path, side: Path) -> list[PyrightError] | None: + """Type-check one side against the env of `python`, or None when pyright dies. + + The config is written into the side's own root with relative includes, so + that root is the project root and nothing outside it is ever scanned. The + interpreter goes on the command line: `--pythonpath` beats the implicit + `VIRTUAL_ENV` that `uv run` exports, which a config `venvPath` does not. + """ + config = { + "include": list(repo.include) or ["."], + "typeCheckingMode": "basic", + } + (side / "pyrightconfig.json").write_text(json.dumps(config)) + result = _run( + ["uv", "run", "--frozen", "pyright", "--project", str(side), "--pythonpath", str(python), "--outputjson"], + cwd=WORKSPACE_ROOT, + ) + try: + output: object = json.loads(result.stdout) + except json.JSONDecodeError: + print(f" pyright produced no JSON (exit {result.returncode}):\n{result.stderr}", file=sys.stderr) + return None + assert isinstance(output, dict) + summary = output.get("summary") + assert isinstance(summary, dict) + if not summary.get("filesAnalyzed"): + # A bad include path makes pyright "succeed" over nothing; a verdict + # based on that would be a lie, so the repo fails instead. + print(f" pyright analyzed zero files in {side} -- check the include paths", file=sys.stderr) + return None + diagnostics = output.get("generalDiagnostics") + assert isinstance(diagnostics, list) + errors: list[PyrightError] = [] + for diagnostic in diagnostics: + assert isinstance(diagnostic, dict) + if diagnostic.get("severity") != "error": + continue + file = str(Path(str(diagnostic["file"])).relative_to(side)) + start = diagnostic["range"]["start"]["line"] + assert isinstance(start, int) + errors.append( + PyrightError( + file=file, + line=start + 1, # pyright lines are zero-based + rule=str(diagnostic.get("rule", "")), + message=str(diagnostic["message"]), + ) + ) + return errors + + +def _collect_markers(roots: list[Path], side: Path) -> dict[str, list[int]]: + """Every `# mcp-codemod:` line in the migrated tree, by file.""" + markers: dict[str, list[int]] = {} + needle = f"# {MARKER}:" + for root in roots: + candidates = [path for path in root.rglob("*") if path.suffix == ".py" or path.name == "pyproject.toml"] + candidates += list(root.rglob("requirements*.txt")) + for path in candidates: + try: + lines = path.read_bytes().decode("utf-8").splitlines() + except (OSError, UnicodeDecodeError): + continue + hits = [number for number, line in enumerate(lines, start=1) if needle in line] + if hits: + markers[str(path.relative_to(side))] = hits + return markers + + +def _audit_repo(repo: Repo, *, v1_python: Path, fresh: bool) -> tuple[dict[str, object], int] | None: + print(f"\n=== {repo.slug} ({repo.note})") + pristine = WORK_DIR / "repos" / repo.slug / "pristine" + migrated = WORK_DIR / "repos" / repo.slug / "migrated" + _clone_pinned(repo, pristine, fresh=fresh) + + if migrated.is_dir(): + shutil.rmtree(migrated) + shutil.copytree(pristine, migrated, ignore=shutil.ignore_patterns(".git")) + + roots = _side_roots(repo, migrated) + report = run_codemod(discover(roots), write=True) + dependency_reports = update_dependencies(roots, write=True) + severities = report.diagnostics + rewritten_files = {str(file.path.relative_to(migrated)) for file in report.changed} + print( + f" codemod: {len(report.changed)} of {len(report.files)} files rewritten, " + f"{severities['manual'] + severities['review']} flagged sites, " + f"{sum(1 for dependency in dependency_reports if dependency.changed)} dependency files updated" + ) + + baseline = _pyright_errors(repo, python=v1_python, side=pristine) + post = _pyright_errors(repo, python=WORKSPACE_ROOT / ".venv" / "bin" / "python", side=migrated) + if baseline is None or post is None: + return None + baseline_keys = {error.key for error in baseline} + new_errors = [error for error in post if error.key not in baseline_keys] + resolved = len(baseline) - len([error for error in baseline if error.key in {e.key for e in post}]) + + markers = _collect_markers(roots, migrated) + actionable: list[PyrightError] = [] + drift: list[PyrightError] = [] + for error in new_errors: + nearby = markers.get(error.file, []) + if any(abs(line - error.line) <= MARKER_RADIUS for line in nearby): + continue + # Uncovered errors are actionable unless everything says v2 strictness + # drift: an untouched file, no mcp symbol in the message, and a rule + # from the drift list. A silent codemod miss fails any one of these. + if error.file not in rewritten_files and "mcp" not in error.message.lower() and error.rule in DRIFT_RULES: + drift.append(error) + else: + actionable.append(error) + + covered = len(new_errors) - len(actionable) - len(drift) + print( + f" pyright: {len(baseline)} baseline errors, {len(new_errors)} new after migration " + f"({resolved} resolved): {covered} covered by markers, {len(drift)} v2 strictness drift" + ) + for error in actionable: + print(f" UNCOVERED {error.file}:{error.line} [{error.rule}] {error.message.splitlines()[0]}") + + result: dict[str, object] = { + "slug": repo.slug, + "sha": repo.sha, + "files_rewritten": len(report.changed), + "files_total": len(report.files), + "flagged_sites": severities["manual"] + severities["review"], + "baseline_errors": len(baseline), + "new_errors": len(new_errors), + "covered_by_markers": covered, + "strictness_drift": [ + {"file": error.file, "line": error.line, "rule": error.rule, "message": error.message} for error in drift + ], + "uncovered": [ + {"file": error.file, "line": error.line, "rule": error.rule, "message": error.message} + for error in actionable + ], + } + return result, len(actionable) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", help="run a single repository by slug") + parser.add_argument("--fresh", action="store_true", help="re-clone repositories even when present") + args = parser.parse_args() + + repos = _load_repos(args.repo) + if not repos: + sys.exit(f"no repository matches {args.repo!r}") + WORK_DIR.mkdir(exist_ok=True) + v1_python = _ensure_v1_environment() + + results: list[dict[str, object]] = [] + total_uncovered = 0 + for repo in repos: + audited = _audit_repo(repo, v1_python=v1_python, fresh=args.fresh) + if audited is not None: + result, uncovered = audited + results.append(result) + total_uncovered += uncovered + + results_dir = WORK_DIR / "results" + results_dir.mkdir(exist_ok=True) + for result in results: + (results_dir / f"{result['slug']}.json").write_text(json.dumps(result, indent=2) + "\n") + + print(f"\n{len(results)} repositories audited; {total_uncovered} uncovered new errors.") + return 1 if total_uncovered or len(results) != len(repos) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/mcp-codemod/README.md b/src/mcp-codemod/README.md index 84248fe783..f0239cfa27 100644 --- a/src/mcp-codemod/README.md +++ b/src/mcp-codemod/README.md @@ -40,6 +40,11 @@ manual fix-up. change. - The `streamable_http_client(...) as (read, write, _)` three-tuple to the v2 two-tuple. +- The `mcp` requirement in `pyproject.toml` and `requirements*.txt`, to + `>=2,<3`, wherever the current constraint cannot accept any v2 release. Only + the version specifier changes; the name, extras, environment marker, and + formatting keep your spelling. A constraint that already admits v2, a Poetry + dependency table, and the removed `ws` extra are marked instead of guessed at. ## What it marks instead @@ -48,7 +53,10 @@ The codemod never guesses at these; it leaves them exactly as written and adds a `# mcp-codemod:` comment explaining what to do: - Removed APIs that have no drop-in replacement (`create_connected_server_and_client_session`, - the WebSocket transport, `mcp.shared.progress`, `get_context()`). + the WebSocket transport, `mcp.shared.progress`, `get_context()`), and imports + of whole module namespaces v2 deleted (the experimental tasks API, which is + first-class on v2). Together with the renames these account for every public + module v1 shipped, so an import is never left to fail unexplained. - The v1 `mcp.types` names with no v2 home (`Cursor`, the `TASK_*` constants, the type-machinery aliases). `mcp_types` is not a name-superset of v1's `mcp.types`, so these are marked with their replacement instead of being rewritten into an diff --git a/src/mcp-codemod/mcp_codemod/_dependencies.py b/src/mcp-codemod/mcp_codemod/_dependencies.py new file mode 100644 index 0000000000..00925cd331 --- /dev/null +++ b/src/mcp-codemod/mcp_codemod/_dependencies.py @@ -0,0 +1,353 @@ +"""Update a project's dependency declarations for the v2 SDK. + +`update_dependencies()` finds every `pyproject.toml` and `requirements*.txt` +under the given paths and rewrites the `mcp` requirement to `>=2,<3` wherever +its current specifier cannot accept any v2 release; a constraint that already +admits v2 is left exactly as written. Only the specifier changes -- the +requirement's name, extras, and environment marker keep their original +spelling. Anything that cannot be rewritten safely (a removed extra, a Poetry +dependency table) is marked with a `# mcp-codemod:` comment instead, the same +contract the source transformer follows. +""" + +import os +import re +from collections.abc import Iterator, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import TypeGuard + +import tomllib +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name +from packaging.version import InvalidVersion, Version + +from mcp_codemod._mappings import REMOVED_EXTRAS +from mcp_codemod._runner import IGNORED_DIRECTORIES +from mcp_codemod._transformer import MARKER, Diagnostic + +__all__ = ["DependencyReport", "update_dependencies"] + +V2_SPECIFIER = ">=2,<3" + +# Probes used to classify a specifier. A constraint is only rewritten when it +# provably belongs to the v1 era (it admits a v1 release, or every version it +# spells has major < 2) AND provably admits no v2 release; anything else -- +# `==2.1.4`, `>=2.1,<2.2`, the published `==2.0.0a1` -- is the user's own v2 +# choice and is never touched. +_V1_PROBES = ("1.0.0", "1.99.99") +_V2_PROBES = ("2.0.0a1", "2.0.0", "2.99.99") + +# The name-plus-extras prefix of a requirement string this module already +# validated with `Requirement`, used to splice a new specifier in behind it. +_REQUIREMENT_PREFIX = re.compile(r"^\s*[A-Za-z0-9][A-Za-z0-9._-]*\s*(\[[^\]]*\])?") + +# A `mcp = ...` key in a Poetry dependency table, which uses its own constraint +# syntax this module does not rewrite. +_POETRY_MCP_KEY = re.compile(r"^[ \t]*([\"']?)mcp\1[ \t]*=", re.MULTILINE) + +# A requirements.txt line that NAMES mcp but did not parse as a requirement +# (pip-compile continuations, `--hash=` options, URL forms): it cannot be +# rewritten, but passing it over silently would hide a v1 pin. +_UNPARSEABLE_MCP_LINE = re.compile(r"^\s*mcp\b", re.IGNORECASE) + +# The pyproject tables whose arrays hold PEP 508 strings; replacements and +# markers stay inside them so a lookalike string in a comment or some other +# tool's table is never touched. +_DEPENDENCY_TABLES = re.compile(r"^(project|project\.optional-dependencies|dependency-groups)$") + + +@dataclass(frozen=True, slots=True) +class DependencyReport: + """The outcome for one dependency file. `error` is set when it failed.""" + + path: Path + original: str + updated: str | None + diagnostics: list[Diagnostic] + error: str | None + + @property + def changed(self) -> bool: + """Whether the updated text differs from what was read.""" + return self.updated is not None and self.updated != self.original + + +def _line_of(text: str, index: int) -> int: + return text.count("\n", 0, index) + 1 + + +def _needs_v2(requirement: Requirement) -> bool: + """Whether the constraint is a v1-era one that excludes every v2 release. + + An empty specifier admits everything, and a constraint that is not provably + from the v1 era (an exact v2 pin, a narrow v2 range) is the user's own v2 + choice, so both are left exactly as written. + """ + specifier = requirement.specifier + if not str(specifier): + return False + if any(specifier.contains(probe, prereleases=True) for probe in _V2_PROBES): + return False + v1_era = any(specifier.contains(probe, prereleases=True) for probe in _V1_PROBES) + for clause in specifier: + try: + spelled_version = Version(clause.version.rstrip(".*")) + except InvalidVersion: + continue + v1_era = v1_era or spelled_version.major < 2 + return v1_era + + +def _rewrite_specifier(spelled: str) -> str: + """Replace the specifier in a validated requirement string, keeping the rest. + + The name, extras, environment marker, and even the spacing around `;` are + the user's own spelling and survive; only the version constraint changes. + """ + base, separator, env_marker = spelled.partition(";") + prefix = _REQUIREMENT_PREFIX.match(base) + assert prefix is not None # `Requirement` accepted it, so the prefix parses + spacing = base[len(base.rstrip()) :] + return f"{prefix.group(0)}{V2_SPECIFIER}{spacing}{separator}{env_marker}" + + +def _insert_marker_above(text: str, index: int, message: str) -> str: + """Insert a `# mcp-codemod:` comment line above the line containing `index`.""" + line_start = text.rfind("\n", 0, index) + 1 + line = text[line_start:] + indent = line[: len(line) - len(line.lstrip())] + ending = "\r\n" if text[line_start:].partition("\n")[0].endswith("\r") else "\n" + comment = f"{indent}# {MARKER}: {message}{ending}" + if comment in text: + return text + return text[:line_start] + comment + text[line_start:] + + +def _mcp_requirement(spelled: str) -> Requirement | None: + """Parse a dependency string, returning it only when it names `mcp` itself.""" + try: + requirement = Requirement(spelled) + except InvalidRequirement: + return None + return requirement if canonicalize_name(requirement.name) == "mcp" else None + + +def _is_table(value: object) -> TypeGuard[dict[str, object]]: + """Whether a parsed TOML value is a table (its keys are strings by grammar).""" + return isinstance(value, dict) + + +def _is_array(value: object) -> TypeGuard[list[object]]: + return isinstance(value, list) + + +def _pyproject_dependency_strings(parsed: dict[str, object]) -> Iterator[str]: + """Every PEP 508 string in the standard dependency tables of a pyproject.""" + project = parsed.get("project") + if _is_table(project): + dependencies = project.get("dependencies") + if _is_array(dependencies): + yield from (entry for entry in dependencies if isinstance(entry, str)) + optional = project.get("optional-dependencies") + if _is_table(optional): + for group in optional.values(): + if _is_array(group): + yield from (entry for entry in group if isinstance(entry, str)) + groups = parsed.get("dependency-groups") + if _is_table(groups): + for group in groups.values(): + if _is_array(group): + # A group entry may also be an `{include-group = ...}` table. + yield from (entry for entry in group if isinstance(entry, str)) + + +def _has_poetry_mcp(parsed: dict[str, object]) -> bool: + """Whether any Poetry dependency table (main, legacy dev, or group) names mcp.""" + tool = parsed.get("tool") + poetry = tool.get("poetry") if _is_table(tool) else None + if not _is_table(poetry): + return False + tables = [poetry.get("dependencies"), poetry.get("dev-dependencies")] + groups = poetry.get("group") + if _is_table(groups): + tables.extend(group.get("dependencies") for group in groups.values() if _is_table(group)) + return any(_is_table(table) and "mcp" in table for table in tables) + + +def _dependency_region_occurrences(text: str, quoted: str) -> list[int]: + """Offsets of `quoted` inside the standard dependency tables, comments excluded. + + Scanning by table keeps a lookalike string in some other tool's table or in + a TOML comment out of reach of every rewrite and marker. + """ + occurrences: list[int] = [] + offset = 0 + table = "" + for line in text.splitlines(keepends=True): + header = re.match(r"\[([^\]]+)\]", line.strip()) + if header is not None: + table = header.group(1) + elif _DEPENDENCY_TABLES.match(table): + comment_at = line.find("#") + searchable = line if comment_at == -1 else line[:comment_at] + at = searchable.find(quoted) + if at != -1: + occurrences.append(offset + at) + offset += len(line) + return occurrences + + +def _classify(requirement: Requirement) -> tuple[str, str] | None: + """The action for one `mcp` requirement: (kind, message), or None to leave it. + + `rewrite` carries no message; `flag` carries the marker text. Checked in + trust order -- a removed extra or a URL pin outranks the specifier, since + rewriting around either would lose something the user wrote deliberately. + """ + removed = sorted(extra for extra in requirement.extras if extra in REMOVED_EXTRAS) + if removed: + return ("flag", f"{REMOVED_EXTRAS[removed[0]]}; set `mcp{V2_SPECIFIER}` by hand") + if requirement.url is not None: + return ("flag", "this pins `mcp` by URL: point it at a v2 release by hand") + if _needs_v2(requirement): + return ("rewrite", "") + return None + + +def _update_pyproject(text: str, *, add_markers: bool) -> tuple[str, list[Diagnostic]]: + diagnostics: list[Diagnostic] = [] + parsed: dict[str, object] = tomllib.loads(text) + + for spelled in dict.fromkeys(_pyproject_dependency_strings(parsed)): + requirement = _mcp_requirement(spelled) + action = _classify(requirement) if requirement is not None else None + if requirement is None or action is None: + continue + # The TOML string is located by its quoted form; a requirement needing + # escapes inside a TOML string does not exist in practice. + quoted = next( + (q + spelled + q for q in ('"', "'") if _dependency_region_occurrences(text, q + spelled + q)), None + ) + if quoted is None: + continue + kind, message = action + if kind == "flag": + at = _dependency_region_occurrences(text, quoted)[0] + diagnostics.append(Diagnostic(_line_of(text, at), "dependency", "manual", message)) + if add_markers: + text = _insert_marker_above(text, at, message) + continue + replacement = quoted[0] + _rewrite_specifier(spelled) + quoted[0] + for at in reversed(_dependency_region_occurrences(text, quoted)): + text = text[:at] + replacement + text[at + len(quoted) :] + line = _line_of(text, at) + diagnostics.append( + Diagnostic(line, "dependency", "info", f"updated the `mcp` requirement to `{V2_SPECIFIER}`") + ) + + if _has_poetry_mcp(parsed): + message = f"update this Poetry constraint for v2 (`{V2_SPECIFIER}`) by hand" + # The diagnostic never depends on locating the keys in the text (an inline + # table defeats the line match); only the marker placement does. + keys = list(_POETRY_MCP_KEY.finditer(text)) + if not keys: + diagnostics.append(Diagnostic(1, "dependency", "manual", message)) + for key in reversed(keys): + diagnostics.append(Diagnostic(_line_of(text, key.start()), "dependency", "manual", message)) + if add_markers: + text = _insert_marker_above(text, key.start() + len(key.group(0)), message) + return text, diagnostics + + +def _update_requirements(text: str, *, add_markers: bool) -> tuple[str, list[Diagnostic]]: + diagnostics: list[Diagnostic] = [] + lines = text.splitlines(keepends=True) + out: list[str] = [] + for number, line in enumerate(lines, start=1): + body = line.split("#", 1)[0] + spelled = body.strip() + if not spelled or spelled.startswith("-"): + out.append(line) + continue + requirement = _mcp_requirement(spelled) + if requirement is None: + # A line that names mcp but did not parse (a pip-compile + # continuation, `--hash=` options) may still pin v1; say so. + if _UNPARSEABLE_MCP_LINE.match(spelled) and _is_unparseable(spelled): + action = ("flag", f"could not parse this `mcp` line: update it for v2 (`{V2_SPECIFIER}`) by hand") + else: + out.append(line) + continue + else: + classified = _classify(requirement) + if classified is None: + out.append(line) + continue + action = classified + kind, message = action + if kind == "flag": + diagnostics.append(Diagnostic(number, "dependency", "manual", message)) + if add_markers: + ending = "\r\n" if line.endswith("\r\n") else "\n" + comment = f"# {MARKER}: {message}{ending}" + if out[-1:] != [comment]: + out.append(comment) + out.append(line) + continue + out.append(line.replace(spelled, _rewrite_specifier(spelled), 1)) + diagnostics.append( + Diagnostic(number, "dependency", "info", f"updated the `mcp` requirement to `{V2_SPECIFIER}`") + ) + return "".join(out), diagnostics + + +def _is_unparseable(spelled: str) -> bool: + try: + Requirement(spelled) + except InvalidRequirement: + return True + return False + + +def _dependency_files(paths: Sequence[Path]) -> Iterator[Path]: + """Yield every dependency file under the given directories, pruned and sorted.""" + for path in paths: + if not path.is_dir(): + continue + found: list[Path] = [] + for directory, child_directories, files in os.walk(path): + child_directories[:] = [name for name in child_directories if name not in IGNORED_DIRECTORIES] + found.extend( + Path(directory, name) + for name in files + if name == "pyproject.toml" or (name.startswith("requirements") and name.endswith(".txt")) + ) + yield from sorted(found) + + +def update_dependencies(paths: Sequence[Path], *, write: bool, add_markers: bool = True) -> list[DependencyReport]: + """Update the `mcp` requirement in every dependency file under `paths`. + + Files are read and written as UTF-8 bytes, like the source runner. A file + that cannot be read or parsed is reported with its error and left as found. + """ + reports: list[DependencyReport] = [] + for path in _dependency_files(paths): + source = "" + try: + source = path.read_bytes().decode("utf-8") + if path.name == "pyproject.toml": + updated, diagnostics = _update_pyproject(source, add_markers=add_markers) + else: + updated, diagnostics = _update_requirements(source, add_markers=add_markers) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: + reports.append(DependencyReport(path, source, None, [], f"{type(exc).__name__}: {exc}")) + continue + if not diagnostics and updated == source: + continue + report = DependencyReport(path, source, updated, diagnostics, None) + if write and report.changed: + path.write_bytes(updated.encode("utf-8")) + reports.append(report) + return reports diff --git a/src/mcp-codemod/mcp_codemod/_mappings.py b/src/mcp-codemod/mcp_codemod/_mappings.py index 6382411561..7067062c3b 100644 --- a/src/mcp-codemod/mcp_codemod/_mappings.py +++ b/src/mcp-codemod/mcp_codemod/_mappings.py @@ -16,12 +16,16 @@ "ERRORDATA_QNAMES", "FASTMCP_QNAMES", "LOWLEVEL_DECORATOR_METHODS", + "LOWLEVEL_REMOVED_ATTRS", "LOWLEVEL_SERVER_QNAMES", "MCPERROR_QNAMES", "MODULE_RENAMES", + "REHOMED_IMPORTS", "REMOVED_APIS", "REMOVED_ATTRS", "REMOVED_CTOR_PARAMS", + "REMOVED_EXTRAS", + "REMOVED_MODULES", "SYMBOL_RENAMES", "TRANSPORT_CLIENT_QNAMES", "TRANSPORT_CLIENT_REMOVED_PARAMS", @@ -42,6 +46,40 @@ "mcp.types": "mcp_types", } +# Imports whose v2 module is importable but is not the name's PUBLIC home, +# keyed by (renamed module, imported name) and applied after `MODULE_RENAMES`: +# `Context` moved out of `server.py` on v2, and while the module still imports +# it, a type checker treats a name a module does not re-export as private. The +# package declares it in `__all__`, so the import is split out to point there. +REHOMED_IMPORTS: dict[tuple[str, str], str] = { + ("mcp.server.mcpserver.server", "Context"): "mcp.server.mcpserver", +} + +# v1 module namespaces that no longer exist on v2 under any name, keyed by their +# roots and matched by longest prefix like `MODULE_RENAMES`. An import of one is +# marked (never rewritten or deleted); together with the renames these account +# for every public module v1 shipped, which `tests/codemod/test_mappings.py` +# pins against the frozen v1 module list and the installed v2 package. +REMOVED_MODULES: dict[str, str] = { + "mcp.client.experimental": ( + "removed: the experimental tasks API is first-class on v2; see the tasks section of the migration guide" + ), + "mcp.server.experimental": ( + "removed: the experimental tasks API is first-class on v2; see the tasks section of the migration guide" + ), + "mcp.server.lowlevel.experimental": ( + "removed: the experimental tasks API is first-class on v2; see the tasks section of the migration guide" + ), + "mcp.shared.experimental": ( + "removed: the experimental tasks API is first-class on v2; see the tasks section of the migration guide" + ), + "mcp.client.websocket": "removed: the WebSocket transport was deleted", + "mcp.server.websocket": "removed: the WebSocket transport was deleted", + "mcp.server.lowlevel.func_inspection": "removed: it was an internal helper of the lowlevel server", + "mcp.shared.progress": "removed: report progress with `ctx.report_progress()` inside a handler", + "mcp.shared.response_router": "removed: superseded by `JSONRPCDispatcher`", +} + # Symbol renames, keyed by every v1 qualified name the symbol was reachable from. # The transformer resolves a usage to its qualified name through the file's imports # (`libcst.metadata.QualifiedNameProvider`), so an aliased import is never broken @@ -111,6 +149,13 @@ "mcp.types.TASK_STATUS_CANCELLED": 'removed: use the literal string `"cancelled"`', } +# Extras the v1 `mcp` distribution declared that v2 does not, with guidance. +# Pinned against the installed distribution's `Provides-Extra` metadata by +# `tests/codemod/test_mappings.py`. +REMOVED_EXTRAS: dict[str, str] = { + "ws": "the `ws` extra was removed with the WebSocket transport", +} + # Attribute and method names that vanished from a class that still exists. These # can only be matched by name (the codemod cannot know a receiver's type), so a # name qualifies only when it is distinctive enough that a false match is @@ -240,6 +285,15 @@ def _to_snake(name: str) -> str: "mount_path": "removed: mount the app under a Starlette route instead", } +# Attributes removed from the lowlevel `Server` whose NAMES survive elsewhere on +# v2 (`Context.request_context` is a live idiom), so unlike `REMOVED_ATTRS` they +# are only matched against a receiver the pre-pass proved is a lowlevel server. +LOWLEVEL_REMOVED_ATTRS: dict[str, str] = { + "request_context": ( + "`Server.request_context` and the `request_ctx` ContextVar were removed: handlers now receive `ctx` explicitly" + ), +} + # The v1 lowlevel `Server` decorator-factory methods and the `on_*` keyword each # became on the v2 `Server` constructor. This transform is flag-only by design: # moving the registration means reordering statements across the module AND diff --git a/src/mcp-codemod/mcp_codemod/_transformer.py b/src/mcp-codemod/mcp_codemod/_transformer.py index 220d20a336..11a33aae43 100644 --- a/src/mcp-codemod/mcp_codemod/_transformer.py +++ b/src/mcp-codemod/mcp_codemod/_transformer.py @@ -45,12 +45,15 @@ ERRORDATA_QNAMES, FASTMCP_QNAMES, LOWLEVEL_DECORATOR_METHODS, + LOWLEVEL_REMOVED_ATTRS, LOWLEVEL_SERVER_QNAMES, MCPERROR_QNAMES, MODULE_RENAMES, + REHOMED_IMPORTS, REMOVED_APIS, REMOVED_ATTRS, REMOVED_CTOR_PARAMS, + REMOVED_MODULES, SYMBOL_RENAMES, TRANSPORT_CLIENT_QNAMES, TRANSPORT_CLIENT_REMOVED_PARAMS, @@ -113,6 +116,14 @@ def _rename_module(dotted: str) -> str | None: return None +def _removed_module(dotted: str) -> str | None: + """Return the guidance for a module path v2 deleted, or None if it survives.""" + for root, guidance in REMOVED_MODULES.items(): + if dotted == root or dotted.startswith(root + "."): + return guidance + return None + + def _dotted_name(dotted: str) -> cst.Attribute | cst.Name: # A dotted module path always parses to a Name or a chain of Attributes, which # is the only thing import nodes accept; `parse_expression` just cannot say so. @@ -124,6 +135,43 @@ def _names_the_sdk(module: str) -> bool: return module in ("mcp", "mcp_types") or module.startswith(("mcp.", "mcp_types.")) +def _split_rehomed_imports( + statement: cst.SimpleStatementLine, imported: cst.ImportFrom +) -> cst.SimpleStatementLine | cst.FlattenSentinel[cst.BaseStatement] | None: + """Move `REHOMED_IMPORTS` names out of an already-renamed from-import. + + Returns None when the statement imports none of them. The rehomed names keep + their `as` aliases; when nothing else was imported, the new statement takes + the original's place wholesale, formatting included. + """ + assert imported.module is not None and not isinstance(imported.names, cst.ImportStar) + module = get_full_name_for_node(imported.module) or "" + moved: list[cst.ImportAlias] = [] + kept: list[cst.ImportAlias] = [] + targets: set[str] = set() + for alias in imported.names: + name = cst.ensure_type(alias.name, cst.Name).value + target = REHOMED_IMPORTS.get((module, name)) + if target is None: + kept.append(alias) + else: + moved.append(alias.with_changes(comma=cst.MaybeSentinel.DEFAULT)) + targets.add(target) + if not moved: + return None + # Every current row rehomes to one module; revisit if a second target appears. + replacement = cst.SimpleStatementLine( + body=[cst.ImportFrom(module=_dotted_name(targets.pop()), names=moved)], + ) + if not kept: + return replacement.with_changes( + leading_lines=statement.leading_lines, trailing_whitespace=statement.trailing_whitespace + ) + kept[-1] = kept[-1].with_changes(comma=cst.MaybeSentinel.DEFAULT) + remaining = statement.with_changes(body=[imported.with_changes(names=kept)]) + return cst.FlattenSentinel([remaining, replacement]) + + def _with_markers(statement: _StatementT, messages: Sequence[str]) -> _StatementT: """Prepend a `# mcp-codemod:` comment per distinct message not already present.""" existing = {line.comment.value for line in statement.leading_lines if line.comment is not None} @@ -193,8 +241,15 @@ def visit_Attribute(self, node: cst.Attribute) -> None: self.unrenamed_reference_roots.add(qualified.name.split(".")[0]) def _record_lowlevel_server(self, value: cst.BaseExpression | None, target: cst.BaseExpression) -> None: - """When `value` calls the lowlevel `Server(...)`, remember the name it binds.""" - if not isinstance(value, cst.Call) or not isinstance(target, cst.Name): + """When `value` calls the lowlevel `Server(...)`, remember the name it binds. + + The target's full spelling is recorded, so an attribute binding like + `self.server = Server(...)` is recognized exactly like a plain name. + """ + if not isinstance(value, cst.Call): + return + bound = get_full_name_for_node(target) + if bound is None: return qualified = { q.name @@ -202,7 +257,7 @@ def _record_lowlevel_server(self, value: cst.BaseExpression | None, target: cst. if q.source is not QualifiedNameSource.LOCAL } if qualified & LOWLEVEL_SERVER_QNAMES: - self.lowlevel_server_vars.add(target.value) + self.lowlevel_server_vars.add(bound) def _record_class_field(self, target: cst.BaseExpression) -> None: """Remember a camelCase name a class body in this file declares as its own.""" @@ -309,14 +364,19 @@ def on_leave( result = super().on_leave(original_node, updated_node) if isinstance(original_node, cst.SimpleStatementLine | cst.BaseCompoundStatement): pending = self._pending_markers.pop() - if ( - pending - and self._add_markers - and isinstance(result, cst.SimpleStatementLine | cst.BaseCompoundStatement) - ): - # `result` is the same statement node `on_leave` was about to return, - # just with the marker comments prepended to its leading lines. - result = cast(_NodeT, _with_markers(result, pending)) + if pending and self._add_markers: + # At statement level every transform here returns the statement + # itself or a FlattenSentinel of statements -- nothing is removed. + if isinstance(result, cst.FlattenSentinel): + # A split statement: the markers belong above its first piece, + # which takes the original's place in the module. + pieces = list(result) + statement = cast("cst.SimpleStatementLine | cst.BaseCompoundStatement", pieces[0]) + pieces[0] = cast(_NodeT, _with_markers(statement, pending)) + result = cst.FlattenSentinel(pieces) + else: + narrowed = cast("cst.SimpleStatementLine | cst.BaseCompoundStatement", result) + result = cast(_NodeT, _with_markers(narrowed, pending)) return result def visit_ClassDef(self, node: cst.ClassDef) -> None: @@ -387,6 +447,13 @@ def leave_ImportFrom(self, original_node: cst.ImportFrom, updated_node: cst.Impo return updated_node module = get_full_name_for_node(updated_node.module) or "" + # Importing from a deleted module namespace: one marker for the whole + # statement says everything the per-name checks below could, so they are + # skipped (the names of a deleted module are gone with it). + if (module_guidance := _removed_module(module)) is not None: + self._diag(original_node, "removed_module", "manual", f"`{module}` {module_guidance}") + return updated_node + # `QualifiedNameProvider` resolves *references* to a binding; the import # alias that creates the binding gets nothing, so it is handled here: a # renamed symbol is renamed in place, and importing a name that no longer @@ -398,7 +465,7 @@ def leave_ImportFrom(self, original_node: cst.ImportFrom, updated_node: cst.Impo for alias in updated_node.names: # In a `from X import name` statement the alias is always a bare Name. qualified = f"{module}.{cst.ensure_type(alias.name, cst.Name).value}" - if (guidance := REMOVED_APIS.get(qualified)) is not None: + if (guidance := _removed_module(qualified) or REMOVED_APIS.get(qualified)) is not None: self._diag(original_node, "removed_api", "manual", f"`{qualified}` {guidance}") elif new := SYMBOL_RENAMES.get(qualified): renamed_any = True @@ -418,7 +485,9 @@ def leave_Import(self, original_node: cst.Import, updated_node: cst.Import) -> c renamed_any = False for alias in updated_node.names: dotted = get_full_name_for_node(alias.name) or "" - if (renamed := _rename_module(dotted)) is not None: + if (guidance := _removed_module(dotted)) is not None: + self._diag(original_node, "removed_module", "manual", f"`{dotted}` {guidance}") + elif (renamed := _rename_module(dotted)) is not None: renamed_any = True self.rewrites["module_rename"] += 1 root = dotted.split(".")[0] @@ -460,6 +529,13 @@ def leave_SimpleStatementLine( return updated_node if imported.relative or imported.module is None: return updated_node + # `leave_ImportFrom` already renamed the module and its names, so a name + # whose public v2 home is elsewhere (`Context` under `.server`) is split + # out of the statement here, against the renamed spelling. + rehomed = _split_rehomed_imports(updated_node, imported) + if rehomed is not None: + self.rewrites["import_rehome"] += 1 + return rehomed parent = get_full_name_for_node(imported.module) or "" moved: cst.ImportAlias | None = None kept: list[cst.ImportAlias] = [] @@ -521,6 +597,15 @@ def leave_Attribute(self, original_node: cst.Attribute, updated_node: cst.Attrib self.rewrites["mcperror_attr"] += 1 return updated_node.with_changes(value=cst.ensure_type(updated_node.value, cst.Attribute).value) + # An attribute the lowlevel `Server` lost whose name survives elsewhere on + # v2, matched only against a receiver the pre-pass proved is such a server + # (`server` or `self.server` alike). + if (get_full_name_for_node(original_node.value) or "") in self._lowlevel_server_vars and ( + lowlevel_guidance := LOWLEVEL_REMOVED_ATTRS.get(original_node.attr.value) + ) is not None: + self._diag(original_node, "removed_attr", "manual", lowlevel_guidance) + return updated_node + qualified_names = self._qualified(original_node) dotted = get_full_name_for_node(original_node) # The exact node naming a renamed module, written out as it was imported @@ -720,16 +805,16 @@ def leave_Decorator(self, original_node: cst.Decorator, updated_node: cst.Decora if ( isinstance(decorator, cst.Call) and isinstance(decorator.func, cst.Attribute) - and isinstance(decorator.func.value, cst.Name) - and decorator.func.value.value in self._lowlevel_server_vars + and (get_full_name_for_node(decorator.func.value) or "") in self._lowlevel_server_vars and decorator.func.attr.value in LOWLEVEL_DECORATOR_METHODS ): method = decorator.func.attr.value + receiver = get_full_name_for_node(decorator.func.value) self._diag( original_node, "lowlevel_decorator", "manual", - f"the lowlevel `@{decorator.func.value.value}.{method}()` decorator was removed: pass " + f"the lowlevel `@{receiver}.{method}()` decorator was removed: pass " f"`{LOWLEVEL_DECORATOR_METHODS[method]}=` to the `Server(...)` constructor and rewrite " f"the handler to take `(ctx, params)` and return a result model", ) diff --git a/src/mcp-codemod/mcp_codemod/cli.py b/src/mcp-codemod/mcp_codemod/cli.py index 856e41e1bb..a6e58c43f4 100644 --- a/src/mcp-codemod/mcp_codemod/cli.py +++ b/src/mcp-codemod/mcp_codemod/cli.py @@ -7,6 +7,7 @@ from importlib.metadata import version from pathlib import Path +from mcp_codemod._dependencies import DependencyReport, update_dependencies from mcp_codemod._runner import RunReport, discover, run from mcp_codemod._transformer import MARKER @@ -50,7 +51,14 @@ def _print_diffs(report: RunReport) -> None: ) -def _print_summary(report: RunReport, *, roots: Sequence[Path], dry_run: bool, markers: bool) -> None: +def _print_summary( + report: RunReport, + dependencies: Sequence[DependencyReport], + *, + roots: Sequence[Path], + dry_run: bool, + markers: bool, +) -> None: for file in report.files: if file.result is None: print(f"{file.path}: failed ({file.error})", file=sys.stderr) @@ -60,10 +68,24 @@ def _print_summary(report: RunReport, *, roots: Sequence[Path], dry_run: bool, m rewritten = sum(file.result.rewrites.values()) attention = sum(1 for diagnostic in file.result.diagnostics if diagnostic.severity != "info") print(f"{file.path}: {rewritten} rewritten, {attention} need review") + for dependency in dependencies: + if dependency.error is not None: + print(f"{dependency.path}: failed ({dependency.error})", file=sys.stderr) + elif dependency.changed: + flagged = sum(1 for diagnostic in dependency.diagnostics if diagnostic.severity != "info") + updated = len(dependency.diagnostics) - flagged + note = "mcp requirement updated for v2" if updated else f"{flagged} need review" + print(f"{dependency.path}: {note}") print(f"\n{len(report.changed)} of {len(report.files)} files rewritten.") severities = report.diagnostics - attention = severities["review"] + severities["manual"] + pending = [ + (dependency.path, diagnostic) + for dependency in dependencies + for diagnostic in dependency.diagnostics + if diagnostic.severity != "info" + ] + attention = severities["review"] + severities["manual"] + len(pending) if attention: if markers and not dry_run: targets = " ".join(str(root) for root in roots) @@ -77,17 +99,22 @@ def _print_summary(report: RunReport, *, roots: Sequence[Path], dry_run: bool, m for diagnostic in file.result.diagnostics: if diagnostic.severity != "info": print(f" {file.path}:{diagnostic.line}: {diagnostic.message}") + for path, diagnostic in pending: + print(f" {path}:{diagnostic.line}: {diagnostic.message}") if dry_run: print("Dry run: nothing was written.") - if report.failed: - print(f"{len(report.failed)} files failed.", file=sys.stderr) + failures = len(report.failed) + sum(1 for dependency in dependencies if dependency.error is not None) + if failures: + print(f"{failures} files failed.", file=sys.stderr) def main(argv: Sequence[str] | None = None) -> int: """Run the codemod. Returns 0, or 1 if any file failed.""" args = _build_parser().parse_args(argv) report = run(discover(args.paths), write=not args.dry_run, add_markers=not args.no_markers) + dependencies = update_dependencies(args.paths, write=not args.dry_run, add_markers=not args.no_markers) if args.diff: _print_diffs(report) - _print_summary(report, roots=args.paths, dry_run=args.dry_run, markers=not args.no_markers) - return 1 if report.failed else 0 + _print_summary(report, dependencies, roots=args.paths, dry_run=args.dry_run, markers=not args.no_markers) + failed = report.failed or any(dependency.error is not None for dependency in dependencies) + return 1 if failed else 0 diff --git a/src/mcp-codemod/pyproject.toml b/src/mcp-codemod/pyproject.toml index 4c75dcff6f..1211f37ff6 100644 --- a/src/mcp-codemod/pyproject.toml +++ b/src/mcp-codemod/pyproject.toml @@ -28,6 +28,8 @@ dependencies = [ # 1.8.6 is the first release verified to parse and run on Python 3.14, which # the SDK supports; older floors trade an untested resolution for nothing. "libcst>=1.8.6", + # Parses the PEP 508 requirement strings the dependency updater rewrites. + "packaging>=24.0", ] [project.scripts] diff --git a/tests/codemod/test_cli.py b/tests/codemod/test_cli.py index 36f46258d5..738a9a1d9c 100644 --- a/tests/codemod/test_cli.py +++ b/tests/codemod/test_cli.py @@ -165,3 +165,49 @@ def test_a_dry_run_lists_every_site_instead_of_the_grep_hint( assert "grep -rn" not in captured.out assert "Dry run: nothing was written." in captured.out assert "failed (" in captured.err + + +def test_the_cli_updates_dependency_files_alongside_the_sources( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """One run migrates the code and the project's `mcp` requirement together, and + a dependency flag joins the still-need-a-human accounting. + """ + (tmp_path / "server.py").write_text("from mcp.server.fastmcp import FastMCP\n") + (tmp_path / "pyproject.toml").write_text('[project]\ndependencies = ["mcp>=1.2,<2"]\n') + (tmp_path / "requirements.txt").write_text("mcp[ws]==1.9.4\n") + code = main(["v1-to-v2", str(tmp_path)]) + captured = capsys.readouterr() + assert code == 0 + assert "mcp.server.mcpserver" in (tmp_path / "server.py").read_text() + assert '"mcp>=2,<3"' in (tmp_path / "pyproject.toml").read_text() + assert "# mcp-codemod:" in (tmp_path / "requirements.txt").read_text() + assert f"{tmp_path / 'pyproject.toml'}: mcp requirement updated for v2" in captured.out + assert f"{tmp_path / 'requirements.txt'}: 1 need review" in captured.out + assert "1 sites still need a human" in captured.out + + +def test_a_broken_pyproject_fails_the_run_without_stopping_it( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """An unparseable dependency file is reported on stderr and sets the exit code, + while the source files still migrate.""" + (tmp_path / "server.py").write_text("from mcp.server.fastmcp import FastMCP\n") + (tmp_path / "pyproject.toml").write_text("[broken") + code = main(["v1-to-v2", str(tmp_path)]) + captured = capsys.readouterr() + assert code == 1 + assert "mcp.server.mcpserver" in (tmp_path / "server.py").read_text() + assert "TOMLDecodeError" in captured.err + + +def test_no_markers_lists_dependency_sites_in_the_summary(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Under `--no-markers` a dependency flag cannot live in the file, so the + summary lists it with its location like any other site.""" + requirements = tmp_path / "requirements.txt" + requirements.write_text("mcp[ws]==1.9.4\n") + code = main(["v1-to-v2", "--no-markers", str(tmp_path)]) + captured = capsys.readouterr() + assert code == 0 + assert requirements.read_text() == "mcp[ws]==1.9.4\n" + assert f"{requirements}:1: the `ws` extra was removed" in captured.out diff --git a/tests/codemod/test_dependencies.py b/tests/codemod/test_dependencies.py new file mode 100644 index 0000000000..f2b1694019 --- /dev/null +++ b/tests/codemod/test_dependencies.py @@ -0,0 +1,405 @@ +"""Dependency-file updating in `mcp_codemod._dependencies`.""" + +import textwrap +from pathlib import Path + +from inline_snapshot import snapshot +from mcp_codemod._dependencies import update_dependencies + + +def _write(path: Path, content: str) -> Path: + path.write_text(textwrap.dedent(content)) + return path + + +def test_a_v1_only_mcp_requirement_is_rewritten_to_the_v2_range(tmp_path: Path) -> None: + """A specifier that excludes every v2 release becomes `>=2,<3`; nothing else in + the file changes, not even formatting. + """ + pyproject = _write( + tmp_path / "pyproject.toml", + """\ + [project] + name = "demo" + dependencies = [ + "httpx>=0.27", + "mcp>=1.2,<2", + ] + """, + ) + reports = update_dependencies([tmp_path], write=True) + assert [report.changed for report in reports] == [True] + assert pyproject.read_text() == snapshot( + """\ +[project] +name = "demo" +dependencies = [ + "httpx>=0.27", + "mcp>=2,<3", +] +""" + ) + + +def test_a_requirement_that_already_admits_v2_is_untouched(tmp_path: Path) -> None: + """`mcp>=1.0` and an unconstrained `mcp` both admit v2 releases, so neither is + rewritten and no report is produced. + """ + pyproject = _write( + tmp_path / "pyproject.toml", + """\ + [project] + dependencies = ["mcp>=1.0", "anyio"] + + [project.optional-dependencies] + bare = ["mcp"] + """, + ) + original = pyproject.read_text() + assert update_dependencies([tmp_path], write=True) == [] + assert pyproject.read_text() == original + + +def test_extras_and_environment_markers_keep_their_original_spelling(tmp_path: Path) -> None: + """Only the specifier is spliced out: the name, extras, and environment marker + survive exactly as the user wrote them. + """ + pyproject = _write( + tmp_path / "pyproject.toml", + """\ + [project] + dependencies = ["mcp[cli,rich]==1.9.4 ; python_version >= '3.10'"] + """, + ) + update_dependencies([tmp_path], write=True) + assert pyproject.read_text() == snapshot( + """\ +[project] +dependencies = ["mcp[cli,rich]>=2,<3 ; python_version >= '3.10'"] +""" + ) + + +def test_a_requirement_with_a_removed_extra_is_marked_not_rewritten(tmp_path: Path) -> None: + """The `ws` extra has no v2 home, so the requirement is left as written and a + marker explains both the extra and the constraint change. + """ + pyproject = _write( + tmp_path / "pyproject.toml", + """\ + [project] + dependencies = [ + "mcp[ws]>=1.2,<2", + ] + """, + ) + reports = update_dependencies([tmp_path], write=True) + assert [diagnostic.severity for report in reports for diagnostic in report.diagnostics] == ["manual"] + assert pyproject.read_text() == snapshot( + """\ +[project] +dependencies = [ + # mcp-codemod: the `ws` extra was removed with the WebSocket transport; set `mcp>=2,<3` by hand + "mcp[ws]>=1.2,<2", +] +""" + ) + + +def test_optional_dependencies_and_dependency_groups_are_updated(tmp_path: Path) -> None: + """The standard tables beyond `[project.dependencies]` get the same treatment, + and an `include-group` table entry is passed over.""" + pyproject = _write( + tmp_path / "pyproject.toml", + """\ + [project.optional-dependencies] + server = ["mcp~=1.9"] + + [dependency-groups] + dev = ["pytest", {include-group = "lint"}, "mcp==1.16.0"] + lint = ["ruff"] + """, + ) + update_dependencies([tmp_path], write=True) + content = pyproject.read_text() + assert 'server = ["mcp>=2,<3"]' in content + assert '"mcp>=2,<3"]' in content + assert "1.16.0" not in content + + +def test_a_poetry_constraint_is_marked_for_a_hand_update(tmp_path: Path) -> None: + """Poetry's dependency table uses its own constraint syntax, so the `mcp` entry + is marked rather than rewritten. + """ + pyproject = _write( + tmp_path / "pyproject.toml", + """\ + [tool.poetry.dependencies] + python = "^3.10" + mcp = "^1.2" + """, + ) + reports = update_dependencies([tmp_path], write=True) + assert [diagnostic.severity for report in reports for diagnostic in report.diagnostics] == ["manual"] + assert pyproject.read_text() == snapshot( + """\ +[tool.poetry.dependencies] +python = "^3.10" +# mcp-codemod: update this Poetry constraint for v2 (`>=2,<3`) by hand +mcp = "^1.2" +""" + ) + + +def test_requirements_txt_lines_are_rewritten_and_keep_their_comments(tmp_path: Path) -> None: + """A plain requirement line is rewritten in place; its trailing comment, the + surrounding lines, and pip options are untouched. + """ + requirements = _write( + tmp_path / "requirements.txt", + """\ + -r base.txt + httpx>=0.27 + mcp[cli]>=1.2,<2 # the SDK + not a requirement!! + """, + ) + update_dependencies([tmp_path], write=True) + assert requirements.read_text() == snapshot( + """\ +-r base.txt +httpx>=0.27 +mcp[cli]>=2,<3 # the SDK +not a requirement!! +""" + ) + + +def test_a_requirements_line_with_a_removed_extra_is_marked(tmp_path: Path) -> None: + """The removed-extra rule applies to requirements files too, as a comment line + above the requirement.""" + requirements = _write(tmp_path / "requirements-dev.txt", "mcp[ws]==1.9.4\n") + reports = update_dependencies([tmp_path], write=True) + assert [diagnostic.severity for report in reports for diagnostic in report.diagnostics] == ["manual"] + assert requirements.read_text() == snapshot( + """\ +# mcp-codemod: the `ws` extra was removed with the WebSocket transport; set `mcp>=2,<3` by hand +mcp[ws]==1.9.4 +""" + ) + + +def test_a_second_run_over_updated_files_is_a_noop(tmp_path: Path) -> None: + """Re-running over already-updated and already-marked files changes nothing.""" + _write(tmp_path / "pyproject.toml", '[project]\ndependencies = ["mcp[ws]<2", "mcp==1.9"]\n') + _write(tmp_path / "requirements.txt", "mcp[ws]==1.9.4\nmcp==1.2\n") + update_dependencies([tmp_path], write=True) + first_pyproject = (tmp_path / "pyproject.toml").read_text() + first_requirements = (tmp_path / "requirements.txt").read_text() + update_dependencies([tmp_path], write=True) + assert (tmp_path / "pyproject.toml").read_text() == first_pyproject + assert (tmp_path / "requirements.txt").read_text() == first_requirements + + +def test_an_unparseable_pyproject_is_reported_and_left_untouched(tmp_path: Path) -> None: + """A broken TOML file is recorded with its error and never written to.""" + pyproject = _write(tmp_path / "pyproject.toml", "[project\ndependencies = [") + original = pyproject.read_text() + reports = update_dependencies([tmp_path], write=True) + assert len(reports) == 1 + assert reports[0].error is not None and "TOMLDecodeError" in reports[0].error + assert pyproject.read_text() == original + + +def test_nothing_is_written_when_write_is_false(tmp_path: Path) -> None: + """With `write=False` the report carries the would-be content but the file on + disk is untouched.""" + pyproject = _write(tmp_path / "pyproject.toml", '[project]\ndependencies = ["mcp<2"]\n') + original = pyproject.read_text() + reports = update_dependencies([tmp_path], write=False) + assert reports[0].changed + assert pyproject.read_text() == original + + +def test_dependency_files_inside_ignored_directories_are_skipped(tmp_path: Path) -> None: + """A pyproject inside `.venv` or `node_modules` is vendored, not the user's.""" + (tmp_path / ".venv").mkdir() + _write(tmp_path / ".venv" / "pyproject.toml", '[project]\ndependencies = ["mcp<2"]\n') + assert update_dependencies([tmp_path], write=True) == [] + + +def test_a_file_path_argument_yields_no_dependency_updates(tmp_path: Path) -> None: + """Dependency files are discovered under directory arguments only; pointing the + codemod at a single source file updates that file alone.""" + target = tmp_path / "server.py" + target.write_text("from mcp import ClientSession\n") + assert update_dependencies([target], write=True) == [] + + +def test_a_poetry_inline_dependency_table_still_gets_a_diagnostic(tmp_path: Path) -> None: + """When the Poetry table is written inline, no marker can be placed on the `mcp` + key's own line, but the diagnostic is still reported.""" + pyproject = _write(tmp_path / "pyproject.toml", '[tool.poetry]\ndependencies = { mcp = "^1.2" }\n') + original = pyproject.read_text() + reports = update_dependencies([tmp_path], write=True) + assert [diagnostic.severity for report in reports for diagnostic in report.diagnostics] == ["manual"] + assert pyproject.read_text() == original + + +def test_a_requirement_hidden_behind_toml_escapes_is_left_alone(tmp_path: Path) -> None: + """A dependency string whose raw TOML spelling differs from its parsed value + (an escape sequence) cannot be located for a safe textual rewrite, so it is + passed over rather than guessed at.""" + pyproject = _write(tmp_path / "pyproject.toml", '[project]\ndependencies = ["mcp \\u003c 2"]\n') + original = pyproject.read_text() + assert update_dependencies([tmp_path], write=True) == [] + assert pyproject.read_text() == original + + +def test_non_list_table_values_and_comment_lines_are_passed_over(tmp_path: Path) -> None: + """Malformed-but-parseable shapes (a string where a group list belongs) and + requirements lines with nothing actionable are skipped without complaint.""" + _write( + tmp_path / "pyproject.toml", + """\ + [project.optional-dependencies] + weird = "not-a-list" + + [dependency-groups] + odd = "also-not-a-list" + """, + ) + _write(tmp_path / "requirements.txt", "# just a comment\n\nhttpx\n") + assert update_dependencies([tmp_path], write=True) == [] + + +def test_add_markers_false_reports_without_writing_comments(tmp_path: Path) -> None: + """With `add_markers=False` a flag-only finding appears in the report but the + file is not modified at all.""" + pyproject = _write(tmp_path / "pyproject.toml", '[project]\ndependencies = ["mcp[ws]<2"]\n') + original = pyproject.read_text() + reports = update_dependencies([tmp_path], write=True, add_markers=False) + assert [diagnostic.severity for report in reports for diagnostic in report.diagnostics] == ["manual"] + assert not reports[0].changed + assert pyproject.read_text() == original + + +def test_constraints_already_on_v2_are_never_touched(tmp_path: Path) -> None: + """An exact v2 pin, a published-alpha pin, and a narrow v2 range are the user's + own v2 choices; none of them is a v1-era constraint, so none is rewritten.""" + pyproject = _write( + tmp_path / "pyproject.toml", + """\ + [project] + dependencies = ["mcp==2.1.4"] + + [project.optional-dependencies] + alpha = ["mcp==2.0.0a1"] + narrow = ["mcp>=2.1,<2.2"] + """, + ) + original = pyproject.read_text() + assert update_dependencies([tmp_path], write=True) == [] + assert pyproject.read_text() == original + + +def test_a_removed_extra_is_flagged_even_when_the_specifier_admits_v2(tmp_path: Path) -> None: + """`mcp[ws]>=1.0` resolves to a v2 where the extra does not exist and its + dependency silently vanishes, so the extra outranks the specifier check.""" + pyproject = _write(tmp_path / "pyproject.toml", '[project]\ndependencies = ["mcp[ws]>=1.0"]\n') + reports = update_dependencies([tmp_path], write=True) + assert [diagnostic.severity for report in reports for diagnostic in report.diagnostics] == ["manual"] + assert "# mcp-codemod:" in pyproject.read_text() + assert "mcp[ws]>=1.0" in pyproject.read_text() + + +def test_a_url_requirement_is_flagged_not_rewritten(tmp_path: Path) -> None: + """A VCS/URL reference has no specifier to rewrite but may pin v1 forever, so + it is marked for a hand update.""" + requirements = _write(tmp_path / "requirements.txt", "mcp @ git+https://github.com/o/r@v1.9.4\n") + reports = update_dependencies([tmp_path], write=True) + assert [diagnostic.severity for report in reports for diagnostic in report.diagnostics] == ["manual"] + assert "pins `mcp` by URL" in requirements.read_text() + + +def test_an_unparseable_mcp_line_is_flagged(tmp_path: Path) -> None: + """A pip-compile style line (`--hash=` options) names mcp but cannot be parsed + or rewritten; passing it over silently would hide a v1 pin.""" + requirements = _write( + tmp_path / "requirements.txt", + "httpx==0.27.0\nmcp==1.9.4 --hash=sha256:abc123\n", + ) + reports = update_dependencies([tmp_path], write=True) + assert [diagnostic.severity for report in reports for diagnostic in report.diagnostics] == ["manual"] + content = requirements.read_text() + assert "could not parse this `mcp` line" in content + assert "mcp==1.9.4 --hash=sha256:abc123" in content + + +def test_a_poetry_group_dependency_is_marked(tmp_path: Path) -> None: + """Poetry >=1.2 group tables and the legacy dev table count as Poetry homes for + the `mcp` constraint too.""" + pyproject = _write( + tmp_path / "pyproject.toml", + """\ + [tool.poetry.group.dev.dependencies] + mcp = "^1.2" + """, + ) + reports = update_dependencies([tmp_path], write=True) + assert [diagnostic.severity for report in reports for diagnostic in report.diagnostics] == ["manual"] + assert "# mcp-codemod:" in pyproject.read_text() + + +def test_lookalike_strings_in_comments_and_other_tables_are_never_touched(tmp_path: Path) -> None: + """Rewrites and markers stay inside the standard dependency tables, so the same + requirement string in a TOML comment or another tool's table survives.""" + pyproject = _write( + tmp_path / "pyproject.toml", + """\ + [project] + # keep "mcp>=1.2,<2" in sync with the docs + dependencies = ["mcp>=1.2,<2"] + + [tool.mytool] + note = "mcp>=1.2,<2" + """, + ) + update_dependencies([tmp_path], write=True) + content = pyproject.read_text() + assert '# keep "mcp>=1.2,<2" in sync with the docs' in content + assert 'note = "mcp>=1.2,<2"' in content + assert 'dependencies = ["mcp>=2,<3"]' in content + + +def test_an_arbitrary_equality_clause_is_left_alone(tmp_path: Path) -> None: + """`===` pins a string that may not even parse as a version; nothing about it is + provably v1-era, so it is never rewritten.""" + pyproject = _write(tmp_path / "pyproject.toml", '[project]\ndependencies = ["mcp===legacy1"]\n') + original = pyproject.read_text() + assert update_dependencies([tmp_path], write=True) == [] + assert pyproject.read_text() == original + + +def test_two_poetry_tables_each_get_a_diagnostic(tmp_path: Path) -> None: + """`mcp` in both the main and a group table yields one diagnostic per entry.""" + _write( + tmp_path / "pyproject.toml", + """\ + [tool.poetry.dependencies] + mcp = "^1.2" + + [tool.poetry.group.dev.dependencies] + mcp = "^1.2" + """, + ) + reports = update_dependencies([tmp_path], write=True, add_markers=False) + assert [diagnostic.severity for report in reports for diagnostic in report.diagnostics] == ["manual", "manual"] + + +def test_an_mcp_prefixed_other_package_is_untouched(tmp_path: Path) -> None: + """`mcp-extra` is a different distribution; neither the rewrite nor the + unparseable-line flag may fire on it.""" + requirements = _write(tmp_path / "requirements.txt", "mcp-extra==1.0\n") + assert update_dependencies([tmp_path], write=True) == [] + assert requirements.read_text() == "mcp-extra==1.0\n" diff --git a/tests/codemod/test_mappings.py b/tests/codemod/test_mappings.py index 34911c3cde..78e8a5d552 100644 --- a/tests/codemod/test_mappings.py +++ b/tests/codemod/test_mappings.py @@ -9,6 +9,8 @@ import inspect from importlib import import_module +from importlib.metadata import metadata +from importlib.util import find_spec import mcp_types import pytest @@ -16,10 +18,14 @@ from mcp_codemod._mappings import ( CAMEL_FIELDS, LOWLEVEL_DECORATOR_METHODS, + LOWLEVEL_REMOVED_ATTRS, MODULE_RENAMES, + REHOMED_IMPORTS, REMOVED_APIS, REMOVED_ATTRS, REMOVED_CTOR_PARAMS, + REMOVED_EXTRAS, + REMOVED_MODULES, SYMBOL_RENAMES, TRANSPORT_CLIENT_REMOVED_PARAMS, TRANSPORT_CTOR_PARAMS, @@ -30,7 +36,7 @@ import mcp.server.mcpserver from mcp.client.streamable_http import streamable_http_client from mcp.server.lowlevel import Server -from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver import Context, MCPServer def _v2_resolves(qualified: str) -> bool: @@ -415,3 +421,174 @@ def test_the_removed_client_keyword_set_is_exactly_v1_minus_v2() -> None: ) v2_parameters = frozenset(inspect.signature(streamable_http_client).parameters) assert v1_parameters - v2_parameters == TRANSPORT_CLIENT_REMOVED_PARAMS + + +# Every public module v1 shipped (no path segment starting with an underscore), +# extracted from `origin/v1.x` and frozen here because v1 is closed history. +_V1_PUBLIC_MODULES = ( + "mcp", + "mcp.cli", + "mcp.cli.claude", + "mcp.cli.cli", + "mcp.client", + "mcp.client.auth", + "mcp.client.auth.exceptions", + "mcp.client.auth.extensions", + "mcp.client.auth.extensions.client_credentials", + "mcp.client.auth.oauth2", + "mcp.client.auth.utils", + "mcp.client.experimental", + "mcp.client.experimental.task_handlers", + "mcp.client.experimental.tasks", + "mcp.client.session", + "mcp.client.session_group", + "mcp.client.sse", + "mcp.client.stdio", + "mcp.client.streamable_http", + "mcp.client.websocket", + "mcp.os", + "mcp.os.posix", + "mcp.os.posix.utilities", + "mcp.os.win32", + "mcp.os.win32.utilities", + "mcp.server", + "mcp.server.auth", + "mcp.server.auth.errors", + "mcp.server.auth.handlers", + "mcp.server.auth.handlers.authorize", + "mcp.server.auth.handlers.metadata", + "mcp.server.auth.handlers.register", + "mcp.server.auth.handlers.revoke", + "mcp.server.auth.handlers.token", + "mcp.server.auth.json_response", + "mcp.server.auth.middleware", + "mcp.server.auth.middleware.auth_context", + "mcp.server.auth.middleware.bearer_auth", + "mcp.server.auth.middleware.client_auth", + "mcp.server.auth.provider", + "mcp.server.auth.routes", + "mcp.server.auth.settings", + "mcp.server.elicitation", + "mcp.server.experimental", + "mcp.server.experimental.request_context", + "mcp.server.experimental.session_features", + "mcp.server.experimental.task_context", + "mcp.server.experimental.task_result_handler", + "mcp.server.experimental.task_scope", + "mcp.server.experimental.task_support", + "mcp.server.fastmcp", + "mcp.server.fastmcp.exceptions", + "mcp.server.fastmcp.prompts", + "mcp.server.fastmcp.prompts.base", + "mcp.server.fastmcp.prompts.manager", + "mcp.server.fastmcp.resources", + "mcp.server.fastmcp.resources.base", + "mcp.server.fastmcp.resources.resource_manager", + "mcp.server.fastmcp.resources.templates", + "mcp.server.fastmcp.resources.types", + "mcp.server.fastmcp.server", + "mcp.server.fastmcp.tools", + "mcp.server.fastmcp.tools.base", + "mcp.server.fastmcp.tools.tool_manager", + "mcp.server.fastmcp.utilities", + "mcp.server.fastmcp.utilities.context_injection", + "mcp.server.fastmcp.utilities.func_metadata", + "mcp.server.fastmcp.utilities.logging", + "mcp.server.fastmcp.utilities.types", + "mcp.server.lowlevel", + "mcp.server.lowlevel.experimental", + "mcp.server.lowlevel.func_inspection", + "mcp.server.lowlevel.helper_types", + "mcp.server.lowlevel.server", + "mcp.server.models", + "mcp.server.session", + "mcp.server.sse", + "mcp.server.stdio", + "mcp.server.streamable_http", + "mcp.server.streamable_http_manager", + "mcp.server.transport_security", + "mcp.server.validation", + "mcp.server.websocket", + "mcp.shared", + "mcp.shared.auth", + "mcp.shared.auth_utils", + "mcp.shared.context", + "mcp.shared.exceptions", + "mcp.shared.experimental", + "mcp.shared.experimental.tasks", + "mcp.shared.experimental.tasks.capabilities", + "mcp.shared.experimental.tasks.context", + "mcp.shared.experimental.tasks.helpers", + "mcp.shared.experimental.tasks.in_memory_task_store", + "mcp.shared.experimental.tasks.message_queue", + "mcp.shared.experimental.tasks.polling", + "mcp.shared.experimental.tasks.resolver", + "mcp.shared.experimental.tasks.store", + "mcp.shared.memory", + "mcp.shared.message", + "mcp.shared.metadata_utils", + "mcp.shared.progress", + "mcp.shared.response_router", + "mcp.shared.session", + "mcp.shared.tool_name_validation", + "mcp.shared.version", + "mcp.types", +) + + +def test_every_v1_module_resolves_on_v2_or_is_renamed_or_removed() -> None: + """The whole v1 module namespace is accounted for: every public module either + still imports on v2, is rewritten by `MODULE_RENAMES`, or is marked through a + `REMOVED_MODULES` root. An unaccounted module would mean an import the codemod + neither fixes nor flags. The removed roots must also really be gone from v2, + and each must cover at least one v1 module (no stale roots). + """ + + def covered_by(table: dict[str, str], module: str) -> bool: + return any(module == root or module.startswith(f"{root}.") for root in table) + + unaccounted = [ + module + for module in _V1_PUBLIC_MODULES + if not covered_by(MODULE_RENAMES, module) + and not covered_by(REMOVED_MODULES, module) + and find_spec(module) is None + ] + assert unaccounted == [] + for root in REMOVED_MODULES: + assert find_spec(root) is None, root + assert any(module == root or module.startswith(f"{root}.") for module in _V1_PUBLIC_MODULES), root + + +def test_the_removed_extras_are_exactly_v1_minus_the_installed_v2() -> None: + """The flagged extras are exactly the ones v1's `mcp` distribution declared and + the installed v2 does not: flagging a surviving extra would be a lie, and + missing a removed one leaves a constraint that cannot resolve. v1's set is + frozen history; v2's comes from the installed metadata. + """ + v1_extras = {"cli", "rich", "ws"} + v2_extras = set(metadata("mcp").get_all("Provides-Extra") or []) + assert v1_extras - v2_extras == set(REMOVED_EXTRAS) + + +def test_every_rehomed_import_points_at_a_declared_public_export() -> None: + """A rehome target must spell the name in its `__all__` -- the whole point is + moving the import to where v2 declares the name publicly -- and the source + module must still hold the name too, so the rehome is never load-bearing + for runtime behaviour. + """ + for (source_module, name), target in REHOMED_IMPORTS.items(): + assert name in getattr(import_module(target), "__all__", []), (source_module, name) + assert hasattr(import_module(source_module), name), (source_module, name) + + +def test_every_lowlevel_removed_attribute_is_really_gone_from_the_v2_server() -> None: + """The receiver-matched lowlevel removals must be absent from the v2 `Server` + (a marker on a live attribute would be a lie), while still being spelled by + some other living v2 API -- otherwise the plain name-matched `REMOVED_ATTRS` + table is their cheaper home. + """ + assert set(LOWLEVEL_REMOVED_ATTRS) == {"request_context"} + for name in LOWLEVEL_REMOVED_ATTRS: + assert not hasattr(Server, name), name + assert hasattr(Context, name), name diff --git a/tests/codemod/test_transformer.py b/tests/codemod/test_transformer.py index 2a846c03aa..7be995ce09 100644 --- a/tests/codemod/test_transformer.py +++ b/tests/codemod/test_transformer.py @@ -317,7 +317,7 @@ async def main() -> None: result = transform(source) assert any(d.severity == "manual" and "WebSocket" in d.message for d in result.diagnostics) assert result.code == snapshot("""\ -# mcp-codemod: `mcp.client.websocket.websocket_client` removed: the WebSocket transport was deleted +# mcp-codemod: `mcp.client.websocket` removed: the WebSocket transport was deleted from mcp.client.websocket import websocket_client @@ -912,24 +912,6 @@ def test_surviving_constructor_keywords_are_not_flagged() -> None: assert transform(source).diagnostics == [] -def test_a_lowlevel_server_bound_to_an_attribute_is_not_tracked() -> None: - """Only a plain-name binding of a lowlevel `Server(...)` is tracked, so a registration - on a server held in an instance attribute is left alone with no diagnostic.""" - source = textwrap.dedent("""\ - from mcp.server.lowlevel import Server - - - class Holder: - def __init__(self) -> None: - self.s = Server("x") - - @self.s.call_tool() - async def handle(name, arguments): - return [] - """) - assert transform(source).diagnostics == [] - - def test_transforming_already_transformed_code_is_a_noop() -> None: """Running the codemod over its own output changes nothing, even for a source that exercises a module rename, a symbol rename, a camelCase attribute rename, and a flag-only diagnostic. @@ -1490,12 +1472,17 @@ def test_a_removed_nested_class_reached_through_its_parent_is_marked() -> None: def test_the_server_submodule_import_targets_the_v2_submodule() -> None: - """`mcp.server.fastmcp.server` maps to the literal v2 submodule, where every one - of its public names (`Settings` is the giveaway -- the package does not export - it) still lives. + """`mcp.server.fastmcp.server` maps to the literal v2 submodule, where its + module-level names (`Settings` is the giveaway -- the package does not export + it) still live; `Context` alone is rehomed to the package, its public v2 home. """ source = "from mcp.server.fastmcp.server import Context, Settings\n" - assert transform(source).code == snapshot("from mcp.server.mcpserver.server import Context, Settings\n") + assert transform(source).code == snapshot( + """\ +from mcp.server.mcpserver.server import Settings +from mcp.server.mcpserver import Context +""" + ) def test_a_resolvable_non_mcp_receiver_is_never_flagged() -> None: @@ -1529,3 +1516,135 @@ def test_no_unbind_marker_when_another_import_keeps_the_root_bound() -> None: assert "import mcp_types" in result.code assert "mcp_types.Tool" in result.code assert result.diagnostics == [] + + +def test_an_import_of_a_removed_module_is_marked_and_kept() -> None: + """`import mcp.shared.progress` names a module v2 deleted outright; the import is + kept exactly as written and marked with the replacement guidance. + """ + source = "import mcp.shared.progress\n" + result = transform(source) + assert "import mcp.shared.progress\n" in result.code + assert [diagnostic.transform for diagnostic in result.diagnostics] == ["removed_module"] + assert "ctx.report_progress()" in result.diagnostics[0].message + + +def test_a_from_import_out_of_a_removed_namespace_gets_one_marker() -> None: + """A `from` import out of a deleted namespace gets a single whole-statement + marker; per-name markers would only repeat it. + """ + source = "from mcp.shared.experimental.tasks import InMemoryTaskStore, task_execution\n" + result = transform(source) + assert result.code.count("# mcp-codemod:") == 1 + assert [diagnostic.severity for diagnostic in result.diagnostics] == ["manual"] + assert "first-class on v2" in result.diagnostics[0].message + + +def test_a_removed_module_imported_from_its_parent_package_is_marked() -> None: + """`from mcp.client import websocket` binds the deleted module through its parent, + so the per-name check resolves `mcp.client.websocket` against the removed roots. + """ + source = "from mcp.client import websocket\n" + result = transform(source) + assert result.code.count("# mcp-codemod:") == 1 + assert "`mcp.client.websocket` removed" in result.diagnostics[0].message + + +def test_context_imported_from_the_server_module_is_rehomed_to_the_package() -> None: + """`Context` moved out of `server.py` on v2; importing it from there would be a + private-usage to a type checker, so the import is split out to the package, + which declares it publicly. + """ + source = "from mcp.server.fastmcp.server import Context, FastMCP, Settings\n" + assert transform(source).code == snapshot( + """\ +from mcp.server.mcpserver.server import MCPServer, Settings +from mcp.server.mcpserver import Context +""" + ) + + +def test_a_rehomed_import_keeps_its_alias_and_takes_the_statement_over_when_alone() -> None: + """A lone rehomed name replaces the whole statement, `as` alias and all.""" + source = "from mcp.server.fastmcp.server import Context as Ctx\n" + assert transform(source).code == snapshot("from mcp.server.mcpserver import Context as Ctx\n") + + +def test_request_context_on_a_proven_lowlevel_server_is_flagged() -> None: + """`Server.request_context` is gone on v2, but `Context.request_context` lives; + only a receiver the pre-pass proved holds a lowlevel `Server` is flagged, so + the live idiom is never touched (which `test_the_v2_request_context_idiom_is_ + never_flagged` pins). + """ + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + server = Server("git") + + + async def progress(token: str) -> None: + ctx = server.request_context + await ctx.session.send_progress_notification(token, 1.0) + """) + result = transform(source) + assert "server.request_context" in result.code + assert [diagnostic.severity for diagnostic in result.diagnostics] == ["manual"] + assert "handlers now receive `ctx` explicitly" in result.diagnostics[0].message + + +def test_a_lowlevel_server_bound_to_an_attribute_is_recognized() -> None: + """`self.server = Server(...)` binds the server to an attribute; its decorators + and removed attributes get the same treatment as a plain name binding.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + + class App: + def __init__(self) -> None: + self.server = Server("demo") + + def current(self) -> object: + return self.server.request_context + """) + result = transform(source) + assert [diagnostic.severity for diagnostic in result.diagnostics] == ["manual"] + assert "handlers now receive `ctx` explicitly" in result.diagnostics[0].message + + +def test_a_marker_survives_a_statement_split() -> None: + """A removed-module flag on an import that is also being split for a renamed + sibling lands above the split's first piece instead of being dropped.""" + result = transform("from mcp.server import websocket, fastmcp\n") + assert result.code == snapshot( + """\ +# mcp-codemod: `mcp.server.websocket` removed: the WebSocket transport was deleted +from mcp.server import websocket +import mcp.server.mcpserver as fastmcp +""" + ) + + +def test_a_tuple_assignment_involving_a_server_call_is_passed_over() -> None: + """A tuple target has no single dotted spelling to track, so the pre-pass + records nothing and the module is returned unchanged.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + primary, label = Server("a"), "main" + """) + result = transform(source) + assert result.code == source + assert result.diagnostics == [] + + +def test_unpacking_a_call_result_is_passed_over() -> None: + """A tuple target has no single dotted spelling to track, so a call result that + is unpacked records nothing and the module is returned unchanged.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + server, transport = build(Server("x")) + """) + result = transform(source) + assert result.code == source + assert result.diagnostics == [] diff --git a/uv.lock b/uv.lock index 40685a2ea6..4b2ef6d11d 100644 --- a/uv.lock +++ b/uv.lock @@ -1103,10 +1103,14 @@ name = "mcp-codemod" source = { editable = "src/mcp-codemod" } dependencies = [ { name = "libcst" }, + { name = "packaging" }, ] [package.metadata] -requires-dist = [{ name = "libcst", specifier = ">=1.8.6" }] +requires-dist = [ + { name = "libcst", specifier = ">=1.8.6" }, + { name = "packaging", specifier = ">=24.0" }, +] [[package]] name = "mcp-everything-server"