From 8900f2527c4f9df11b02e1efef9c19a6c243df06 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/4] 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.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.md b/README.md index 0c0876bb66..195922b657 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ > > **v1.x is the only stable release line and remains recommended for production.** It lives on the [`v1.x` branch](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x) and continues to receive critical bug fixes and security patches; see [the v1.x README](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/README.md) for its documentation. `pip` and `uv` don't select a pre-release unless you explicitly request one, 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.** > -> 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. Stable v2 is targeted for 2026-07-27, alongside the spec release. Try the pre-releases and [tell us what breaks](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) — or discuss in [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX). +> 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. Stable v2 is targeted for 2026-07-27, alongside the spec release. Try the pre-releases and [tell us what breaks](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) — or discuss in [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX). ## Documentation diff --git a/docs/migration.md b/docs/migration.md index a671ea4932..5defbdfa77 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 7b947588fe..b73546ee70 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", @@ -212,10 +216,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" } @@ -264,7 +276,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 801095acad14fec887e5306a1572c30634c9c9bc 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/4] 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 5defbdfa77..358e97c41e 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" From ecafdc78d6466b6eee4e1bc3a7edd7ef47fba0cf Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:38:23 +0000 Subject: [PATCH 3/4] Re-scope the codemod to run-on-v2 minimalism The goal is that migrated v1 code runs on v2 on its legacy paths, not that it adopts v2 idioms. Applying that bar: - Leave e.error.code / .message / .data chains alone: v2's MCPError keeps a typed .error ErrorData, so the v1 spelling runs and type-checks unchanged. The except-binding tracking goes with it. - Rewrite one-argument McpError(...) calls to MCPError.from_error_data(...) instead of flattening the inline ErrorData: the user's expression is kept as written and the non-inline form no longer needs a marker. - Convert v1 positional arguments on the lowlevel Server constructor to keywords (v2 is keyword-only after name but kept v1's names and order), pinned against the installed signature by a new ratchet test. - Reword every marker message that pointed at replaced internals or at the successor of the removed experimental tasks API; state removals plainly instead of steering users onto new surfaces. - Teach the batch harness that a reportArgumentType error naming a detonating argument type (timedelta, AnyUrl) is a real break, never v2 strictness drift, and ignore stale work/ directories. --- scripts/codemod-batch-test/.gitignore | 1 + scripts/codemod-batch-test/run.py | 12 +- src/mcp-codemod/README.md | 10 +- src/mcp-codemod/mcp_codemod/_mappings.py | 50 ++--- src/mcp-codemod/mcp_codemod/_transformer.py | 115 +++++------ tests/codemod/test_mappings.py | 9 + tests/codemod/test_transformer.py | 208 ++++++-------------- 7 files changed, 159 insertions(+), 246 deletions(-) diff --git a/scripts/codemod-batch-test/.gitignore b/scripts/codemod-batch-test/.gitignore index 9d931c43c2..d22ffb1ffe 100644 --- a/scripts/codemod-batch-test/.gitignore +++ b/scripts/codemod-batch-test/.gitignore @@ -1 +1,2 @@ work/ +work/ diff --git a/scripts/codemod-batch-test/run.py b/scripts/codemod-batch-test/run.py index aba4ddbc8a..0a5d071451 100644 --- a/scripts/codemod-batch-test/run.py +++ b/scripts/codemod-batch-test/run.py @@ -47,6 +47,11 @@ # failed to flag looks exactly like that. DRIFT_RULES = frozenset({"reportArgumentType", "reportOptionalSubscript", "reportOptionalMemberAccess"}) +# Argument types that detonate at RUNTIME on v2 (`timedelta` where v2 takes float +# seconds, `AnyUrl` where v2 takes `str`). A `reportArgumentType` error naming one +# of these is a real break pyright happens to catch, never strictness drift. +DETONATOR_TYPES = ("timedelta", "AnyUrl") + # 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" @@ -258,7 +263,12 @@ def _audit_repo(repo: Repo, *, v1_python: Path, fresh: bool) -> tuple[dict[str, # 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: + if ( + error.file not in rewritten_files + and "mcp" not in error.message.lower() + and error.rule in DRIFT_RULES + and not any(f'of type "{detonator}"' in error.message for detonator in DETONATOR_TYPES) + ): drift.append(error) else: actionable.append(error) diff --git a/src/mcp-codemod/README.md b/src/mcp-codemod/README.md index f0239cfa27..f961636467 100644 --- a/src/mcp-codemod/README.md +++ b/src/mcp-codemod/README.md @@ -28,9 +28,9 @@ manual fix-up. `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. +- `McpError(...)` calls to `MCPError.from_error_data(...)`, which takes the + same single `ErrorData` argument the v1 constructor did. (`e.error.code` and + friends are deliberately left alone: they still work on v2.) - 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 @@ -54,8 +54,8 @@ The codemod never guesses at these; it leaves them exactly as written and adds a - Removed APIs that have no drop-in replacement (`create_connected_server_and_client_session`, 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 + of whole module namespaces v2 deleted (the removed experimental tasks + API). 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`, diff --git a/src/mcp-codemod/mcp_codemod/_mappings.py b/src/mcp-codemod/mcp_codemod/_mappings.py index 7067062c3b..95366155af 100644 --- a/src/mcp-codemod/mcp_codemod/_mappings.py +++ b/src/mcp-codemod/mcp_codemod/_mappings.py @@ -15,6 +15,7 @@ "CAMEL_FIELDS", "ERRORDATA_QNAMES", "FASTMCP_QNAMES", + "LOWLEVEL_CTOR_POSITIONAL_PARAMS", "LOWLEVEL_DECORATOR_METHODS", "LOWLEVEL_REMOVED_ATTRS", "LOWLEVEL_SERVER_QNAMES", @@ -61,23 +62,15 @@ # 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.experimental": ("removed: the v1 experimental tasks API was deleted and has no replacement"), + "mcp.server.experimental": ("removed: the v1 experimental tasks API was deleted and has no replacement"), + "mcp.server.lowlevel.experimental": ("removed: the v1 experimental tasks API was deleted and has no replacement"), + "mcp.shared.experimental": ("removed: the v1 experimental tasks API was deleted and has no replacement"), "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`", + "mcp.shared.response_router": "removed: it was internal session machinery; there is no public replacement", } # Symbol renames, keyed by every v1 qualified name the symbol was reachable from. @@ -102,7 +95,8 @@ # `# 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" + "removed: pair `create_client_server_memory_streams()` with `Server.run()` and a `ClientSession` " + "to keep the v1 test shape, or use `mcp.Client(server)`" ), "mcp.shared.progress.progress": "removed: report progress with `ctx.report_progress()` inside a handler", "mcp.shared.progress.Progress": "removed: `mcp.shared.progress` was deleted", @@ -113,12 +107,12 @@ "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.shared.session.BaseSession": "removed: use `ClientSession` or `ServerSession` directly", "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 + # vocabulary left with the experimental tasks API 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 @@ -138,15 +132,15 @@ "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"`', + "mcp.types.TaskExecutionMode": "removed with the v1 experimental tasks API", + "mcp.types.TASK_REQUIRED": "removed with the v1 experimental tasks API", + "mcp.types.TASK_OPTIONAL": "removed with the v1 experimental tasks API", + "mcp.types.TASK_FORBIDDEN": "removed with the v1 experimental tasks API", + "mcp.types.TASK_STATUS_WORKING": "removed with the v1 experimental tasks API", + "mcp.types.TASK_STATUS_INPUT_REQUIRED": "removed with the v1 experimental tasks API", + "mcp.types.TASK_STATUS_COMPLETED": "removed with the v1 experimental tasks API", + "mcp.types.TASK_STATUS_FAILED": "removed with the v1 experimental tasks API", + "mcp.types.TASK_STATUS_CANCELLED": "removed with the v1 experimental tasks API", } # Extras the v1 `mcp` distribution declared that v2 does not, with guidance. @@ -285,6 +279,12 @@ def _to_snake(name: str) -> str: "mount_path": "removed: mount the app under a Starlette route instead", } +# The v1 lowlevel `Server.__init__` parameters after `name`, in positional order. +# v2 makes everything after `name` keyword-only but keeps these names, so a v1 +# positional argument converts to the keyword at its position one for one. +# Pinned against the installed v2 constructor by `tests/codemod/test_mappings.py`. +LOWLEVEL_CTOR_POSITIONAL_PARAMS: tuple[str, ...] = ("version", "instructions", "website_url", "icons", "lifespan") + # 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. diff --git a/src/mcp-codemod/mcp_codemod/_transformer.py b/src/mcp-codemod/mcp_codemod/_transformer.py index 11a33aae43..8f0ca15436 100644 --- a/src/mcp-codemod/mcp_codemod/_transformer.py +++ b/src/mcp-codemod/mcp_codemod/_transformer.py @@ -32,8 +32,6 @@ from libcst.helpers import get_full_name_for_node from libcst.metadata import ( CodeRange, - ExpressionContext, - ExpressionContextProvider, MetadataWrapper, PositionProvider, QualifiedNameProvider, @@ -44,6 +42,7 @@ CAMEL_FIELDS, ERRORDATA_QNAMES, FASTMCP_QNAMES, + LOWLEVEL_CTOR_POSITIONAL_PARAMS, LOWLEVEL_DECORATOR_METHODS, LOWLEVEL_REMOVED_ATTRS, LOWLEVEL_SERVER_QNAMES, @@ -276,7 +275,7 @@ def visit_AnnAssign(self, node: cst.AnnAssign) -> None: class _V1ToV2(cst.CSTTransformer): - METADATA_DEPENDENCIES = (QualifiedNameProvider, PositionProvider, ExpressionContextProvider) + METADATA_DEPENDENCIES = (QualifiedNameProvider, PositionProvider) def __init__(self, prepass: _PrePass, *, add_markers: bool) -> None: super().__init__() @@ -302,7 +301,6 @@ def __init__(self, prepass: _PrePass, *, add_markers: bool) -> None: # 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() @@ -409,37 +407,6 @@ def visit_Arg(self, node: cst.Arg) -> None: 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: @@ -580,22 +547,10 @@ def leave_Name(self, original_node: cst.Name, updated_node: cst.Name) -> cst.Nam 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) + # `e.error.code` on a caught error is deliberately NOT collapsed to `e.code`: + # v2's `MCPError` keeps a typed `.error` ErrorData, so the v1 spelling runs + # and type-checks unchanged -- touching it would be modernization, not + # migration. # 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 @@ -679,15 +634,22 @@ def leave_Attribute(self, original_node: cst.Attribute, updated_node: cst.Attrib 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)`. + # v1's constructor took a single `ErrorData`; v2's classmethod + # `MCPError.from_error_data(...)` takes exactly that argument, so any + # one-argument call converts uniformly -- the user's expression is kept as + # written, whatever it is. The name itself is renamed by `leave_Name`, + # which has already run on the inner nodes. + if callee & MCPERROR_QNAMES and len(original_node.args) == 1: + self.rewrites["mcperror_ctor"] += 1 + return updated_node.with_changes( + func=cst.Attribute(value=updated_node.func, attr=cst.Name("from_error_data")) + ) + # 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: + # one way a classmethod cannot replace, so the inline `ErrorData(...)` is + # flattened into v2's `(code, message, data=None)` arguments; any other + # single argument has nothing safe to unpack and is marked. + if 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 @@ -700,12 +662,12 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Cal "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. + # camelCase keyword arguments still work at RUNTIME on v2 (every model + # field accepts its camelCase alias by name), but the synthesized + # `__init__` signatures are snake_case, so leaving them fails the user's + # own type-checking. The rename cannot break the call -- which is why, + # unlike the attribute form, the risky tier needs no review marker here -- + # and is 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 @@ -747,6 +709,29 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Cal elif keyword in REMOVED_CTOR_PARAMS: self._diag(argument, "removed_ctor_param", "manual", f"`{keyword}=` {REMOVED_CTOR_PARAMS[keyword]}") + # The lowlevel `Server` constructor is keyword-only after `name` on v2, but + # its parameters kept v1's names and order, so v1 positionals convert to + # keywords one for one. A `*`-splat hides how many positions it fills, so a + # call carrying one is left for v2 to reject loudly at construction. + if ( + callee & LOWLEVEL_SERVER_QNAMES + and 1 < len(original_node.args) <= 1 + len(LOWLEVEL_CTOR_POSITIONAL_PARAMS) + and not any(argument.star for argument in original_node.args) + ): + arguments = [] + for index, argument in enumerate(updated_node.args): + if index > 0 and argument.keyword is None: + self.rewrites["lowlevel_ctor_kwargs"] += 1 + argument = argument.with_changes( + keyword=cst.Name(LOWLEVEL_CTOR_POSITIONAL_PARAMS[index - 1]), + equal=cst.AssignEqual( + whitespace_before=cst.SimpleWhitespace(""), whitespace_after=cst.SimpleWhitespace("") + ), + ) + arguments.append(argument) + if arguments != list(updated_node.args): + updated_node = updated_node.with_changes(args=arguments) + # 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). diff --git a/tests/codemod/test_mappings.py b/tests/codemod/test_mappings.py index 78e8a5d552..89a60aab21 100644 --- a/tests/codemod/test_mappings.py +++ b/tests/codemod/test_mappings.py @@ -17,6 +17,7 @@ from mcp_codemod import transform from mcp_codemod._mappings import ( CAMEL_FIELDS, + LOWLEVEL_CTOR_POSITIONAL_PARAMS, LOWLEVEL_DECORATOR_METHODS, LOWLEVEL_REMOVED_ATTRS, MODULE_RENAMES, @@ -592,3 +593,11 @@ def test_every_lowlevel_removed_attribute_is_really_gone_from_the_v2_server() -> for name in LOWLEVEL_REMOVED_ATTRS: assert not hasattr(Server, name), name assert hasattr(Context, name), name + + +def test_the_lowlevel_positional_params_are_keyword_only_on_the_installed_server() -> None: + """Every v1 positional the codemod converts must exist, keyword-only, on the + installed v2 `Server.__init__` -- otherwise the conversion emits a `TypeError`.""" + parameters = inspect.signature(Server.__init__).parameters + for name in LOWLEVEL_CTOR_POSITIONAL_PARAMS: + assert parameters[name].kind is inspect.Parameter.KEYWORD_ONLY diff --git a/tests/codemod/test_transformer.py b/tests/codemod/test_transformer.py index 7be995ce09..3136d3a6f1 100644 --- a/tests/codemod/test_transformer.py +++ b/tests/codemod/test_transformer.py @@ -567,9 +567,9 @@ def field(result: object, key: str) -> object: 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.""" +def test_a_single_argument_mcperror_call_becomes_from_error_data() -> None: + """A v1 `McpError(...)` call took one `ErrorData`; v2's `MCPError.from_error_data(...)` + takes exactly that argument, so the call converts with the expression kept as written.""" source = textwrap.dedent("""\ from mcp.shared.exceptions import McpError from mcp.types import ErrorData @@ -580,126 +580,54 @@ def test_mcperror_wrapping_errordata_is_flattened_to_keyword_arguments() -> None from mcp.shared.exceptions import MCPError from mcp_types import ErrorData -raise MCPError(code=1, message="x", data=None) +raise MCPError.from_error_data(ErrorData(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.""" +def test_a_mcperror_call_with_a_non_inline_argument_is_rewritten_without_a_marker() -> None: + """`McpError(err)` needs no unpacking under `from_error_data`, so the once-marked + non-inline form is now just rewritten.""" 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 + assert "raise MCPError.from_error_data(err)" in result.code + assert result.diagnostics == [] -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`.""" +def test_a_dotted_mcperror_call_converts_on_its_full_spelling() -> None: + """The `from_error_data` conversion composes with the symbol rename when the + constructor is reached through its module path.""" source = textwrap.dedent("""\ - from mcp.shared.exceptions import McpError + import mcp.shared.exceptions - try: - run() - except McpError as e: - print(e.error.code, e.error.message, e.error.data) + raise mcp.shared.exceptions.McpError(build_error()) """) - assert transform(source).code == snapshot("""\ -from mcp.shared.exceptions import MCPError - -try: - run() -except MCPError as e: - print(e.code, e.message, e.data) -""") + result = transform(source) + assert "raise mcp.shared.exceptions.MCPError.from_error_data(build_error())" in result.code -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`.""" +def test_error_attribute_chains_on_a_caught_error_are_left_alone() -> None: + """`e.error.code` and friends still work on v2 (`MCPError.error` is a typed + `ErrorData`), so inside `except McpError as e:` only the exception name changes.""" 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) + print(e.error.code, e.error.message, e.error.data) """) assert transform(source).code == snapshot("""\ -from mcp import MCPError +from mcp.shared.exceptions import MCPError try: run() -except ValueError as e: - try: - run() - except MCPError as inner: - log(inner.code) - log(e.error.code) +except MCPError as e: + print(e.error.code, e.error.message, e.error.data) """) @@ -1232,60 +1160,6 @@ def test_two_identical_findings_on_one_statement_produce_one_marker() -> None: 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. @@ -1537,7 +1411,7 @@ def test_a_from_import_out_of_a_removed_namespace_gets_one_marker() -> None: 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 + assert "has no replacement" in result.diagnostics[0].message def test_a_removed_module_imported_from_its_parent_package_is_marked() -> None: @@ -1648,3 +1522,37 @@ def test_unpacking_a_call_result_is_passed_over() -> None: result = transform(source) assert result.code == source assert result.diagnostics == [] + + +def test_lowlevel_server_positional_arguments_become_keywords() -> None: + """v2 makes everything after `name` keyword-only on the lowlevel `Server` but keeps + v1's parameter names and order, so positionals convert one for one.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + server = Server("srv", "1.2.0", "does things") + """) + result = transform(source) + assert 'Server("srv", version="1.2.0", instructions="does things")' in result.code + assert result.diagnostics == [] + + +def test_a_lowlevel_server_call_with_a_splat_is_left_for_v2_to_reject() -> None: + """A `*`-splat hides how many positions it fills, so the call is left as written -- + v2 raises a TypeError at construction, which is loud and immediate.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + server = Server("srv", *extra) + """) + assert transform(source).code == source + + +def test_lowlevel_keyword_arguments_are_never_touched() -> None: + """A v1 call already passing keywords is valid v2; nothing changes.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + server = Server("srv", version="1.2.0") + """) + assert transform(source).code == source From d99b1400c6e7da506dba841b0963a07bbaa9f544 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:47:06 +0000 Subject: [PATCH 4/4] Rewrite lowlevel decorator registrations through generated adapters The twelve v1 @server.* decorator kinds are gone on v2. Their sites now become add_request_handler / add_notification_handler calls at the decorator's exact source position (registration there is when the v1 decorator ran, so execution order is preserved and the deprecated capabilities land on the warning-free path), wired through generated adapters that reproduce the v1 wrapper semantics: bare-list wrapping, call_tool's any-exception-to-isError contract with jsonschema input and output validation (tool lookup through the registered tools/list handler, v1's own cache mechanism, so cross-module list_tools works), read_resource content conversion, and the completion None-mapping. Handler bodies are never touched. Shapes the adapter cannot serve honestly -- a stacked decorator, an attribute receiver, a non-v1 signature, a non-literal decorator argument, a taken name -- are marked with the reason. The suite migrates a six-registration server and serves it to a v1-shaped ClientSession over the legacy protocol; the templates are pinned against the installed v2 (method strings register, params models exist, imports resolve, no 2026-era surface is emitted). Also on the client surface: inline timedelta session timeouts convert to float seconds and non-provable values are marked (the mismatch only fails on the first request); cursor= on session list_* methods wraps into params=PaginatedRequestParams(...); pydantic URL wrappers around resource URIs are dropped where the target provably takes v2's plain str and marked elsewhere; constructions of and pydantic method calls on the v1 RootModel wrappers that became plain union aliases are marked with the TypeAdapter fix; ._mcp_server and the type-keyed handler dicts are marked with their v2 homes. Adapters honor an explicit `uri: str` annotation and keep v1's AnyUrl otherwise, and keep the emitted code insensitive to user return annotations so a wrong annotation cannot manufacture type errors inside generated code. Batch harness: seven more pinned repositories (two seven-decorator servers, a multi-package lowlevel server, the method-local-server marker path, two client libraries including a positional timedelta timeout and the old streamablehttp spelling, and an exact ==1.6.0 pin). Markers now cover the full statement they precede rather than a fixed radius, Unknown-typed errors in files that carry markers classify as cascade of a marked break, and the work directory is a dot-directory so pytest never collects the cloned repositories' own suites. All eleven repositories audit at zero uncovered errors. An adversarial review round over the full change confirmed ten defects, all fixed with regression tests: adapter imports now inject at the top of the module (a mid-file import as the anchor left registration code running before its imports bound); the rewrite gates now also block a handler named like a template local, and any module-level non-import binding of a name the adapter references (both were silent runtime breaks past the gates); import injection dedup now reads the updated module's top-level import binds, so conditional or function-local imports no longer suppress a needed injection; list_* adapters pass a returned full result model through instead of double-wrapping (v1's runtime behavior); the blocked-progress marker names add_notification_handler (a request-handler registration would never fire); the timeout transform skips already-v2 shapes so re-runs stay no-ops; the emitted name scheme is defined once and shared between templates and gates; and the harness classifier no longer lets a marker cover a whole def/class body or write off arbitrary Unknown-typed errors (header-only spans; cascade restricted to propagation rules and never detonators). --- docs/migration.md | 27 +- scripts/codemod-batch-test/repos.json | 73 +- scripts/codemod-batch-test/run.py | 125 ++- src/mcp-codemod/README.md | 23 +- src/mcp-codemod/mcp_codemod/__init__.py | 18 +- src/mcp-codemod/mcp_codemod/_adapters.py | 304 ++++++ src/mcp-codemod/mcp_codemod/_dependencies.py | 64 +- src/mcp-codemod/mcp_codemod/_mappings.py | 179 ++- src/mcp-codemod/mcp_codemod/_runner.py | 26 +- src/mcp-codemod/mcp_codemod/_transformer.py | 719 ++++++++---- src/mcp-codemod/mcp_codemod/cli.py | 2 +- tests/codemod/test_adapters.py | 178 +++ tests/codemod/test_cli.py | 26 +- tests/codemod/test_dependencies.py | 65 +- tests/codemod/test_mappings.py | 113 +- tests/codemod/test_runner.py | 24 +- tests/codemod/test_transformer.py | 1032 +++++++++++++----- 17 files changed, 2059 insertions(+), 939 deletions(-) create mode 100644 src/mcp-codemod/mcp_codemod/_adapters.py create mode 100644 tests/codemod/test_adapters.py diff --git a/docs/migration.md b/docs/migration.md index 358e97c41e..e05c76ca37 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, 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: +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, the lowlevel `@server.*()` decorator registrations (through generated adapters that keep your handler bodies untouched), 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 @@ -61,6 +61,13 @@ the raised `code`, `message`, and `data` intact. Previously the tool wrapper caught it like any other exception and returned `CallToolResult(isError=True)`, which discarded the error code and structured `data`. +The same applies to `@mcp.prompt()` and resource-template functions: an +`MCPError` raised there propagates verbatim as the JSON-RPC error. On v1 +those wrappers converted it into a generic rendering error (for prompts, a +`ValueError("Error rendering prompt ...")`), so a client matching on the +error code or message will see the raised values instead of the wrapped +generic ones. + `MCPError` carries `ErrorData` and is the SDK's protocol-error type — raise it when the request itself should be rejected (missing client capability, elicitation required, invalid parameters). For tool *execution* failures the @@ -550,9 +557,14 @@ from mcp.shared.exceptions import MCPError try: result = await session.call_tool("my_tool") except MCPError as e: - print(f"Error: {e.message}") + print(f"Error: {e.error.message}") ``` +Only the exception's name changes: `MCPError.error` still carries the full +`ErrorData`, so an existing handler body keeps working as written. `e.code`, +`e.message`, and `e.data` also exist as direct read-only properties if you +prefer the shorter spelling. + `MCPError` is also exported from the top-level `mcp` package: ```python @@ -963,6 +975,17 @@ async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestPar server = Server("my-server", on_call_tool=handle_call_tool) ``` +Registration does not have to move to the constructor: `add_request_handler` +(see [below](#lowlevel-server-add_request_handler-is-now-public-and-takes-params_type)) +registers the same handler into the same registry at any point before `run()`, +which preserves your module's statement order — `mcp-codemod` migrates decorator +registrations this way for exactly that reason: + +```python +server = Server("my-server") +server.add_request_handler("tools/call", CallToolRequestParams, handle_call_tool) +``` + ### `RequestContext` type parameters simplified The `mcp.shared.context` module has been removed. `RequestContext` is now split into `ClientRequestContext` (in `mcp.client.context`) and `ServerRequestContext` (in `mcp.server.context`). diff --git a/scripts/codemod-batch-test/repos.json b/scripts/codemod-batch-test/repos.json index 6beb547684..9fdc5565b5 100644 --- a/scripts/codemod-batch-test/repos.json +++ b/scripts/codemod-batch-test/repos.json @@ -3,7 +3,11 @@ "slug": "official-servers", "url": "https://github.com/modelcontextprotocol/servers", "sha": "7b1170d1da1e36bc9f553f51e76e64cbfd652b3e", - "include": ["src/fetch", "src/git", "src/time"], + "include": [ + "src/fetch", + "src/git", + "src/time" + ], "note": "The official reference servers; lowlevel Server and FastMCP usage." }, { @@ -17,7 +21,9 @@ "slug": "awslabs-aws-documentation", "url": "https://github.com/awslabs/mcp", "sha": "3a5294539de4de3a91d0ee72d5487bc8b8b1fcd7", - "include": ["src/aws-documentation-mcp-server"], + "include": [ + "src/aws-documentation-mcp-server" + ], "note": "One server from the awslabs monorepo; production FastMCP usage." }, { @@ -26,5 +32,68 @@ "sha": "451d255a7305e6efef8a1a2b7374a21c512bba45", "include": [], "note": "Small community FastMCP server." + }, + { + "slug": "mysql-mcp-server", + "url": "https://github.com/designcomputer/mysql_mcp_server", + "sha": "e25be7fc4e9e79d7efc52eb69d776129429a837f", + "include": [ + "src/mysql_mcp_server" + ], + "note": "Seven lowlevel decorators on a module-level Server in one file; stdio + SSE wiring." + }, + { + "slug": "kaltura-mcp", + "url": "https://github.com/zoharbabin/kaltura-mcp", + "sha": "567f016e536692959e294c1ee94c9fc901576cd8", + "include": [ + "src/kaltura_mcp" + ], + "note": "Full seven-decorator lowlevel server with an explicit mcp>=1,<2 pin; handlers dispatch into other modules." + }, + { + "slug": "arxiv-mcp-server", + "url": "https://github.com/blazickjp/arxiv-mcp-server", + "sha": "d58901760d7ede4adb162eaba1725209a933f100", + "include": [ + "src/arxiv_mcp_server" + ], + "note": "Decorator-registered lowlevel server whose handlers live across tools/, prompts/, resources/ subpackages." + }, + { + "slug": "fastapi-mcp", + "url": "https://github.com/tadata-org/fastapi_mcp", + "sha": "e5cad13cabfc725bbcb047e526816d887d96da62", + "include": [ + "fastapi_mcp" + ], + "note": "Decorators on a method-local Server closing over self, with request_context introspection: the marker path." + }, + { + "slug": "langchain-mcp-adapters", + "url": "https://github.com/langchain-ai/langchain-mcp-adapters", + "sha": "6a10b83516e825b8ff73870e5595113acc1c8c6d", + "include": [ + "langchain_mcp_adapters" + ], + "note": "Client library exercising every v1 transport, session kwargs pass-through, and cursor pagination." + }, + { + "slug": "mcpadapt", + "url": "https://github.com/grll/mcpadapt", + "sha": "538cd85628b555ef4ad9392b7270b52274f444d4", + "include": [ + "src/mcpadapt" + ], + "note": "Client library on the old streamablehttp_client spelling with a positional timedelta session timeout." + }, + { + "slug": "chroma-mcp", + "url": "https://github.com/chroma-core/chroma-mcp", + "sha": "98ff67589bdcc31b730a5415ff9529433f949077", + "include": [ + "src/chroma_mcp" + ], + "note": "Org-backed FastMCP server frozen on mcp[cli]==1.6.0: the exact-pin dependency rewrite." } ] diff --git a/scripts/codemod-batch-test/run.py b/scripts/codemod-batch-test/run.py index 0a5d071451..07514f84d8 100644 --- a/scripts/codemod-batch-test/run.py +++ b/scripts/codemod-batch-test/run.py @@ -1,23 +1,14 @@ """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. +Each pinned repo is migrated and pyright-checked on both sides (pristine against the +latest v1 SDK, migrated against this workspace's v2). Every new error must sit on or +near a `# mcp-codemod:` marker; an uncovered error is a silent miss and exits 1. -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] +Usage: uv run --frozen python scripts/codemod-batch-test/run.py [--repo SLUG] [--fresh] """ import argparse +import ast import json import shutil import subprocess @@ -32,28 +23,23 @@ HARNESS_DIR = Path(__file__).resolve().parent WORKSPACE_ROOT = HARNESS_DIR.parents[1] -WORK_DIR = HARNESS_DIR / "work" +# Dot-directory: pytest's default norecursedirs keeps cloned repos' test suites out of `./scripts/test`. +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. +# Max line distance for an error to still count as explained by a marker. 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. +# Rules written off as v2 strictness drift, but only in a file the codemod did not touch and with +# no mcp symbol in the message. `reportAttributeAccessIssue` is absent: a missed removal looks like it. DRIFT_RULES = frozenset({"reportArgumentType", "reportOptionalSubscript", "reportOptionalMemberAccess"}) -# Argument types that detonate at RUNTIME on v2 (`timedelta` where v2 takes float -# seconds, `AnyUrl` where v2 takes `str`). A `reportArgumentType` error naming one -# of these is a real break pyright happens to catch, never strictness drift. +# A `reportArgumentType` error naming one of these is a real runtime break on v2, never strictness drift. DETONATOR_TYPES = ("timedelta", "AnyUrl") -# 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. +# Rules that carry a break's downstream type propagation rather than its source. +CASCADE_RULES = frozenset({"reportArgumentType", "reportAssignmentType", "reportCallIssue", "reportReturnType"}) + +# Outside the SDK checkout: inside it, uv resolves the SDK workspace itself and the env would hold v2. V1_ENV_DIR = Path.home() / ".cache" / "mcp-codemod-batch-test" / "v1env" V1_ENV_PYPROJECT = """\ @@ -115,9 +101,7 @@ def _load_repos(only: str | None) -> list[Repo]: 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. + Fails loudly unless it really holds v1: a v2 baseline would report no migration delta. """ env_dir = V1_ENV_DIR python = env_dir / ".venv" / "bin" / "python" @@ -159,9 +143,9 @@ def _pyright_errors(repo: Repo, *, python: Path, side: Path) -> list[PyrightErro """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. + nothing outside it is ever scanned. + + `--pythonpath` beats the implicit `VIRTUAL_ENV` that `uv run` exports, which a config `venvPath` does not. """ config = { "include": list(repo.include) or ["."], @@ -181,8 +165,7 @@ def _pyright_errors(repo: Repo, *, python: Path, side: Path) -> list[PyrightErro 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. + # A bad include path makes pyright "succeed" over nothing; fail the repo instead. print(f" pyright analyzed zero files in {side} -- check the include paths", file=sys.stderr) return None diagnostics = output.get("generalDiagnostics") @@ -206,21 +189,57 @@ def _pyright_errors(repo: Repo, *, python: Path, side: Path) -> list[PyrightErro 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]] = {} +def _statement_spans(source: str) -> list[tuple[int, int]]: + """The (lineno, end_lineno) of every statement in a parseable Python file. + + A compound statement contributes only its HEADER lines (up to its first body + statement): a marker above a `with` covers the multi-line call in its header, + never the hundreds of lines inside a def or class body. + """ + try: + tree = ast.parse(source) + except SyntaxError: + return [] + spans: list[tuple[int, int]] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.stmt): + continue + end = node.end_lineno or node.lineno + body = getattr(node, "body", None) + if isinstance(body, list) and body and isinstance(body[0], ast.stmt): + end = min(end, body[0].lineno - 1) + spans.append((node.lineno, end)) + return spans + + +def _collect_markers(roots: list[Path], side: Path) -> dict[str, list[tuple[int, int]]]: + """Every `# mcp-codemod:` line in the migrated tree, by file, as covered spans. + + A marker covers `MARKER_RADIUS` around itself plus any statement starting within that radius below it. + """ + markers: dict[str, list[tuple[int, 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() + source = path.read_bytes().decode("utf-8") except (OSError, UnicodeDecodeError): continue + lines = source.splitlines() hits = [number for number, line in enumerate(lines, start=1) if needle in line] - if hits: - markers[str(path.relative_to(side))] = hits + if not hits: + continue + spans = _statement_spans(source) if path.suffix == ".py" else [] + covered: list[tuple[int, int]] = [] + for hit in hits: + end = hit + MARKER_RADIUS + for start, stop in spans: + if hit < start <= hit + MARKER_RADIUS: + end = max(end, stop) + covered.append((hit - MARKER_RADIUS, end)) + markers[str(path.relative_to(side))] = covered return markers @@ -256,13 +275,18 @@ def _audit_repo(repo: Repo, *, v1_python: Path, fresh: bool) -> tuple[dict[str, markers = _collect_markers(roots, migrated) actionable: list[PyrightError] = [] drift: list[PyrightError] = [] + cascade: list[PyrightError] = [] for error in new_errors: - nearby = markers.get(error.file, []) - if any(abs(line - error.line) <= MARKER_RADIUS for line in nearby): + spans = markers.get(error.file, []) + if any(start <= error.line <= end for start, end in spans): + continue + # A break's source always errors without "Unknown" in its message, so + # "Unknown" only appears in downstream propagation -- and in a marked file + # the roots are the marked ones. Detonators stay actionable regardless. + is_detonator = any(f'of type "{detonator}"' in error.message for detonator in DETONATOR_TYPES) + if "Unknown" in error.message and spans and not is_detonator and error.rule in CASCADE_RULES: + cascade.append(error) 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() @@ -273,10 +297,11 @@ def _audit_repo(repo: Repo, *, v1_python: Path, fresh: bool) -> tuple[dict[str, else: actionable.append(error) - covered = len(new_errors) - len(actionable) - len(drift) + covered = len(new_errors) - len(actionable) - len(drift) - len(cascade) 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" + f"({resolved} resolved): {covered} covered by markers, {len(cascade)} marked-break cascade, " + f"{len(drift)} v2 strictness drift" ) for error in actionable: print(f" UNCOVERED {error.file}:{error.line} [{error.rule}] {error.message.splitlines()[0]}") diff --git a/src/mcp-codemod/README.md b/src/mcp-codemod/README.md index f961636467..ebc3b6426a 100644 --- a/src/mcp-codemod/README.md +++ b/src/mcp-codemod/README.md @@ -40,6 +40,21 @@ manual fix-up. change. - The `streamable_http_client(...) as (read, write, _)` three-tuple to the v2 two-tuple. +- Lowlevel `@server.list_tools()` / `@server.call_tool()` / ... decorator + registrations, to `server.add_request_handler(...)` calls at the same source + position, each wired through a generated adapter that reproduces the v1 + wrapper semantics your handler relied on: bare-list wrapping, the `call_tool` + isError contract with jsonschema input/output validation, `read_resource` + content conversion, and the completion None-mapping. Your handler bodies are + not touched. A shape the adapter cannot serve honestly (a stacked decorator, + a `self.`-attribute server, a non-v1 signature) is marked instead. +- Positional arguments after the name on the lowlevel `Server(...)` constructor + to keywords (v2 is keyword-only there but kept v1's names and order). +- An inline `timedelta(...)` passed as a `ClientSession` timeout to + `.total_seconds()` (v2 takes float seconds and would only fail on the first + request), `cursor=` on the session's `list_*` methods to the v2 + `params=PaginatedRequestParams(...)` form, and a pydantic `AnyUrl(...)` / + `FileUrl(...)` wrapper around a resource URI to the plain string v2 expects. - 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 @@ -69,9 +84,11 @@ The codemod never guesses at these; it leaves them exactly as written and adds a `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. +- Lowlevel decorator registrations the generated adapters cannot serve + honestly: a second decorator stacked on the handler, a server reached through + an attribute (`self.server`), a handler signature away from the v1 form, or a + decorator argument the codemod cannot evaluate. The marker names the reason + and the `add_request_handler(...)` destination. - 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. diff --git a/src/mcp-codemod/mcp_codemod/__init__.py b/src/mcp-codemod/mcp_codemod/__init__.py index 3ad6a6ccc6..a964c52677 100644 --- a/src/mcp-codemod/mcp_codemod/__init__.py +++ b/src/mcp-codemod/mcp_codemod/__init__.py @@ -1,21 +1,11 @@ """Automated rewrites for migrating code between major versions of the MCP Python SDK. -Run it as a tool: +Run as a tool (`uvx mcp-codemod v1-to-v2 ./src`) or call `transform(source)` as a library. - 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 +Rewrites are 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. +information outside the file gets an inline `# mcp-codemod:` comment instead of a +guess; `grep -rn '# mcp-codemod:'` after a run lists everything left for a human. """ from mcp_codemod._transformer import MARKER, Diagnostic, Result, transform diff --git a/src/mcp-codemod/mcp_codemod/_adapters.py b/src/mcp-codemod/mcp_codemod/_adapters.py new file mode 100644 index 0000000000..db3d892514 --- /dev/null +++ b/src/mcp-codemod/mcp_codemod/_adapters.py @@ -0,0 +1,304 @@ +"""Emitted-source templates for the lowlevel decorator -> registration rewrite. + +Adapters reproduce v1's wrapper semantics against the public v2 surface only: a +migrated file never imports from mcp_codemod, and nothing 2026-era is emitted. +Templates use `__FN__`/`__RECV__` placeholders instead of `str.format` because +the emitted code is full of braces. +""" + +import ast +from dataclasses import dataclass, field + +__all__ = ["LOWLEVEL_HANDLER_SPECS", "TEMPLATE_LOCALS", "HandlerSpec", "build_adapter", "cache_name", "handler_name"] + +# Injected into the migrated module only when the name is not already bound by an import there. +ADAPTER_IMPORTS: dict[str, str] = { + "base64": "import base64", + "Iterable": "from collections.abc import Iterable", + "cast": "from typing import cast", + "json": "import json", + "jsonschema": "import jsonschema", + "AnyUrl": "from pydantic import AnyUrl", + "MCPError": "from mcp import MCPError", + "ServerRequestContext": "from mcp.server import ServerRequestContext", + "ReadResourceContents": "from mcp.server.lowlevel.helper_types import ReadResourceContents", + "mcp_types": "import mcp_types", +} + + +@dataclass(frozen=True, slots=True) +class HandlerSpec: + """How one v1 decorator kind maps onto a generated v2 registration.""" + + template: str + arity: int + """Positional-parameter count of the v1 handler signature.""" + imports: tuple[str, ...] = field(default=("ServerRequestContext", "mcp_types")) + """Names from ADAPTER_IMPORTS the emitted code references.""" + notification: bool = False + """Whether the kind registers through add_notification_handler.""" + + +# v1's list wrappers passed an already-full result model through at runtime, so +# the adapter must too; the cast keeps the user's return annotation out of it. +_BARE_LIST = """\ + +async def ___FN___handler( + ctx: ServerRequestContext, params: mcp_types.PaginatedRequestParams +) -> mcp_types.{result}: + result = cast("object", await __FN__()) + if isinstance(result, mcp_types.{result}): + return result + return mcp_types.{result}({field}=cast("list[mcp_types.{item}]", result)) + + +__RECV__.add_request_handler("{method}", mcp_types.PaginatedRequestParams, ___FN___handler) +""" + +_GET_PROMPT = """\ + +async def ___FN___handler( + ctx: ServerRequestContext, params: mcp_types.GetPromptRequestParams +) -> mcp_types.GetPromptResult: + return await __FN__(params.name, params.arguments) + + +__RECV__.add_request_handler("prompts/get", mcp_types.GetPromptRequestParams, ___FN___handler) +""" + +_COMPLETION = """\ + +async def ___FN___handler( + ctx: ServerRequestContext, params: mcp_types.CompleteRequestParams +) -> mcp_types.CompleteResult: + completion = await __FN__(params.ref, params.argument, params.context) + if completion is None: + completion = mcp_types.Completion(values=[], total=None, has_more=None) + return mcp_types.CompleteResult(completion=completion) + + +__RECV__.add_request_handler("completion/complete", mcp_types.CompleteRequestParams, ___FN___handler) +""" + +_URI_EMPTY = """\ + +async def ___FN___handler( + ctx: ServerRequestContext, params: mcp_types.{params} +) -> mcp_types.EmptyResult: + await __FN__(__URI__) + return mcp_types.EmptyResult() + + +__RECV__.add_request_handler("{method}", mcp_types.{params}, ___FN___handler) +""" + +_SET_LOGGING_LEVEL = """\ + +async def ___FN___handler( + ctx: ServerRequestContext, params: mcp_types.SetLevelRequestParams +) -> mcp_types.EmptyResult: + await __FN__(params.level) + return mcp_types.EmptyResult() + + +__RECV__.add_request_handler("logging/setLevel", mcp_types.SetLevelRequestParams, ___FN___handler) +""" + +_PROGRESS = """\ + +async def ___FN___handler( + ctx: ServerRequestContext, params: mcp_types.ProgressNotificationParams +) -> None: + await __FN__(params.progress_token, params.progress, params.total, params.message) + + +__RECV__.add_notification_handler("notifications/progress", mcp_types.ProgressNotificationParams, ___FN___handler) +""" + +# Reproduces v1's `@read_resource()` return conversion: bare `str`/`bytes` is a single +# content item; iterables of `ReadResourceContents` convert with v1's default MIME types. +_READ_RESOURCE = """\ + +async def ___FN___handler( + ctx: ServerRequestContext, params: mcp_types.ReadResourceRequestParams +) -> mcp_types.ReadResourceResult: + result: object = await __FN__(__URI__) + if isinstance(result, str | bytes): + items = [ReadResourceContents(content=result)] + else: + items = list(cast("Iterable[ReadResourceContents]", result)) + contents: list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents] = [] + for item in items: + if isinstance(item.content, str): + contents.append( + mcp_types.TextResourceContents( + uri=params.uri, text=item.content, mime_type=item.mime_type or "text/plain", _meta=item.meta + ) + ) + else: + contents.append( + mcp_types.BlobResourceContents( + uri=params.uri, + blob=base64.b64encode(item.content).decode(), + mime_type=item.mime_type or "application/octet-stream", + _meta=item.meta, + ) + ) + return mcp_types.ReadResourceResult(contents=contents) + + +__RECV__.add_request_handler("resources/read", mcp_types.ReadResourceRequestParams, ___FN___handler) +""" + +# Reproduces v1's `@call_tool()` dispatch in v1's order with v1's error strings, looking +# tools up through the registered tools/list handler; `MCPError` re-raises per v2's contract. +_CALL_TOOL = """\ + +___RECV___tool_cache: dict[str, mcp_types.Tool] = {} + + +async def ___FN___handler( + ctx: ServerRequestContext, params: mcp_types.CallToolRequestParams +) -> mcp_types.CallToolResult: + def _error(message: str) -> mcp_types.CallToolResult: + return mcp_types.CallToolResult(content=[mcp_types.TextContent(type="text", text=message)], is_error=True) + + try: + arguments = params.arguments or {} + if params.name not in ___RECV___tool_cache: + listed = __RECV__.get_request_handler("tools/list") + if listed is not None: + tools = await listed.handler(ctx, mcp_types.PaginatedRequestParams()) + if isinstance(tools, mcp_types.ListToolsResult): + ___RECV___tool_cache.clear() + ___RECV___tool_cache.update({tool.name: tool for tool in tools.tools}) + tool = ___RECV___tool_cache.get(params.name) +__VALIDATION__ results = cast("object", await __FN__(params.name, arguments)) + if isinstance(results, mcp_types.CallToolResult): + return results + if isinstance(results, tuple) and len(results) == 2: + content, structured = results + elif isinstance(results, dict): + content = [mcp_types.TextContent(type="text", text=json.dumps(results, indent=2))] + structured = results + elif isinstance(results, Iterable): + content, structured = results, None + else: + return _error(f"Unexpected return type from tool: {type(results).__name__}") + if tool is not None and tool.output_schema is not None: + if structured is None: + return _error("Output validation error: outputSchema defined but no structured output returned") + try: + jsonschema.validate(instance=structured, schema=tool.output_schema) + except jsonschema.ValidationError as exc: + return _error(f"Output validation error: {exc.message}") + return mcp_types.CallToolResult(content=list(content), structured_content=structured, is_error=False) + except MCPError: + raise + except Exception as exc: + return _error(str(exc)) + + +__RECV__.add_request_handler("tools/call", mcp_types.CallToolRequestParams, ___FN___handler) +""" + +_CALL_TOOL_VALIDATION = """\ + if tool is not None: + try: + jsonschema.validate(instance=arguments, schema=tool.input_schema) + except jsonschema.ValidationError as exc: + return _error(f"Input validation error: {exc.message}") +""" + +_URI_IMPORTS = ("ServerRequestContext", "mcp_types") + +LOWLEVEL_HANDLER_SPECS: dict[str, HandlerSpec] = { + "list_tools": HandlerSpec( + _BARE_LIST.format(result="ListToolsResult", field="tools", method="tools/list", item="Tool"), + 0, + ("ServerRequestContext", "mcp_types", "cast"), + ), + "list_resources": HandlerSpec( + _BARE_LIST.format(result="ListResourcesResult", field="resources", method="resources/list", item="Resource"), + 0, + ("ServerRequestContext", "mcp_types", "cast"), + ), + "list_prompts": HandlerSpec( + _BARE_LIST.format(result="ListPromptsResult", field="prompts", method="prompts/list", item="Prompt"), + 0, + ("ServerRequestContext", "mcp_types", "cast"), + ), + "list_resource_templates": HandlerSpec( + _BARE_LIST.format( + result="ListResourceTemplatesResult", + field="resource_templates", + method="resources/templates/list", + item="ResourceTemplate", + ), + 0, + ("ServerRequestContext", "mcp_types", "cast"), + ), + "get_prompt": HandlerSpec(_GET_PROMPT, 2), + "completion": HandlerSpec(_COMPLETION, 3), + "subscribe_resource": HandlerSpec( + _URI_EMPTY.format(params="SubscribeRequestParams", method="resources/subscribe"), 1, _URI_IMPORTS + ), + "unsubscribe_resource": HandlerSpec( + _URI_EMPTY.format(params="UnsubscribeRequestParams", method="resources/unsubscribe"), 1, _URI_IMPORTS + ), + "set_logging_level": HandlerSpec(_SET_LOGGING_LEVEL, 1), + "progress_notification": HandlerSpec(_PROGRESS, 4, notification=True), + "read_resource": HandlerSpec( + _READ_RESOURCE, + 1, + ("ServerRequestContext", "mcp_types", "ReadResourceContents", "Iterable", "cast", "base64"), + ), + "call_tool": HandlerSpec( + _CALL_TOOL, 2, ("ServerRequestContext", "mcp_types", "MCPError", "Iterable", "cast", "json", "jsonschema") + ), +} + + +def handler_name(fn: str) -> str: + """The emitted adapter's name for a handler function.""" + return f"_{fn}_handler" + + +def cache_name(recv: str) -> str: + """The emitted call_tool tool-cache name for a server variable.""" + return f"_{recv}_tool_cache" + + +def _template_locals() -> dict[str, frozenset[str]]: + """Names each rendered template binds, derived from the templates themselves. + + A user function sharing one of these names would be shadowed inside its own + adapter (UnboundLocalError), so the transformer blocks those sites. + """ + locals_by_kind: dict[str, frozenset[str]] = {} + for kind in LOWLEVEL_HANDLER_SPECS: + names: set[str] = set() + for node in ast.walk(ast.parse(build_adapter(kind, "no_fn", "no_recv"))): + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store): + names.add(node.id) + elif isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + names.add(node.name) + elif isinstance(node, ast.ExceptHandler) and node.name: + names.add(node.name) + locals_by_kind[kind] = frozenset(names) + return locals_by_kind + + +def build_adapter(kind: str, fn: str, recv: str, *, validate_input: bool = True, uri_as_str: bool = False) -> str: + """Render the emitted block for one rewritten decorator site. + + `validate_input=False` omits only the input-validation block -- v1 validated output + schemas regardless. `uri_as_str` passes the wire string through for `str`-annotated uris. + """ + template = LOWLEVEL_HANDLER_SPECS[kind].template + template = template.replace("__VALIDATION__", _CALL_TOOL_VALIDATION if validate_input else "") + template = template.replace("__URI__", "params.uri" if uri_as_str else "AnyUrl(params.uri)") + return template.replace("__FN__", fn).replace("__RECV__", recv) + + +TEMPLATE_LOCALS: dict[str, frozenset[str]] = _template_locals() diff --git a/src/mcp-codemod/mcp_codemod/_dependencies.py b/src/mcp-codemod/mcp_codemod/_dependencies.py index 00925cd331..32087f345b 100644 --- a/src/mcp-codemod/mcp_codemod/_dependencies.py +++ b/src/mcp-codemod/mcp_codemod/_dependencies.py @@ -1,13 +1,8 @@ """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. +Rewrites v1-era `mcp` requirements that exclude v2 to `>=2,<3` in every +`pyproject.toml` and `requirements*.txt` under the given paths; what cannot be +rewritten safely gets a `# mcp-codemod:` marker instead. """ import os @@ -30,30 +25,20 @@ 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. +# Era probes for `_needs_v2`; the prerelease probe makes a `==2.0.0a1` pin count as a v2 choice. _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. +# Name-plus-extras prefix of a requirement string already validated with `Requirement`. _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. +# An `mcp = ...` key in a Poetry dependency table, whose constraint syntax is never rewritten. _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. +# A requirements.txt line that names mcp but did not parse; skipping it 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. +# The pyproject tables holding PEP 508 strings; edits stay inside them so lookalike text elsewhere is untouched. _DEPENDENCY_TABLES = re.compile(r"^(project|project\.optional-dependencies|dependency-groups)$") @@ -80,9 +65,7 @@ def _line_of(text: str, index: int) -> int: 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. + Constraints not provably v1-era (e.g. a narrow v2 range) are the user's own v2 choice and stay. """ specifier = requirement.specifier if not str(specifier): @@ -100,11 +83,7 @@ def _needs_v2(requirement: Requirement) -> bool: 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. - """ + """Replace the version specifier with `V2_SPECIFIER`, keeping the user's spelling of everything else.""" base, separator, env_marker = spelled.partition(";") prefix = _REQUIREMENT_PREFIX.match(base) assert prefix is not None # `Requirement` accepted it, so the prefix parses @@ -163,7 +142,6 @@ def _pyproject_dependency_strings(parsed: dict[str, object]) -> Iterator[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): @@ -176,11 +154,7 @@ def _has_poetry_mcp(parsed: dict[str, object]) -> bool: 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. - """ + """Offsets of `quoted` inside the standard dependency tables, comments excluded.""" occurrences: list[int] = [] offset = 0 table = "" @@ -201,9 +175,7 @@ def _dependency_region_occurrences(text: str, quoted: str) -> list[int]: 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. + A removed extra or URL pin outranks the specifier; rewriting would lose something the user wrote deliberately. """ removed = sorted(extra for extra in requirement.extras if extra in REMOVED_EXTRAS) if removed: @@ -224,8 +196,7 @@ def _update_pyproject(text: str, *, add_markers: bool) -> tuple[str, list[Diagno 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. + # Locate by quoted form; a requirement needing TOML string escapes does not exist in practice. quoted = next( (q + spelled + q for q in ('"', "'") if _dependency_region_occurrences(text, q + spelled + q)), None ) @@ -248,8 +219,7 @@ def _update_pyproject(text: str, *, add_markers: bool) -> tuple[str, list[Diagno 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. + # Only marker placement needs the key's location; an inline table defeats the line match. keys = list(_POETRY_MCP_KEY.finditer(text)) if not keys: diagnostics.append(Diagnostic(1, "dependency", "manual", message)) @@ -272,8 +242,6 @@ def _update_requirements(text: str, *, add_markers: bool) -> tuple[str, list[Dia 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: @@ -311,7 +279,6 @@ def _is_unparseable(spelled: str) -> bool: 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 @@ -329,8 +296,7 @@ def _dependency_files(paths: Sequence[Path]) -> Iterator[Path]: 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. + A file that cannot be read, decoded as UTF-8, or parsed is reported with its error and left as found. """ reports: list[DependencyReport] = [] for path in _dependency_files(paths): diff --git a/src/mcp-codemod/mcp_codemod/_mappings.py b/src/mcp-codemod/mcp_codemod/_mappings.py index 95366155af..428f7f9228 100644 --- a/src/mcp-codemod/mcp_codemod/_mappings.py +++ b/src/mcp-codemod/mcp_codemod/_mappings.py @@ -1,11 +1,7 @@ """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. +Every transform in `_transformer.py` is driven by one of these tables, and +`tests/codemod/test_mappings.py` pins them against the installed v2 packages. """ import re @@ -15,11 +11,16 @@ "CAMEL_FIELDS", "ERRORDATA_QNAMES", "FASTMCP_QNAMES", + "CLIENT_SESSION_QNAMES", "LOWLEVEL_CTOR_POSITIONAL_PARAMS", - "LOWLEVEL_DECORATOR_METHODS", "LOWLEVEL_REMOVED_ATTRS", "LOWLEVEL_SERVER_QNAMES", "MCPERROR_QNAMES", + "PYDANTIC_URL_QNAMES", + "SESSION_LIST_METHODS", + "SESSION_URI_METHODS", + "TIMEDELTA_QNAMES", + "UNION_TYPE_ALIASES", "MODULE_RENAMES", "REHOMED_IMPORTS", "REMOVED_APIS", @@ -35,11 +36,7 @@ "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-path renames, applied by longest prefix to imports and fully-dotted usages. MODULE_RENAMES: dict[str, str] = { "mcp.server.fastmcp": "mcp.server.mcpserver", "mcp.server.fastmcp.server": "mcp.server.mcpserver.server", @@ -47,20 +44,14 @@ "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. +# (renamed module, imported name) -> the name's PUBLIC v2 home, applied after +# `MODULE_RENAMES`: a type checker treats a name a module does not re-export as private. 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. +# v1 module roots with no v2 home under any name, matched by longest prefix. Imports +# are marked, never rewritten; with `MODULE_RENAMES` these cover every public v1 module. REMOVED_MODULES: dict[str, str] = { "mcp.client.experimental": ("removed: the v1 experimental tasks API was deleted and has no replacement"), "mcp.server.experimental": ("removed: the v1 experimental tasks API was deleted and has no replacement"), @@ -74,9 +65,7 @@ } # 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. +# Usages resolve through the file's imports, so aliases and same-named user symbols are safe. SYMBOL_RENAMES: dict[str, str] = { "mcp.server.FastMCP": "MCPServer", "mcp.server.fastmcp.FastMCP": "MCPServer", @@ -90,9 +79,7 @@ "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. +# v1 public symbols with no v2 home: never rewritten, a `# mcp-codemod:` marker carries the guidance. REMOVED_APIS: dict[str, str] = { "mcp.shared.memory.create_connected_server_and_client_session": ( "removed: pair `create_client_server_memory_streams()` with `Server.run()` and a `ClientSession` " @@ -111,12 +98,8 @@ "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 left with the experimental tasks API 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. + # Every v1 `mcp.types` name with no same-name home in `mcp_types`. Enumerating + # them all is what lets the tests prove every other rewritten import resolves. "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": ( @@ -143,22 +126,17 @@ "mcp.types.TASK_STATUS_CANCELLED": "removed with the v1 experimental tasks API", } -# 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`. +# Extras the v1 `mcp` distribution declared that v2 does not. 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 -# 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 attributes matched by NAME only (receiver types are unknown): an entry must be +# distinctive AND not spelled by any surviving v2 API (see `LOWLEVEL_REMOVED_ATTRS`). 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", + "_mcp_server": "renamed on v2: the wrapped lowlevel server is the private `_lowlevel_server` attribute", } @@ -173,15 +151,9 @@ def _to_snake(name: str) -> str: return re.sub(r"(? str: } ) -# Every camelCase field name declared in v1's `mcp/types.py`. Anything outside -# this set is never renamed -- this is what keeps `logging.getLogger`, stdlib and -# third-party camelCase APIs, and the user's own attributes untouched. +# Every camelCase field name declared in v1's `mcp/types.py`. Names outside this set +# are never renamed, keeping stdlib, third-party, and user camelCase attributes untouched. _V1_CAMEL_FIELDS: tuple[str, ...] = ( "clientInfo", "costPriority", @@ -254,11 +225,9 @@ def _to_snake(name: str) -> str: name: CamelField(_to_snake(name), "risky" if name in _RISKY else "safe") for name in _V1_CAMEL_FIELDS } -# `MCPServer.__init__` keyword arguments that moved to `run()` / `sse_app()` / -# `streamable_http_app()`. The right destination depends on how the server is -# started, and may not be in the same file, so these are never rewritten: the -# kwarg is left in place (v2 then fails loudly with a `TypeError`) and a marker -# is inserted. Deleting the kwarg instead would silently lose configuration. +# `MCPServer.__init__` kwargs that moved to `run()` / the app factories. The right +# destination depends on how the server is started, so the kwarg is only marked and +# left in place (v2 fails loudly): deleting it would silently lose configuration. TRANSPORT_CTOR_PARAMS: frozenset[str] = frozenset( { "event_store", @@ -279,44 +248,28 @@ def _to_snake(name: str) -> str: "mount_path": "removed: mount the app under a Starlette route instead", } -# The v1 lowlevel `Server.__init__` parameters after `name`, in positional order. -# v2 makes everything after `name` keyword-only but keeps these names, so a v1 -# positional argument converts to the keyword at its position one for one. -# Pinned against the installed v2 constructor by `tests/codemod/test_mappings.py`. +# v1 lowlevel `Server.__init__` parameters after `name`, in positional order; v2 keeps +# the names but makes them keyword-only, so positional arguments convert one for one. LOWLEVEL_CTOR_POSITIONAL_PARAMS: tuple[str, ...] = ("version", "instructions", "website_url", "icons", "lifespan") -# 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. +# Removed lowlevel `Server` attributes whose NAMES survive elsewhere on v2, so they +# only match receivers the pre-pass proved are lowlevel servers. LOWLEVEL_REMOVED_ATTRS: dict[str, str] = { "request_context": ( "`Server.request_context` and the `request_ctx` ContextVar were removed: handlers now receive `ctx` explicitly" ), + "request_handlers": ( + "the type-keyed `request_handlers` dict was replaced: register with " + "`add_request_handler(method, params_type, handler)` and look up with `get_request_handler(method)`" + ), + "notification_handlers": ( + "the type-keyed `notification_handlers` dict was replaced: register with " + "`add_notification_handler(method, params_type, handler)`" + ), } -# 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 -# rewriting the handler to `(ctx, params) -> 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. +# Qualified-name sets the transformer resolves callees and constructors against; +# the renamed-class sets are derived from `SYMBOL_RENAMES` so they cannot drift from it. 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( @@ -326,25 +279,57 @@ def _to_snake(name: str) -> str: "mcp.server.lowlevel.server.Server", } ) +CLIENT_SESSION_QNAMES: frozenset[str] = frozenset( + { + "mcp.ClientSession", + "mcp.client.ClientSession", + "mcp.client.session.ClientSession", + } +) +TIMEDELTA_QNAMES: frozenset[str] = frozenset({"datetime.timedelta"}) +PYDANTIC_URL_QNAMES: frozenset[str] = frozenset( + { + "pydantic.AnyUrl", + "pydantic.FileUrl", + "pydantic.networks.AnyUrl", + "pydantic.networks.FileUrl", + } +) +# `ClientSession` methods whose v1 `cursor=` keyword became `params=PaginatedRequestParams(...)`. +SESSION_LIST_METHODS: frozenset[str] = frozenset( + {"list_tools", "list_prompts", "list_resources", "list_resource_templates"} +) +# `ClientSession` methods whose `uri` parameter is a plain `str` on v2 (was `AnyUrl`). +SESSION_URI_METHODS: frozenset[str] = frozenset({"read_resource", "subscribe_resource", "unsubscribe_resource"}) + +# v1 RootModel wrappers that are plain union aliases on v2: the import is fine, but +# constructing them or calling pydantic model methods fails, so only those uses are marked. +UNION_TYPE_ALIASES: dict[str, str] = { + "mcp.types.ClientNotification": "ClientNotification", + "mcp.types.ClientRequest": "ClientRequest", + "mcp.types.ClientResult": "ClientResult", + "mcp.types.JSONRPCMessage": "JSONRPCMessage", + "mcp.types.ServerNotification": "ServerNotification", + "mcp.types.ServerRequest": "ServerRequest", + "mcp.types.ServerResult": "ServerResult", +} + 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. +# The streamable-HTTP client's v1 qualified names, and the same set widened with the +# v2 spelling: a half-migrated call under the v2 name still gets the 3-tuple rewrite, +# but only a v1-NAME call proves unmigrated code, so only it is flagged for the 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. +# v1 `streamablehttp_client` keywords that v2 dropped; `terminate_on_close` survived. 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 index 4e71a777e6..d987450a72 100644 --- a/src/mcp-codemod/mcp_codemod/_runner.py +++ b/src/mcp-codemod/mcp_codemod/_runner.py @@ -1,12 +1,4 @@ -"""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. -""" +"""Apply the v1 -> v2 transformer to files on disk.""" import os from collections import Counter @@ -20,7 +12,6 @@ __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", @@ -83,10 +74,7 @@ def diagnostics(self) -> Counter[str]: 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. + A path that is itself a file is yielded as-is, even without a `.py` suffix. """ for path in paths: if path.is_dir(): @@ -100,19 +88,15 @@ def discover(paths: Sequence[Path]) -> Iterator[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. + """Transform every discovered file, writing the results back when `write` is true. - 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. + Failures are recorded per file; 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. + # UTF-8 bytes rather than `read_text()`: locale-independent, and line endings round-trip unchanged. source = path.read_bytes().decode("utf-8") result = transform(source, add_markers=add_markers) except (OSError, UnicodeDecodeError, ParserSyntaxError) as exc: diff --git a/src/mcp-codemod/mcp_codemod/_transformer.py b/src/mcp-codemod/mcp_codemod/_transformer.py index 8f0ca15436..c9e29c0525 100644 --- a/src/mcp-codemod/mcp_codemod/_transformer.py +++ b/src/mcp-codemod/mcp_codemod/_transformer.py @@ -1,26 +1,11 @@ """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. +`transform()` is the whole programmatic surface: one module's source text in, +rewritten text plus diagnostics out. Rewrites are deliberately conservative: a +construct is rewritten only when its meaning is unambiguous from the file alone +(names resolved through the imports, camelCase renames restricted to v1's declared +field names); anything else is left as written under an inline `# mcp-codemod:` +marker. Running the transformer over its own output is a no-op. """ from collections import Counter @@ -38,42 +23,49 @@ QualifiedNameSource, ) +from mcp_codemod._adapters import ( + ADAPTER_IMPORTS, + LOWLEVEL_HANDLER_SPECS, + TEMPLATE_LOCALS, + build_adapter, + cache_name, + handler_name, +) from mcp_codemod._mappings import ( CAMEL_FIELDS, + CLIENT_SESSION_QNAMES, ERRORDATA_QNAMES, FASTMCP_QNAMES, LOWLEVEL_CTOR_POSITIONAL_PARAMS, - LOWLEVEL_DECORATOR_METHODS, LOWLEVEL_REMOVED_ATTRS, LOWLEVEL_SERVER_QNAMES, MCPERROR_QNAMES, MODULE_RENAMES, + PYDANTIC_URL_QNAMES, REHOMED_IMPORTS, REMOVED_APIS, REMOVED_ATTRS, REMOVED_CTOR_PARAMS, REMOVED_MODULES, + SESSION_LIST_METHODS, + SESSION_URI_METHODS, SYMBOL_RENAMES, + TIMEDELTA_QNAMES, TRANSPORT_CLIENT_QNAMES, TRANSPORT_CLIENT_REMOVED_PARAMS, TRANSPORT_CLIENT_V1_QNAMES, TRANSPORT_CTOR_PARAMS, + UNION_TYPE_ALIASES, ) __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. -""" +"""The prefix of every inserted comment; `grep -rn '# mcp-codemod:'` lists the sites still needing a human.""" 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. +# Longest prefix wins, should overlapping keys ever be added. _MODULE_RENAMES_LONGEST_FIRST: tuple[tuple[str, str], ...] = tuple( sorted(MODULE_RENAMES.items(), key=lambda item: -len(item[0])) ) @@ -86,10 +78,8 @@ 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. + `info`: a safe rewrite was applied, reported for the record; `review`: a rewrite + was applied but rests on a heuristic; `manual`: nothing was rewritten. """ line: int @@ -124,8 +114,7 @@ def _removed_module(dotted: str) -> str | 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. + # A dotted path always parses to a Name or Attribute chain; `parse_expression` cannot say so. return cast("cst.Attribute | cst.Name", cst.parse_expression(dotted)) @@ -137,12 +126,7 @@ def _names_the_sdk(module: str) -> bool: 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. - """ + """Split `REHOMED_IMPORTS` names into their own from-import, or return None when there are none.""" 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] = [] @@ -171,11 +155,52 @@ def _split_rehomed_imports( return cst.FlattenSentinel([remaining, replacement]) +def _import_binds(node: cst.BaseSmallStatement) -> set[str]: + """The module-level names one import statement binds.""" + binds: set[str] = set() + if isinstance(node, cst.Import): + for alias in node.names: + if alias.asname is not None: + binds.add(cst.ensure_type(alias.asname.name, cst.Name).value) + else: + binds.add((get_full_name_for_node(alias.name) or "").split(".")[0]) + elif isinstance(node, cst.ImportFrom) and not isinstance(node.names, cst.ImportStar): + for alias in node.names: + bound = alias.asname.name if alias.asname is not None else alias.name + binds.add(cst.ensure_type(bound, cst.Name).value) + return binds + + +def _statement_binds(node: cst.BaseSmallStatement) -> set[str]: + """The plain names one small statement binds (assignment targets and imports).""" + binds = _import_binds(node) + if isinstance(node, cst.Assign): + for target in node.targets: + if isinstance(target.target, cst.Name): + binds.add(target.target.value) + elif isinstance(node, cst.AnnAssign) and isinstance(node.target, cst.Name): + binds.add(node.target.value) + return binds + + +def _is_v2_timeout_shape(value: cst.BaseExpression) -> bool: + """Whether a timeout expression is already valid v2: `None`, a numeric literal, + or a `.total_seconds()` call (including the one a previous run emitted).""" + if isinstance(value, cst.Name) and value.value == "None": + return True + if isinstance(value, cst.Integer | cst.Float): + return True + return ( + isinstance(value, cst.Call) + and isinstance(value.func, cst.Attribute) + and value.func.attr.value == "total_seconds" + ) + + 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. + # `dict.fromkeys` rather than a set: dedupe while keeping 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: @@ -187,18 +212,13 @@ def _with_markers(statement: _StatementT, messages: Sequence[str]) -> _Statement 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. + `imports_mcp` gates the name-only heuristics to files that import the SDK + (v1's `mcp` or v2's `mcp_types` -- a half-migrated file is still in scope). + `lowlevel_server_vars` tells a lowlevel `Server`'s decorators apart from the + syntactically identical `MCPServer` ones; `user_declared_camel` is every + allowlisted camelCase name a class body in this file declares itself. + `client_session_vars` backs the session-method rewrites, and `bound_names` / + `import_binds` back the adapter name-collision and import-injection checks. """ METADATA_DEPENDENCIES = (QualifiedNameProvider,) @@ -209,15 +229,44 @@ def __init__(self) -> None: self.unrenamed_reference_roots: set[str] = set() self.user_declared_camel: set[str] = set() self.lowlevel_server_vars: set[str] = set() + self.client_session_vars: set[str] = set() + self.bound_names: set[str] = set() + self.import_binds: set[str] = set() + # Module-level bindings only: a function-local `json = ...` cannot shadow + # an injected module import, and a TYPE_CHECKING-gated import does not + # bind at runtime, so both are computed from the module body directly. + self.module_bindings: set[str] = set() + self.module_import_binds: set[str] = set() self._class_depth = 0 + def visit_Module(self, node: cst.Module) -> None: + for statement in node.body: + if isinstance(statement, cst.FunctionDef | cst.ClassDef): + self.module_bindings.add(statement.name.value) + continue + if not isinstance(statement, cst.SimpleStatementLine): + continue + for small in statement.body: + for bind in _statement_binds(small): + self.module_bindings.add(bind) + if isinstance(small, cst.Import | cst.ImportFrom): + for bind in _import_binds(small): + self.module_import_binds.add(bind) + def visit_ClassDef(self, node: cst.ClassDef) -> None: + self.bound_names.add(node.name.value) 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 not isinstance(node.names, cst.ImportStar): + for alias in node.names: + # A from-import alias (and its `as` name) is always a plain Name. + bound = cst.ensure_type(alias.asname.name if alias.asname is not None else alias.name, cst.Name) + self.import_binds.add(bound.value) + self.bound_names.add(bound.value) if node.relative or node.module is None: return if _names_the_sdk(get_full_name_for_node(node.module) or ""): @@ -227,28 +276,43 @@ 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) + # `import a.b` binds `a`; `import a.b as c` binds `c`. + bound = alias.asname.name if alias.asname is not None else None + bind = bound.value if isinstance(bound, cst.Name) else name.split(".")[0] + self.import_binds.add(bind) + self.bound_names.add(bind) if _names_the_sdk(name): self.imports_mcp = True + def visit_FunctionDef(self, node: cst.FunctionDef) -> None: + self.bound_names.add(node.name.value) + for param in (*node.params.posonly_params, *node.params.params, *node.params.kwonly_params): + if param.annotation is not None: + annotated = { + q.name + for q in self.get_metadata(QualifiedNameProvider, param.annotation.annotation, frozenset()) + if q.source is not QualifiedNameSource.LOCAL + } + if annotated & CLIENT_SESSION_QNAMES: + self.client_session_vars.add(param.name.value) + + def visit_WithItem(self, node: cst.WithItem) -> None: + if node.asname is not None: + self._record_binding(node.item, node.asname.name) + 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. + # Renaming `import mcp.types` to `import mcp_types` also unbinds `mcp` -- a + # problem only when a reference no module rename covers still resolves through 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. - - 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 + def _record_binding(self, value: cst.BaseExpression | None, target: cst.BaseExpression) -> None: + """Record a name bound to a lowlevel `Server(...)` or a `ClientSession(...)`, `self.server` included.""" bound = get_full_name_for_node(target) - if bound is None: + if bound is not None and isinstance(target, cst.Name): + self.bound_names.add(bound) + if not isinstance(value, cst.Call) or bound is None: return qualified = { q.name @@ -257,6 +321,8 @@ def _record_lowlevel_server(self, value: cst.BaseExpression | None, target: cst. } if qualified & LOWLEVEL_SERVER_QNAMES: self.lowlevel_server_vars.add(bound) + elif qualified & CLIENT_SESSION_QNAMES: + self.client_session_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.""" @@ -266,12 +332,11 @@ def _record_class_field(self, target: cst.BaseExpression) -> None: 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) + self._record_binding(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) + self._record_binding(node.value, node.target) class _V1ToV2(cst.CSTTransformer): @@ -284,25 +349,22 @@ def __init__(self, prepass: _PrePass, *, add_markers: bool) -> None: 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._client_session_vars = prepass.client_session_vars + self._bound_names = prepass.bound_names + self._module_bindings = prepass.module_bindings + self._module_import_binds = prepass.module_import_binds 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. + # `ADAPTER_IMPORTS` names the emitted adapters reference; `leave_Module` injects the missing imports. + self._needed_imports: set[str] = set() + # One frame per open class definition: whether it subclasses `McpError`. 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. + # Name nodes that are not references (an attribute's `.attr`, a `kwarg=`, 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. + # Pending marker texts per open statement, attached on the way out; the bottom frame is a sentinel. 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. - # 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. + # Calls that are a `with` item bound to a three-element tuple: the one form `leave_WithItem` rewrites. self._narrowable_calls: set[int] = set() # -------------------------------------------------------------- bookkeeping @@ -310,12 +372,9 @@ def __init__(self, prepass: _PrePass, *, add_markers: bool) -> None: 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. + LOCAL-only resolutions are excluded: `mcp` is the most common variable name + in real MCP code, and an attribute chain on such a variable carries a + qualified name spelled exactly like a module path (`mcp.types`). """ return { q.name @@ -324,11 +383,7 @@ def _qualified(self, node: cst.CSTNode) -> set[str]: } 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. - """ + """Whether a plain import other than `renamed_import` still binds `root`.""" for plain in self._plain_imports - {renamed_import}: survives = _rename_module(plain) or plain if survives == root or survives.startswith(f"{root}."): @@ -336,8 +391,7 @@ def _root_still_bound(self, root: str, renamed_import: str) -> bool: 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`. + # The cast: pyright cannot solve `get_metadata`'s generic for `PositionProvider`. line = cast(CodeRange, self.get_metadata(PositionProvider, node)).start.line self.diagnostics.append(Diagnostic(line, transform, severity, message)) if severity != "info": @@ -363,11 +417,9 @@ def on_leave( if isinstance(original_node, cst.SimpleStatementLine | cst.BaseCompoundStatement): pending = self._pending_markers.pop() if pending and self._add_markers: - # At statement level every transform here returns the statement - # itself or a FlattenSentinel of statements -- nothing is removed. + # Statement-level transforms only return the statement itself or a FlattenSentinel. if isinstance(result, cst.FlattenSentinel): - # A split statement: the markers belong above its first piece, - # which takes the original's place in the module. + # Markers on a split statement go above its first piece. pieces = list(result) statement = cast("cst.SimpleStatementLine | cst.BaseCompoundStatement", pieces[0]) pieces[0] = cast(_NodeT, _with_markers(statement, pending)) @@ -414,18 +466,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). + # One statement-level marker covers everything imported from a deleted module. 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 - # exists anywhere is marked (its uses elsewhere in the file are marked by - # `leave_Name`, but an import is often the only mention). + # `QualifiedNameProvider` resolves references, not the import alias that + # creates the binding, so renames and removed-name markers apply here directly. if not isinstance(updated_node.names, cst.ImportStar): aliases: list[cst.ImportAlias] = [] renamed_any = False @@ -458,13 +505,9 @@ def leave_Import(self, original_node: cst.Import, updated_node: cst.Import) -> c 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.) + # `import mcp.types` also bound `mcp`; renaming to a different root drops + # that binding, a problem only when the pre-pass saw another reference still + # resolving through it. (`PositionProvider` has no entry for an `ImportAlias`.) if ( alias.asname is None and renamed.split(".")[0] != root @@ -485,10 +528,8 @@ def leave_Import(self, original_node: cst.Import, updated_node: cst.Import) -> c 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. + # `from import ` of a renamed module bound the OLD module + # object; only a real import of the new module can rebind the local name. if len(updated_node.body) != 1: return updated_node imported = updated_node.body[0] @@ -496,9 +537,7 @@ 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. + # `leave_ImportFrom` has already run, so the split is against the renamed spelling. rehomed = _split_rehomed_imports(updated_node, imported) if rehomed is not None: self.rewrites["import_rehome"] += 1 @@ -520,8 +559,7 @@ def leave_SimpleStatementLine( 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`, ...). + # Keep the original line's leading lines and trailing comment (`# noqa`, ...). return replacement.with_changes( leading_lines=updated_node.leading_lines, trailing_whitespace=updated_node.trailing_whitespace ) @@ -539,22 +577,17 @@ def leave_Name(self, original_node: cst.Name, updated_node: cst.Name) -> cst.Nam 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. + # An aliased import keeps its local spelling; only 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: - # `e.error.code` on a caught error is deliberately NOT collapsed to `e.code`: - # v2's `MCPError` keeps a typed `.error` ErrorData, so the v1 spelling runs - # and type-checks unchanged -- touching it would be modernization, not - # migration. - - # 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). + # `e.error.code` is deliberately NOT collapsed to `e.code`: v2 keeps a typed + # `.error`, so the v1 spelling still runs -- migration, not modernization. + + # An attribute the lowlevel `Server` lost, on a receiver the pre-pass proved is one. 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: @@ -562,20 +595,29 @@ def leave_Attribute(self, original_node: cst.Attribute, updated_node: cst.Attrib return updated_node qualified_names = self._qualified(original_node) + # Pydantic classmethods are gone from the union aliases on v2. + if original_node.attr.value.startswith("model_"): + receiver_names = self._qualified(original_node.value) + for qualified in receiver_names: + if (alias := UNION_TYPE_ALIASES.get(qualified)) is not None: + self._diag( + original_node, + "union_alias", + "manual", + f"`{alias}` is a plain union type on v2 with no pydantic methods: " + f"validate with `pydantic.TypeAdapter({alias})` instead", + ) + break + 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. + # The innermost node naming a renamed module (`mcp.types` inside `mcp.types.Tool`), + # rewritten only in lockstep with a backing plain import: after a bare + # `import mcp`, rewriting would leave nothing importing the new module. 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. + # A prefix of some plain import needs nothing here: the longer node is being rewritten. if not any(plain.startswith(f"{dotted}.") for plain in self._plain_imports): self._diag( original_node, @@ -585,11 +627,7 @@ def leave_Attribute(self, original_node: cst.Attribute, updated_node: cst.Attrib ) 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. + # The mirror of `leave_Name`: removed or renamed symbols reached as a module attribute. for qualified in qualified_names: if qualified in REMOVED_APIS: self._diag(original_node, "removed_api", "manual", f"`{qualified}` {REMOVED_APIS[qualified]}") @@ -599,10 +637,8 @@ def leave_Attribute(self, original_node: cst.Attribute, updated_node: cst.Attrib 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. + # The remaining checks key on the bare attribute name alone: only in an + # SDK-importing file, never on a receiver the imports prove is something else. if not self._imports_mcp or any(not _names_the_sdk(qualified) for qualified in qualified_names): return updated_node @@ -613,10 +649,8 @@ def leave_Attribute(self, original_node: cst.Attribute, updated_node: cst.Attrib 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. + # A class in this file declares this same field, so some receivers + # are the user's own objects: mark every use rather than break them. self._diag( original_node, "attr_snake_case", @@ -631,24 +665,138 @@ def leave_Attribute(self, original_node: cst.Attribute, updated_node: cst.Attrib return updated_node + def _rewrite_session_timeout(self, callee: set[str], original_node: cst.Call, updated_node: cst.Call) -> cst.Call: + """Convert `ClientSession`'s v1 `timedelta` timeout to v2's float seconds. + + Only an inline `timedelta(...)` is provably convertible; any other non-None + value gets a marker instead of a guess. + """ + if not callee & CLIENT_SESSION_QNAMES: + return updated_node + arguments = list(updated_node.args) + # Qualified-name metadata exists only for ORIGINAL nodes; the rewrite applies to updated ones. + for index, argument in enumerate(original_node.args): + positional_timeout = index == 2 and argument.keyword is None and argument.star == "" + keyword_timeout = argument.keyword is not None and argument.keyword.value == "read_timeout_seconds" + if not (positional_timeout or keyword_timeout): + continue + value = argument.value + if isinstance(value, cst.Call) and self._qualified(value.func) & TIMEDELTA_QNAMES: + self.rewrites["timeout_seconds"] += 1 + self._diag(original_node, "timeout_seconds", "info", "converted a `timedelta` timeout to seconds") + arguments[index] = arguments[index].with_changes( + value=cst.Call(func=cst.Attribute(value=arguments[index].value, attr=cst.Name("total_seconds"))) + ) + updated_node = updated_node.with_changes(args=arguments) + elif not _is_v2_timeout_shape(value): + self._diag( + original_node, + "timeout_seconds", + "manual", + "v1's `read_timeout_seconds` was a `timedelta`; v2 takes float seconds: " + "pass this value's `.total_seconds()`", + ) + return updated_node + + def _rewrite_session_method(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Call: + """Rewrite `cursor=` and pydantic-URL uris on a receiver the pre-pass proved is a `ClientSession`.""" + function = original_node.func + if ( + isinstance(function, cst.Attribute) + and (get_full_name_for_node(function.value) or "") in self._client_session_vars + ): + method = function.attr.value + if method in SESSION_LIST_METHODS and len(original_node.args) == 1: + argument = original_node.args[0] + if argument.keyword is not None and argument.keyword.value == "cursor": + self._needed_imports.add("mcp_types") + self.rewrites["session_cursor"] += 1 + self._diag(original_node, "session_cursor", "info", "wrapped `cursor=` in `PaginatedRequestParams`") + wrapped = cst.Call( + func=_dotted_name("mcp_types.PaginatedRequestParams"), + args=[ + cst.Arg( + keyword=cst.Name("cursor"), + value=updated_node.args[0].value, + equal=cst.AssignEqual( + whitespace_before=cst.SimpleWhitespace(""), + whitespace_after=cst.SimpleWhitespace(""), + ), + ) + ], + ) + updated_node = updated_node.with_changes( + args=[updated_node.args[0].with_changes(keyword=cst.Name("params"), value=wrapped)] + ) + elif method in SESSION_URI_METHODS and len(original_node.args) == 1: + value = original_node.args[0].value + if ( + original_node.args[0].keyword is None + and isinstance(value, cst.Call) + and self._qualified(value.func) & PYDANTIC_URL_QNAMES + and len(value.args) == 1 + and value.args[0].keyword is None + ): + self.rewrites["uri_str"] += 1 + self._diag(original_node, "uri_str", "info", f"`{method}` takes a plain `str` uri on v2") + unwrapped = cst.ensure_type(updated_node.args[0].value, cst.Call).args[0].value + updated_node = updated_node.with_changes(args=[updated_node.args[0].with_changes(value=unwrapped)]) + return updated_node + + def _rewrite_uri_kwargs(self, callee: set[str], original_node: cst.Call, updated_node: cst.Call) -> cst.Call: + """Unwrap a pydantic URL passed as `uri=`: rewritten on a proven SDK callee, marked elsewhere.""" + # Qualified-name metadata exists only for ORIGINAL nodes; the rewrite applies to updated ones. + for index, argument in enumerate(original_node.args): + value = argument.value + if ( + argument.keyword is not None + and argument.keyword.value == "uri" + and isinstance(value, cst.Call) + and self._qualified(value.func) & PYDANTIC_URL_QNAMES + and len(value.args) == 1 + and value.args[0].keyword is None + ): + if any(name == "mcp" or name.startswith(("mcp.", "mcp_types.")) for name in callee): + self.rewrites["uri_str"] += 1 + self._diag(original_node, "uri_str", "info", "resource URIs are plain `str` on v2") + arguments = list(updated_node.args) + unwrapped = cst.ensure_type(arguments[index].value, cst.Call).args[0].value + arguments[index] = arguments[index].with_changes(value=unwrapped) + updated_node = updated_node.with_changes(args=arguments) + elif self._imports_mcp: + self._diag( + original_node, + "uri_str", + "manual", + "v2 resource URIs are plain `str`: drop this URL wrapper if the value lands in an mcp type", + ) + return updated_node + + def _flag_union_construction(self, callee: set[str], original_node: cst.Call) -> None: + """Flag construction of a v1 RootModel wrapper that is a plain union alias on v2.""" + for qualified in callee: + if (alias := UNION_TYPE_ALIASES.get(qualified)) is not None: + self._diag( + original_node, + "union_alias", + "manual", + f"`{alias}` is a plain union type on v2 and cannot be constructed: " + f"build the concrete message type instead", + ) + def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Call: callee = self._qualified(original_node.func) - # v1's constructor took a single `ErrorData`; v2's classmethod - # `MCPError.from_error_data(...)` takes exactly that argument, so any - # one-argument call converts uniformly -- the user's expression is kept as - # written, whatever it is. The name itself is renamed by `leave_Name`, - # which has already run on the inner nodes. + # v1's single-`ErrorData` constructor maps exactly onto v2's classmethod + # `MCPError.from_error_data(...)`; `leave_Name` has already renamed the name itself. if callee & MCPERROR_QNAMES and len(original_node.args) == 1: self.rewrites["mcperror_ctor"] += 1 return updated_node.with_changes( func=cst.Attribute(value=updated_node.func, attr=cst.Name("from_error_data")) ) - # A subclass's `super().__init__(...)` is the same constructor spelled the - # one way a classmethod cannot replace, so the inline `ErrorData(...)` is - # flattened into v2's `(code, message, data=None)` arguments; any other - # single argument has nothing safe to unpack and is marked. + # `super().__init__(...)` cannot become a classmethod call, so an inline + # `ErrorData(...)` is flattened into v2's `(code, message, data=None)`. if 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: @@ -662,12 +810,11 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Cal "unpack the `ErrorData` being passed here into those arguments", ) - # camelCase keyword arguments still work at RUNTIME on v2 (every model - # field accepts its camelCase alias by name), but the synthesized - # `__init__` signatures are snake_case, so leaving them fails the user's - # own type-checking. The rename cannot break the call -- which is why, - # unlike the attribute form, the risky tier needs no review marker here -- - # and is gated on the callee resolving into the SDK. + self._flag_union_construction(callee, original_node) + + # camelCase kwargs still work at RUNTIME on v2 (fields accept their aliases) + # but fail type-checking against the snake_case `__init__` signatures. The + # rename cannot break the call, so no review marker even for the risky tier. if any(name == "mcp" or name.startswith(("mcp.", "mcp_types.")) for name in callee): arguments: list[cst.Arg] = [] renamed_any = False @@ -680,16 +827,13 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Cal 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. + # Transport keywords moved off the constructor; where they belong depends on + # how the server is started, so they stay put (v2 rejects them loudly). 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. + # parameter is `title`, so later positionals would silently land wrong. if argument.star == "*" or (argument.keyword is None and argument.star == "" and index > 0): self._diag( argument, @@ -709,10 +853,9 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Cal elif keyword in REMOVED_CTOR_PARAMS: self._diag(argument, "removed_ctor_param", "manual", f"`{keyword}=` {REMOVED_CTOR_PARAMS[keyword]}") - # The lowlevel `Server` constructor is keyword-only after `name` on v2, but - # its parameters kept v1's names and order, so v1 positionals convert to - # keywords one for one. A `*`-splat hides how many positions it fills, so a - # call carrying one is left for v2 to reject loudly at construction. + # v2's lowlevel `Server` ctor is keyword-only after `name` but kept v1's + # parameter names and order, so positionals convert one for one; a `*`-splat + # hides how many positions it fills and is left for v2 to reject. if ( callee & LOWLEVEL_SERVER_QNAMES and 1 < len(original_node.args) <= 1 + len(LOWLEVEL_CTOR_POSITIONAL_PARAMS) @@ -732,12 +875,13 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Cal if arguments != list(updated_node.args): updated_node = updated_node.with_changes(args=arguments) - # 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. + updated_node = self._rewrite_session_timeout(callee, original_node, updated_node) + updated_node = self._rewrite_session_method(original_node, updated_node) + updated_node = self._rewrite_uri_kwargs(callee, original_node, updated_node) + + # The keyword check lives here so it fires however the call is used; only the + # `as (read, write, _)` with-item form gets its unpacking rewritten + # (`leave_WithItem` does), every other use of the v1 name is flagged. if callee & TRANSPORT_CLIENT_QNAMES: for argument in original_node.args: keyword = argument.keyword.value if argument.keyword is not None else "" @@ -758,10 +902,8 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Cal "`(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. + # A `getattr`/`hasattr`/`setattr` name string is the one string position the + # rename applies to; other literals never are -- camelCase IS the wire format. if ( self._imports_mcp and callee & {"builtins.getattr", "builtins.hasattr", "builtins.setattr"} @@ -781,35 +923,134 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Cal 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 + def _lowlevel_decorator(self, node: cst.FunctionDef) -> tuple[str, str, cst.Call] | None: + """The (receiver, kind, decorator call) of a lowlevel registration, or None.""" + for wrapper in node.decorators: + decorator = wrapper.decorator + if ( + isinstance(decorator, cst.Call) + and isinstance(decorator.func, cst.Attribute) + and (get_full_name_for_node(decorator.func.value) or "") in self._lowlevel_server_vars + and decorator.func.attr.value in LOWLEVEL_HANDLER_SPECS + ): + return ( + cast(str, get_full_name_for_node(decorator.func.value)), + decorator.func.attr.value, + decorator, + ) + return None + + def _lowlevel_blocker(self, node: cst.FunctionDef, receiver: str, kind: str, decorator: cst.Call) -> str | None: + """Why this decorator site cannot be rewritten, or None when it can. + + Each check guards a way the generated adapter could silently misbehave + rather than fail loudly. + """ + if len(node.decorators) > 1: + return "another decorator is stacked on it" + if "." in receiver: + return "the server is reached through an attribute" + if self._in_mcperror_class: + return "the handler is defined in a class body" + if node.asynchronous is None: + return "the handler is not `async def`" + arguments = decorator.args + if kind == "call_tool" and len(arguments) == 1: + argument = arguments[0] + if not ( + argument.keyword is not None + and argument.keyword.value == "validate_input" + and isinstance(argument.value, cst.Name) + and argument.value.value in ("True", "False") + ): + return "the decorator call has arguments the codemod cannot evaluate" + elif arguments: + return "the decorator call has arguments the codemod cannot evaluate" + parameters = node.params if ( - isinstance(decorator, cst.Call) - and isinstance(decorator.func, cst.Attribute) - and (get_full_name_for_node(decorator.func.value) or "") in self._lowlevel_server_vars - and decorator.func.attr.value in LOWLEVEL_DECORATOR_METHODS + parameters.star_kwarg is not None + or parameters.kwonly_params + or not isinstance(parameters.star_arg, cst.MaybeSentinel) ): - method = decorator.func.attr.value - receiver = get_full_name_for_node(decorator.func.value) + return "the handler signature does not match the v1 form" + positional = [*parameters.posonly_params, *parameters.params] + required = sum(1 for parameter in positional if parameter.default is None) + if not required <= LOWLEVEL_HANDLER_SPECS[kind].arity <= len(positional): + return "the handler signature does not match the v1 form" + emitted = {handler_name(node.name.value)} + if kind == "call_tool": + emitted.add(cache_name(receiver)) + if emitted & self._bound_names: + return "a generated name is already bound in this file" + if node.name.value in TEMPLATE_LOCALS[kind]: + return "the handler's name collides with a name the generated adapter uses" + # A module-level non-import binding of a name the adapter references would + # shadow the injected import (`json = None` breaks `json.dumps` at runtime). + needed = set(LOWLEVEL_HANDLER_SPECS[kind].imports) | {"AnyUrl"} + if needed & (self._module_bindings - self._module_import_binds): + return "a name the generated adapter needs is already bound in this file" + return None + + def leave_FunctionDef( + self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef + ) -> cst.BaseStatement | cst.FlattenSentinel[cst.BaseStatement]: + found = self._lowlevel_decorator(original_node) + if found is None: + return updated_node + receiver, kind, decorator = found + blocked = self._lowlevel_blocker(original_node, receiver, kind, decorator) + if blocked is not None: + register = ( + "add_notification_handler" if LOWLEVEL_HANDLER_SPECS[kind].notification else "add_request_handler" + ) self._diag( original_node, - "lowlevel_decorator", + "lowlevel_registration", "manual", - 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", + f"the lowlevel `@{receiver}.{kind}()` decorator was removed and this site was not rewritten " + f"automatically ({blocked}): register the handler with `{receiver}.{register}(...)` " + f"taking `(ctx, params)`", ) - return updated_node + return updated_node + validate_input = True + for argument in decorator.args: + validate_input = cst.ensure_type(argument.value, cst.Name).value == "True" + # v1 always passed `AnyUrl` to the uri kinds, but a handler annotated + # `uri: str` declared its own contract -- honor it and skip the wrapper. + uri_as_str = False + if kind in ("read_resource", "subscribe_resource", "unsubscribe_resource"): + parameter = [*original_node.params.posonly_params, *original_node.params.params][0] + annotation = parameter.annotation.annotation if parameter.annotation is not None else None + uri_as_str = isinstance(annotation, cst.Name) and annotation.value == "str" + if not uri_as_str: + self._needed_imports.add("AnyUrl") + spec = LOWLEVEL_HANDLER_SPECS[kind] + self._needed_imports.update(spec.imports) + self.rewrites["lowlevel_registration"] += 1 + self._diag( + original_node, + "lowlevel_registration", + "info", + f"registered `{original_node.name.value}` for `{kind}` through a generated v1-compat adapter", + ) + adapter = list( + cst.parse_module( + build_adapter( + kind, original_node.name.value, receiver, validate_input=validate_input, uri_as_str=uri_as_str + ) + ).body + ) + # `parse_module` files leading blank lines under `Module.header`; restore the separation. + adapter[0] = adapter[0].with_changes(leading_lines=[cst.EmptyLine(), cst.EmptyLine()]) + stripped = updated_node.with_changes( + decorators=[], + leading_lines=[*updated_node.leading_lines, *updated_node.decorators[0].leading_lines], + ) + return cst.FlattenSentinel([stripped, *adapter]) 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. + # Only `as (a, b, c)` can have its unpacking rewritten; every other use of a + # v1 client call gets the yield-shape marker from `leave_Call` instead. if ( isinstance(node.item, cst.Call) and node.asname is not None @@ -819,8 +1060,7 @@ def visit_WithItem(self, node: cst.WithItem) -> None: 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. + # `leave_Call` covers the removed keywords; this narrows the one rewritable form. if not isinstance(original_node.item, cst.Call): return updated_node if not self._qualified(original_node.item.func) & TRANSPORT_CLIENT_QNAMES: @@ -831,8 +1071,7 @@ def leave_WithItem(self, original_node: cst.WithItem, updated_node: cst.WithItem 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. + # A third element bound to a real name (not `_`) leaves broken uses behind. third = elements[2].value if not (isinstance(third, cst.Name) and third.value == "_"): self._diag( @@ -849,11 +1088,38 @@ def leave_WithItem(self, original_node: cst.WithItem, updated_node: cst.WithItem ) 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. + # Imports the generated adapters need. Inserted at the TOP of the module + # (below only the docstring and `__future__` imports) so they precede the + # registration code wherever the decorator sat -- a mid-file import as the + # anchor would leave the adapter running before its imports bind. Dedup is + # against the updated module's top-level import binds, so a rename this + # run produced (`import mcp_types as types`) counts and a conditional or + # function-local import does not. + if self._needed_imports: + bound: set[str] = set() + body = list(updated_node.body) + insert_at = 0 + for index, statement in enumerate(body): + if not isinstance(statement, cst.SimpleStatementLine): + continue + for small in statement.body: + bound |= _import_binds(small) + is_docstring = index == 0 and isinstance(small, cst.Expr) + is_future = ( + isinstance(small, cst.ImportFrom) + and small.module is not None + and get_full_name_for_node(small.module) == "__future__" + ) + if (is_docstring or is_future) and insert_at == index: + insert_at = index + 1 + missing = [name for name in ADAPTER_IMPORTS if name in self._needed_imports and name not in bound] + if missing: + body[insert_at:insert_at] = [cst.parse_statement(ADAPTER_IMPORTS[name]) for name in missing] + updated_node = updated_node.with_changes(body=body) + + # libCST parses a comment above the module's FIRST statement into + # `Module.header`, not `leading_lines`, so `_with_markers` cannot see a + # marker a previous run put there; drop any already rendered in the header. 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} @@ -875,10 +1141,9 @@ def leave_Module(self, original_node: cst.Module, updated_node: cst.Module) -> c 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. + The output is always valid Python with the input's formatting preserved outside + rewrites; unless `add_markers` is false, each non-info diagnostic also gets an + inline `# mcp-codemod:` comment. Raises: libcst.ParserSyntaxError: if `source` is not parseable as Python. diff --git a/src/mcp-codemod/mcp_codemod/cli.py b/src/mcp-codemod/mcp_codemod/cli.py index a6e58c43f4..b9ab13f810 100644 --- a/src/mcp-codemod/mcp_codemod/cli.py +++ b/src/mcp-codemod/mcp_codemod/cli.py @@ -109,7 +109,7 @@ def _print_summary( def main(argv: Sequence[str] | None = None) -> int: - """Run the codemod. Returns 0, or 1 if any file failed.""" + """Run the codemod, returning 1 if any file failed and 0 otherwise.""" 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) diff --git a/tests/codemod/test_adapters.py b/tests/codemod/test_adapters.py new file mode 100644 index 0000000000..c577c34e7b --- /dev/null +++ b/tests/codemod/test_adapters.py @@ -0,0 +1,178 @@ +"""The generated lowlevel adapters, pinned against the installed v2 at runtime. + +Real v1 registration code is migrated and served to a v1-shaped `ClientSession`, +so every template is proven against the installed package, not expectations. +""" + +import textwrap +from typing import Any, cast + +import anyio +import mcp_types +import pytest +from mcp_codemod import transform +from mcp_codemod._adapters import ADAPTER_IMPORTS, LOWLEVEL_HANDLER_SPECS, build_adapter + +from mcp import ClientSession +from mcp.server.lowlevel import Server +from mcp.shared.memory import create_client_server_memory_streams + +KITCHEN_SINK_V1 = textwrap.dedent("""\ + import mcp.types as types + from mcp.server.lowlevel import Server + from pydantic import AnyUrl + + app = Server("kitchen-sink") + SUBSCRIBED: list[str] = [] + + + @app.list_tools() + async def list_tools() -> list[types.Tool]: + return [ + types.Tool( + name="add", + description="Add two numbers", + inputSchema={ + "type": "object", + "required": ["a", "b"], + "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, + }, + ) + ] + + + @app.call_tool() + async def call_tool(name: str, arguments: dict) -> list[types.ContentBlock]: + if name != "add": + raise ValueError(f"Unknown tool: {name}") + return [types.TextContent(type="text", text=str(arguments["a"] + arguments["b"]))] + + + @app.list_resources() + async def list_resources() -> list[types.Resource]: + return [types.Resource(uri=AnyUrl("demo://greeting"), name="greeting", mimeType="text/plain")] + + + @app.read_resource() + async def read_resource(uri: AnyUrl) -> str: + return f"resource at {uri}" + + + @app.subscribe_resource() + async def subscribe(uri: AnyUrl) -> None: + SUBSCRIBED.append(str(uri)) + + + @app.get_prompt() + async def get_prompt(name: str, arguments: dict | None) -> types.GetPromptResult: + return types.GetPromptResult( + messages=[ + types.PromptMessage(role="user", content=types.TextContent(type="text", text=f"prompt {name}")) + ] + ) +""") + + +def _load_migrated(source: str) -> dict[str, Any]: + result = transform(source) + assert result.code.count("# mcp-codemod:") == 0, result.code + namespace: dict[str, Any] = {"__name__": "migrated"} + exec(compile(result.code, "migrated.py", "exec"), namespace) + return namespace + + +@pytest.mark.anyio +async def test_a_migrated_kitchen_sink_server_serves_a_v1_client_over_the_legacy_protocol() -> None: + """Unknown tools and schema-invalid arguments come back as `is_error` results, not protocol errors.""" + namespace = _load_migrated(KITCHEN_SINK_V1) + app = cast(Server[Any], namespace["app"]) + async with create_client_server_memory_streams() as (client_streams, server_streams): + async with anyio.create_task_group() as task_group: + + async def serve() -> None: + await app.run(server_streams[0], server_streams[1], app.create_initialization_options()) + + task_group.start_soon(serve) + async with ClientSession(client_streams[0], client_streams[1]) as session: + with anyio.fail_after(5): + init = await session.initialize() + assert init.protocol_version == "2025-11-25" + tools = await session.list_tools() + assert [tool.name for tool in tools.tools] == ["add"] + ok = await session.call_tool("add", {"a": 2, "b": 3}) + assert not ok.is_error + assert cast(mcp_types.TextContent, ok.content[0]).text == "5" + unknown = await session.call_tool("nope", {}) + assert unknown.is_error + assert "Unknown tool: nope" in cast(mcp_types.TextContent, unknown.content[0]).text + invalid = await session.call_tool("add", {"a": 1}) + assert invalid.is_error + assert "Input validation error" in cast(mcp_types.TextContent, invalid.content[0]).text + resources = await session.list_resources() + assert resources.resources[0].name == "greeting" + read = await session.read_resource("demo://greeting") + assert cast(mcp_types.TextResourceContents, read.contents[0]).text == "resource at demo://greeting" + await session.subscribe_resource("demo://greeting") + assert namespace["SUBSCRIBED"] == ["demo://greeting"] + prompt = await session.get_prompt("hello", None) + content = cast(mcp_types.TextContent, prompt.messages[0].content) + assert content.text == "prompt hello" + task_group.cancel_scope.cancel() + + +def test_the_migration_is_idempotent_on_its_own_output() -> None: + once = transform(KITCHEN_SINK_V1).code + assert transform(once).code == once + + +def test_every_template_renders_to_parseable_python() -> None: + import ast + + for kind in LOWLEVEL_HANDLER_SPECS: + ast.parse(build_adapter(kind, "user_fn", "srv")) + ast.parse(build_adapter(kind, "user_fn", "srv", validate_input=False)) + + +def test_no_template_emits_a_2026_era_surface() -> None: + """The codemod's goal forbids routing users onto 2026-era features.""" + for kind in LOWLEVEL_HANDLER_SPECS: + block = build_adapter(kind, "user_fn", "srv") + for forbidden in ("InputRequiredResult", "subscriptions/listen", "cache_hints", "extensions", "Resolve"): + assert forbidden not in block, (kind, forbidden) + + +def test_every_adapter_import_statement_resolves_on_the_installed_v2() -> None: + for statement in ADAPTER_IMPORTS.values(): + exec(statement, {}) + + +def test_every_spec_params_model_exists_in_mcp_types() -> None: + """The registration passes `mcp_types.` by name; the name must exist.""" + for kind in LOWLEVEL_HANDLER_SPECS: + rendered = build_adapter(kind, "user_fn", "srv") + registration = [ + line + for line in rendered.splitlines() + if "add_request_handler" in line or "add_notification_handler" in line + ] + assert len(registration) == 1, kind + model = registration[0].split("mcp_types.")[1].split(",")[0] + assert hasattr(mcp_types, model), (kind, model) + + +def test_every_spec_method_registers_on_the_installed_server() -> None: + """Pins the emitted method strings and registration calls against the installed `Server`.""" + server: Server[Any] = Server("ratchet") + + async def handler(ctx: object, params: object) -> None: + return None + + anyio.run(handler, None, None) + for kind in LOWLEVEL_HANDLER_SPECS: + rendered = build_adapter(kind, "user_fn", "srv") + line = next(line for line in rendered.splitlines() if ".add_" in line) + method = line.split('"')[1] + if "add_notification_handler" in line: + server.add_notification_handler(method, cast("type[Any]", object), cast(Any, handler)) + else: + server.add_request_handler(method, cast("type[Any]", object), cast(Any, handler)) diff --git a/tests/codemod/test_cli.py b/tests/codemod/test_cli.py index 738a9a1d9c..a83bb82d87 100644 --- a/tests/codemod/test_cli.py +++ b/tests/codemod/test_cli.py @@ -8,7 +8,6 @@ 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") @@ -19,7 +18,6 @@ def test_v1_to_v2_rewrites_files_and_prints_a_summary(tmp_path: Path, capsys: py 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) @@ -31,7 +29,6 @@ def test_dry_run_reports_without_writing(tmp_path: Path, capsys: pytest.CaptureF 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") @@ -43,7 +40,6 @@ def test_diff_prints_a_unified_diff(tmp_path: Path, capsys: pytest.CaptureFixtur 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("""\ @@ -63,7 +59,6 @@ def test_no_markers_suppresses_comment_insertion(tmp_path: Path) -> None: 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") @@ -73,21 +68,18 @@ def test_a_parse_failure_returns_a_nonzero_exit_and_is_reported_to_stderr( 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 @@ -106,7 +98,6 @@ def test_the_grep_hint_appears_only_when_there_are_markers(tmp_path: Path, capsy 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("""\ @@ -124,7 +115,6 @@ def next_page(result: ListToolsResult) -> str | None: 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 @@ -134,8 +124,6 @@ def test_an_unchanged_file_with_no_diagnostics_produces_no_per_file_line( 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 @@ -147,10 +135,8 @@ def test_diff_skips_files_the_codemod_did_not_change(tmp_path: Path, capsys: pyt 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. - """ + """With `--dry-run` no marker lands on disk, so the summary lists each site + directly instead of the grep hint; info-only renames are excluded.""" 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' @@ -170,9 +156,7 @@ def test_a_dry_run_lists_every_site_instead_of_the_grep_hint( 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. - """ + """Dependency files migrate in the same run and their flags join 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") @@ -190,8 +174,6 @@ def test_the_cli_updates_dependency_files_alongside_the_sources( 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)]) @@ -202,8 +184,6 @@ def test_a_broken_pyproject_fails_the_run_without_stopping_it( 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)]) diff --git a/tests/codemod/test_dependencies.py b/tests/codemod/test_dependencies.py index f2b1694019..0147131540 100644 --- a/tests/codemod/test_dependencies.py +++ b/tests/codemod/test_dependencies.py @@ -13,9 +13,7 @@ def _write(path: Path, content: str) -> 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. - """ + """Only the specifier changes; the rest of the file keeps its exact formatting.""" pyproject = _write( tmp_path / "pyproject.toml", """\ @@ -42,9 +40,6 @@ def test_a_v1_only_mcp_requirement_is_rewritten_to_the_v2_range(tmp_path: Path) 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", """\ @@ -61,9 +56,6 @@ def test_a_requirement_that_already_admits_v2_is_untouched(tmp_path: Path) -> No 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", """\ @@ -81,9 +73,6 @@ def test_extras_and_environment_markers_keep_their_original_spelling(tmp_path: P 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", """\ @@ -107,8 +96,7 @@ def test_a_requirement_with_a_removed_extra_is_marked_not_rewritten(tmp_path: Pa 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.""" + """An `include-group` table entry is passed over, not treated as a requirement.""" pyproject = _write( tmp_path / "pyproject.toml", """\ @@ -128,9 +116,7 @@ def test_optional_dependencies_and_dependency_groups_are_updated(tmp_path: Path) 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. - """ + """Poetry's own constraint syntax cannot be rewritten safely, so the entry is marked.""" pyproject = _write( tmp_path / "pyproject.toml", """\ @@ -152,9 +138,6 @@ def test_a_poetry_constraint_is_marked_for_a_hand_update(tmp_path: Path) -> None 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", """\ @@ -176,8 +159,6 @@ def test_requirements_txt_lines_are_rewritten_and_keep_their_comments(tmp_path: 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"] @@ -190,7 +171,6 @@ def test_a_requirements_line_with_a_removed_extra_is_marked(tmp_path: Path) -> N 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) @@ -202,7 +182,6 @@ def test_a_second_run_over_updated_files_is_a_noop(tmp_path: Path) -> None: 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) @@ -212,8 +191,6 @@ def test_an_unparseable_pyproject_is_reported_and_left_untouched(tmp_path: Path) 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) @@ -229,16 +206,14 @@ def test_dependency_files_inside_ignored_directories_are_skipped(tmp_path: Path) 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.""" + """Dependency files are discovered under directory arguments only.""" 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.""" + """An inline table leaves no line to place a marker on, but the diagnostic still fires.""" pyproject = _write(tmp_path / "pyproject.toml", '[tool.poetry]\ndependencies = { mcp = "^1.2" }\n') original = pyproject.read_text() reports = update_dependencies([tmp_path], write=True) @@ -247,9 +222,7 @@ def test_a_poetry_inline_dependency_table_still_gets_a_diagnostic(tmp_path: Path 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.""" + """A raw TOML spelling that differs from its parsed value cannot be safely rewritten.""" pyproject = _write(tmp_path / "pyproject.toml", '[project]\ndependencies = ["mcp \\u003c 2"]\n') original = pyproject.read_text() assert update_dependencies([tmp_path], write=True) == [] @@ -257,8 +230,6 @@ def test_a_requirement_hidden_behind_toml_escapes_is_left_alone(tmp_path: Path) 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", """\ @@ -274,8 +245,6 @@ def test_non_list_table_values_and_comment_lines_are_passed_over(tmp_path: Path) 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) @@ -285,8 +254,6 @@ def test_add_markers_false_reports_without_writing_comments(tmp_path: Path) -> N 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", """\ @@ -304,8 +271,7 @@ def test_constraints_already_on_v2_are_never_touched(tmp_path: Path) -> None: 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.""" + """On v2 the extra silently vanishes, so the extra check 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"] @@ -314,8 +280,7 @@ def test_a_removed_extra_is_flagged_even_when_the_specifier_admits_v2(tmp_path: 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.""" + """A VCS/URL reference has no specifier to rewrite but may pin v1 forever.""" 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"] @@ -323,8 +288,7 @@ def test_a_url_requirement_is_flagged_not_rewritten(tmp_path: Path) -> None: 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.""" + """Passing over an unparseable `mcp` line silently would hide a v1 pin.""" requirements = _write( tmp_path / "requirements.txt", "httpx==0.27.0\nmcp==1.9.4 --hash=sha256:abc123\n", @@ -337,8 +301,6 @@ def test_an_unparseable_mcp_line_is_flagged(tmp_path: Path) -> None: 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", """\ @@ -352,8 +314,6 @@ def test_a_poetry_group_dependency_is_marked(tmp_path: Path) -> None: 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", """\ @@ -373,8 +333,7 @@ def test_lookalike_strings_in_comments_and_other_tables_are_never_touched(tmp_pa 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.""" + """Nothing about an arbitrary-equality pin is provably v1-era.""" pyproject = _write(tmp_path / "pyproject.toml", '[project]\ndependencies = ["mcp===legacy1"]\n') original = pyproject.read_text() assert update_dependencies([tmp_path], write=True) == [] @@ -382,7 +341,6 @@ def test_an_arbitrary_equality_clause_is_left_alone(tmp_path: Path) -> None: 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", """\ @@ -398,8 +356,7 @@ def test_two_poetry_tables_each_get_a_diagnostic(tmp_path: Path) -> None: 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.""" + """Neither the rewrite nor the unparseable-line flag may fire on another distribution.""" 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 89a60aab21..73feeb0b34 100644 --- a/tests/codemod/test_mappings.py +++ b/tests/codemod/test_mappings.py @@ -1,10 +1,7 @@ """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. +Each table is pinned as an exact literal and checked against the installed +packages; a failure here means the table is wrong, not the transformer. """ import inspect @@ -15,10 +12,10 @@ import mcp_types import pytest from mcp_codemod import transform +from mcp_codemod._adapters import LOWLEVEL_HANDLER_SPECS from mcp_codemod._mappings import ( CAMEL_FIELDS, LOWLEVEL_CTOR_POSITIONAL_PARAMS, - LOWLEVEL_DECORATOR_METHODS, LOWLEVEL_REMOVED_ATTRS, MODULE_RENAMES, REHOMED_IMPORTS, @@ -50,7 +47,6 @@ def _v2_resolves(qualified: str) -> bool: 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", @@ -78,7 +74,6 @@ def test_the_symbol_rename_table_is_exact() -> None: @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] = {} @@ -87,7 +82,6 @@ def test_rewriting_an_import_of_each_renamed_symbol_resolves_on_v2(qualified: st 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", @@ -126,7 +120,6 @@ def test_every_removed_api_is_absent_from_the_installed_v2_package() -> None: 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 @@ -139,30 +132,22 @@ def test_every_camelcase_rename_target_is_a_field_on_an_installed_v2_model() -> 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. - """ + """`ProgressNotificationParams` renamed it to `progress_token`, but `RequestParams.Meta` + kept the camelCase wire spelling -- so an unconditional rename 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. - """ + """Flagging a keyword v2 kept would be a lie (`debug`, `log_level`, and `dependencies` + each survived one alpha or another). Landing spots are not asserted: `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) + # If v2 grew a v1 decorator name back as a live method, deleting the decorator would break code. + assert not set(LOWLEVEL_HANDLER_SPECS) & set(dir(Server)) -# 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. +# Every public top-level name of v1's `mcp/types.py`, frozen from `origin/v1.x`. _V1_TYPES_PUBLIC_NAMES = ( "Annotations", "AnyFunction", @@ -363,20 +348,14 @@ def test_the_constructor_keyword_tables_match_the_v2_signatures() -> None: 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. - """ + """Every public name of a renamed v1 module must import from the rename target, + or be in `SYMBOL_RENAMES` or `REMOVED_APIS`; anything else lets the codemod + emit an import that cannot resolve, with no diagnostic.""" 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. + # Only the names users import; the module's other definitions are internals. "mcp.server.fastmcp.server": ("FastMCP", "Context", "Settings"), "mcp.shared.version": ("LATEST_PROTOCOL_VERSION", "SUPPORTED_PROTOCOL_VERSIONS"), } @@ -393,12 +372,12 @@ def test_every_public_name_of_a_renamed_v1_module_is_importable_or_accounted_for 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"} + """`REMOVED_ATTRS` matches by name alone, so a name qualifies only if nothing + public on v2 still spells it -- `request_context` fails exactly this bar.""" + assert set(REMOVED_ATTRS) == {"get_context", "get_server_capabilities", "_mcp_server"} + # The private-name row: v2 really renamed the wrapped server, both spellings private. + assert not hasattr(MCPServer, "_mcp_server") + assert "_lowlevel_server" in vars(MCPServer("probe")) living = { name for module in (mcp, mcp.client.session, mcp.server.mcpserver, mcp_types) @@ -412,11 +391,8 @@ def test_no_removed_attribute_name_is_spelled_by_a_living_v2_api() -> None: 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. - """ + """Flagging a keyword v2 kept would be a lie; missing one v2 dropped is 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"} ) @@ -424,8 +400,7 @@ def test_the_removed_client_keyword_set_is_exactly_v1_minus_v2() -> None: 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. +# Every public module v1 shipped (no underscore path segment), frozen from `origin/v1.x`. _V1_PUBLIC_MODULES = ( "mcp", "mcp.cli", @@ -538,12 +513,8 @@ def test_the_removed_client_keyword_set_is_exactly_v1_minus_v2() -> None: 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). - """ + """An unaccounted module would mean an import the codemod neither fixes nor flags; + removed roots must really be gone from v2 and each must cover a v1 module.""" def covered_by(table: dict[str, str], module: str) -> bool: return any(module == root or module.startswith(f"{root}.") for root in table) @@ -562,42 +533,36 @@ def covered_by(table: dict[str, str], module: str) -> bool: 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. - """ + """Flagging an extra v2 kept would be a lie; missing one v2 dropped leaves a + constraint that cannot resolve. v1's set is frozen history.""" 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. - """ + """The target must declare the name in `__all__`, and the source must still hold + it, 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"} + """Each entry must be absent from the v2 `Server` yet spelled by some other + living API -- otherwise plain name-matched `REMOVED_ATTRS` is its cheaper home.""" + assert set(LOWLEVEL_REMOVED_ATTRS) == {"request_context", "request_handlers", "notification_handlers"} for name in LOWLEVEL_REMOVED_ATTRS: assert not hasattr(Server, name), name - assert hasattr(Context, name), name + # `request_context` survives on `Context` (the reason the table is receiver-gated); + # the handler dicts' replacement API must exist for their guidance to hold. + assert hasattr(Context, "request_context") + assert hasattr(Server, "add_request_handler") and hasattr(Server, "get_request_handler") + assert hasattr(Server, "add_notification_handler") def test_the_lowlevel_positional_params_are_keyword_only_on_the_installed_server() -> None: - """Every v1 positional the codemod converts must exist, keyword-only, on the - installed v2 `Server.__init__` -- otherwise the conversion emits a `TypeError`.""" + """The rewrite emits these as keywords, so each must exist under that name on v2.""" parameters = inspect.signature(Server.__init__).parameters for name in LOWLEVEL_CTOR_POSITIONAL_PARAMS: assert parameters[name].kind is inspect.Parameter.KEYWORD_ONLY diff --git a/tests/codemod/test_runner.py b/tests/codemod/test_runner.py index 1196e7dd54..355cc5f3dd 100644 --- a/tests/codemod/test_runner.py +++ b/tests/codemod/test_runner.py @@ -9,7 +9,6 @@ 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() @@ -20,7 +19,6 @@ def test_discover_yields_every_python_file_under_a_directory_sorted(tmp_path: Pa 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() @@ -39,7 +37,6 @@ def test_discover_honours_an_explicitly_named_file(tmp_path: Path) -> None: 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 @@ -66,7 +63,6 @@ def test_run_writes_only_the_files_that_changed(tmp_path: Path) -> None: 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 @@ -82,9 +78,6 @@ def test_a_dry_run_leaves_every_file_untouched(tmp_path: Path) -> None: 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) @@ -113,9 +106,7 @@ def test_a_file_that_fails_to_parse_is_left_untouched_and_reported(tmp_path: Pat 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. - """ + """Flag-only sites count as `manual` and heuristic rewrites as `review` in the summed counts.""" (tmp_path / "lowlevel.py").write_text( textwrap.dedent("""\ from mcp.server.lowlevel import Server @@ -123,6 +114,7 @@ def test_the_report_aggregates_diagnostic_counts_by_severity(tmp_path: Path) -> server = Server("demo") + @traced @server.list_tools() async def handle_list_tools(): return [] @@ -145,9 +137,7 @@ def cursor(result: ListResourcesResult) -> str | None: 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. - """ + """`FileReport.changed` is true only when the transform succeeded and produced different code.""" rewritten_path = tmp_path / "v1.py" rewritten_path.write_text("from mcp.types import Tool\n") untouched_source = "from mcp_types import Tool\n" @@ -167,9 +157,7 @@ def test_file_report_changed_is_false_for_an_untouched_file(tmp_path: Path) -> N 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. - """ + """A legal but non-UTF-8 file is recorded as failed and left as found, without aborting the run.""" good = tmp_path / "aaa.py" good.write_text("from mcp.server.fastmcp import FastMCP\n") weird = tmp_path / "bbb.py" @@ -185,9 +173,7 @@ def test_a_file_that_cannot_be_decoded_is_left_untouched_and_reported(tmp_path: 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. - """ + """A write failure is recorded as a write failure -- never a parse failure -- and the run continues.""" first = tmp_path / "aaa.py" first.write_text("from mcp.server.fastmcp import FastMCP\n") second = tmp_path / "bbb.py" diff --git a/tests/codemod/test_transformer.py b/tests/codemod/test_transformer.py index 3136d3a6f1..ff22106762 100644 --- a/tests/codemod/test_transformer.py +++ b/tests/codemod/test_transformer.py @@ -1,8 +1,6 @@ """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. +Properties that must not change are asserted byte-identical to the input; rewrites as exact v2 output. """ import textwrap @@ -20,8 +18,7 @@ def test_from_import_of_a_renamed_module_is_rewritten() -> None: 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.""" + """A submodule under a renamed package matches by longest prefix; 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") @@ -33,8 +30,7 @@ def test_plain_import_of_a_renamed_module_is_rewritten() -> None: 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.""" + """A dotted reference like `mcp.types.Tool` is rewritten together with the import that binds it.""" source = textwrap.dedent("""\ import mcp.types @@ -50,8 +46,7 @@ def test_dotted_usage_of_a_renamed_module_follows_its_import() -> None: 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.""" + """`import mcp.types as t` becomes `import mcp_types as t`; references through the alias are untouched.""" source = textwrap.dedent("""\ import mcp.types as t @@ -67,22 +62,19 @@ def test_an_aliased_module_import_keeps_the_local_name() -> None: 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.""" + """`from mcp import types` becomes `import mcp_types as types`, keeping 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.""" + """`from mcp import types as t` becomes `import mcp_types as t`.""" 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`.""" + """Only `types` is split out of a mixed `from mcp import`; the other names stay put.""" result = transform("from mcp import ClientSession, types\n") assert result.code == snapshot( """\ @@ -93,8 +85,7 @@ def test_types_is_split_off_from_other_imported_names() -> None: 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.""" + """A `from mcp import ...` that does not name `types` round-trips byte-identical.""" source = textwrap.dedent("""\ from mcp import ClientSession, StdioServerParameters @@ -105,16 +96,13 @@ def test_a_from_mcp_import_without_types_is_untouched() -> None: 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.""" + """`from mcp import *` names no specific binding, so there is nothing to split out.""" 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. - """ + """A relative import refers to the user's own package, never the SDK.""" source = textwrap.dedent("""\ from . import types from .types import Tool @@ -127,9 +115,7 @@ def make() -> Tool: 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. - """ + """Code already on v2 is a no-op: nothing is rewritten or reported.""" source = textwrap.dedent("""\ import mcp_types from mcp.server.mcpserver import MCPServer @@ -147,9 +133,7 @@ def greet(name: str) -> mcp_types.TextContent: 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. - """ + """Non-mcp imports and references are outside every rename table.""" source = textwrap.dedent("""\ import httpx from pydantic import BaseModel @@ -166,9 +150,7 @@ def fetch(settings: Settings) -> httpx.Response: 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. - """ + """The do-no-harm contract: a module that never mentions mcp comes back byte-identical.""" source = textwrap.dedent("""\ # Shared logging setup for the example application. @@ -186,9 +168,7 @@ def get_logger(name: str) -> logging.Logger: 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. - """ + """An mcp import path that did not move between v1 and v2 is not rewritten.""" source = textwrap.dedent("""\ from mcp.client.streamable_http import streamable_http_client from mcp.server.lowlevel import Server @@ -204,8 +184,7 @@ async def connect(url: str) -> None: 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.""" + """A `FastMCP` import rewrites the module path, the imported name, and every call site.""" source = textwrap.dedent("""\ from mcp.server.fastmcp import FastMCP @@ -219,8 +198,7 @@ def test_a_renamed_class_import_and_every_use_are_rewritten() -> None: 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.""" + """Only the imported name is renamed; the local alias and its uses are untouched.""" source = textwrap.dedent("""\ from mcp.server.fastmcp import FastMCP as F @@ -234,8 +212,7 @@ def test_an_aliased_import_of_a_renamed_symbol_keeps_the_local_alias() -> None: 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.""" + """A dotted use has only its final segment renamed; the import and module prefix are untouched.""" source = textwrap.dedent("""\ import mcp.shared.exceptions @@ -249,8 +226,7 @@ def test_a_fully_dotted_reference_to_a_renamed_symbol_is_rewritten() -> None: 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.""" + """The rename is keyed on the qualified name resolved through imports, never the bare token.""" source = textwrap.dedent("""\ class FastMCP: def __init__(self, name): @@ -263,8 +239,7 @@ def __init__(self, name): 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.""" + """`obj.FastMCP` and `FastMCP=` are name positions, not references, and keep the v1 spelling.""" source = textwrap.dedent("""\ from mcp.server.fastmcp import FastMCP @@ -284,9 +259,7 @@ def use(obj, g): 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. - """ + """A removed function keeps its v1 name and gains a manual diagnostic plus an inline marker.""" source = textwrap.dedent("""\ from mcp.shared.memory import create_connected_server_and_client_session @@ -302,10 +275,7 @@ async def main(server): 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. - """ + """A `websocket_client` use is flagged manual at the import and the call; only markers are inserted.""" source = textwrap.dedent("""\ from mcp.client.websocket import websocket_client @@ -329,9 +299,7 @@ async def main() -> None: 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. - """ + """A removed attribute is matched by name alone (receiver types are invisible), flagged, and kept.""" source = textwrap.dedent("""\ from mcp import ClientSession @@ -345,10 +313,8 @@ def capabilities(session: ClientSession) -> object: 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. - """ +def test_a_call_tool_decorator_site_is_rewritten_with_full_v1_dispatch() -> None: + """The adapter carries v1's whole dispatch: tool cache, input validation, and the isError contract.""" source = textwrap.dedent("""\ from mcp.server.lowlevel import Server @@ -360,17 +326,16 @@ 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 + assert "async def handle(name: str, arguments: dict):\n return []\n" in result.code + assert "_server_tool_cache" in result.code + assert "jsonschema.validate(instance=arguments" in result.code + assert 'server.add_request_handler("tools/call", mcp_types.CallToolRequestParams, _handle_handler)' in result.code + assert "import jsonschema" in result.code + assert "# mcp-codemod:" not 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. - """ + """Only the receiver's binding separates `@mcp.tool()` from a lowlevel decorator; it gets no flag.""" source = textwrap.dedent("""\ from mcp.server.fastmcp import FastMCP @@ -387,10 +352,7 @@ def add(a: int, b: int) -> int: 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. - """ + """A safe-tier camelCase read is renamed, reported as info, and never earns an inline marker.""" source = textwrap.dedent("""\ from mcp.types import CallToolResult @@ -411,10 +373,7 @@ def show(result: CallToolResult) -> None: 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. - """ + """A risky-tier camelCase rename is reported as review, with an inline marker above the site.""" source = textwrap.dedent("""\ from mcp import ClientSession @@ -438,10 +397,7 @@ async def page(session: ClientSession) -> None: 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. - """ + """The camelCase rename is gated on the file importing the SDK at all.""" source = textwrap.dedent("""\ import json @@ -453,10 +409,7 @@ def describe(result: object) -> str: 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. - """ + """Only allowlisted field names are ever considered, so stdlib and user camelCase APIs survive.""" source = textwrap.dedent("""\ import logging @@ -471,9 +424,7 @@ def configure(obj: object, level: int) -> None: 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. - """ + """String spellings outside `getattr`/`hasattr` are left alone: camelCase is the wire format.""" source = textwrap.dedent("""\ from mcp import ClientSession @@ -488,8 +439,7 @@ def wire(session: ClientSession, schema: object, d: dict[str, object]) -> object 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.""" + """camelCase keywords on a call that resolves into the SDK are renamed to snake_case.""" source = textwrap.dedent("""\ from mcp.types import Tool @@ -503,8 +453,7 @@ def test_camelcase_keywords_on_an_mcp_constructor_are_renamed() -> None: 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.""" + """The keyword rename fires only when the callee resolves into the SDK.""" source = textwrap.dedent("""\ import mcp @@ -519,8 +468,7 @@ def build(**fields: object) -> dict[str, object]: 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.""" + """A camelCase string in a `hasattr` call is renamed and reported as info, with no marker.""" source = textwrap.dedent("""\ from mcp import ClientSession @@ -542,8 +490,7 @@ def has_structured(result: object) -> bool: 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.""" + """A `getattr` string outside the camelCase allowlist is never rewritten.""" source = textwrap.dedent("""\ import mcp @@ -555,8 +502,7 @@ def tool_name(result: object) -> object: 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.""" + """The codemod only rewrites names it can read from the source; a variable argument is untouched.""" source = textwrap.dedent("""\ import mcp @@ -568,8 +514,7 @@ def field(result: object, key: str) -> object: def test_a_single_argument_mcperror_call_becomes_from_error_data() -> None: - """A v1 `McpError(...)` call took one `ErrorData`; v2's `MCPError.from_error_data(...)` - takes exactly that argument, so the call converts with the expression kept as written.""" + """A one-argument `McpError(...)` call converts to `MCPError.from_error_data(...)` as written.""" source = textwrap.dedent("""\ from mcp.shared.exceptions import McpError from mcp.types import ErrorData @@ -585,8 +530,7 @@ def test_a_single_argument_mcperror_call_becomes_from_error_data() -> None: def test_a_mcperror_call_with_a_non_inline_argument_is_rewritten_without_a_marker() -> None: - """`McpError(err)` needs no unpacking under `from_error_data`, so the once-marked - non-inline form is now just rewritten.""" + """`McpError(err)` needs no unpacking under `from_error_data`, so it is rewritten without a marker.""" source = textwrap.dedent("""\ from mcp.shared.exceptions import McpError @@ -599,8 +543,7 @@ def reraise(err): def test_a_dotted_mcperror_call_converts_on_its_full_spelling() -> None: - """The `from_error_data` conversion composes with the symbol rename when the - constructor is reached through its module path.""" + """The `from_error_data` conversion composes with the symbol rename on a dotted spelling.""" source = textwrap.dedent("""\ import mcp.shared.exceptions @@ -611,8 +554,7 @@ def test_a_dotted_mcperror_call_converts_on_its_full_spelling() -> None: def test_error_attribute_chains_on_a_caught_error_are_left_alone() -> None: - """`e.error.code` and friends still work on v2 (`MCPError.error` is a typed - `ErrorData`), so inside `except McpError as e:` only the exception name changes.""" + """`e.error.code` and friends still work on v2, so only the exception name changes.""" source = textwrap.dedent("""\ from mcp.shared.exceptions import McpError @@ -632,17 +574,13 @@ def test_error_attribute_chains_on_a_caught_error_are_left_alone() -> None: 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. - """ + """Unparseable source 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. - """ + """v2 no longer yields the third `get_session_id` value, so a 3-tuple `as` target narrows to two.""" source = textwrap.dedent("""\ from mcp.client.streamable_http import streamable_http_client @@ -664,9 +602,7 @@ async def main(url: str) -> None: 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. - """ + """Dropping a real name (not `_`) breaks later uses, so the narrowing also raises a manual diagnostic.""" source = textwrap.dedent("""\ from mcp.client.streamable_http import streamable_http_client @@ -683,9 +619,7 @@ async def main(url: str) -> None: 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. - """ + """Each removed client keyword gets its own manual diagnostic; none are silently deleted.""" source = textwrap.dedent("""\ from mcp.client.streamable_http import streamable_http_client @@ -704,9 +638,7 @@ async def main(url: str, h: dict[str, str], a: object) -> None: 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. - """ + """The alias renames at the import and the call, and the 3-tuple `as` target narrows in the same pass.""" source = textwrap.dedent("""\ from mcp.client.streamable_http import streamablehttp_client @@ -728,9 +660,7 @@ async def main(url: str) -> None: 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. - """ + """A two-element `as` tuple is already the v2 shape, so the module round-trips byte-identical.""" source = textwrap.dedent("""\ from mcp.client.streamable_http import streamable_http_client @@ -743,10 +673,7 @@ async def main(url: str) -> None: 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. - """ + """Only the 3-tuple `as` shape has a third element to drop; a single-name target is untouched.""" source = textwrap.dedent("""\ from mcp.client.streamable_http import streamable_http_client @@ -759,10 +686,7 @@ async def main(url: str) -> None: 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. - """ + """A with-item that is not an mcp transport client is never rewritten.""" source = textwrap.dedent("""\ import threading @@ -781,10 +705,7 @@ def main(path: str) -> None: 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. - """ + """The codemod refuses to act on a name it cannot resolve through an import.""" source = textwrap.dedent("""\ from mcp import ClientSession @@ -797,11 +718,7 @@ async def main(url: str) -> None: 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. - """ + """A transport keyword is flagged but never deleted: where it belongs on v2 depends on server startup.""" source = textwrap.dedent("""\ from mcp.server.fastmcp import FastMCP @@ -826,12 +743,8 @@ def test_a_removed_constructor_keyword_gets_a_marker() -> None: 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. - """ + """A keyword that still exists on the v2 `MCPServer` produces no diagnostic: a flag on a working + keyword is a lie the user cannot reconcile.""" source = textwrap.dedent("""\ from mcp.server.fastmcp import FastMCP @@ -841,9 +754,7 @@ def test_surviving_constructor_keywords_are_not_flagged() -> None: 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. - """ + """Running the codemod over its own output changes nothing.""" source = textwrap.dedent("""\ from mcp import McpError from mcp.types import Tool @@ -862,9 +773,7 @@ def describe(tool: Tool, server: object) -> object: 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. - """ + """A second run recognises an existing `# mcp-codemod:` comment and does not insert it again.""" source = textwrap.dedent("""\ from mcp.server.lowlevel import Server @@ -876,9 +785,7 @@ def test_a_marker_is_not_duplicated_on_a_second_run() -> None: 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. - """ + """With `add_markers=False` findings still appear in `diagnostics` but no comment is written.""" source = textwrap.dedent("""\ from mcp.server.fastmcp import FastMCP @@ -890,9 +797,7 @@ def test_add_markers_false_reports_without_inserting_comments() -> None: 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`. - """ + """The marker lands above the decorator line, not between the decorator and the `def`.""" source = textwrap.dedent("""\ from mcp.server.lowlevel import Server @@ -900,7 +805,7 @@ def test_a_marker_on_a_decorated_function_lands_above_the_decorators() -> None: @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, str]) -> list[str]: + def handle_call_tool(name: str, arguments: dict[str, str]) -> list[str]: return [name] """) lines = transform(source).code.splitlines() @@ -909,9 +814,7 @@ async def handle_call_tool(name: str, arguments: dict[str, str]) -> list[str]: 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. - """ + """An info diagnostic never earns a `# mcp-codemod` comment.""" source = textwrap.dedent("""\ from mcp.types import Tool @@ -926,38 +829,26 @@ def schema_of(tool: Tool) -> object: 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. - """ + """Only the innermost node naming the module is replaced, so the enclosing chain is not double-counted.""" 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. - """ + """`mcp` is the most common variable name in real MCP code; only a name bound by an import is rewritten.""" 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. - """ + """A semicolon-joined import cannot be split out, so the statement is left whole 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. - """ + """Keywords on a call through a local `mcp` variable are untouched when nothing imports the SDK.""" source = 'mcp = Router()\nmcp.register(inputSchema={"a": 1}, isError=False)\n' result = transform(source) assert result.code == source @@ -965,17 +856,13 @@ def test_camelcase_keywords_on_a_local_variable_named_mcp_are_untouched() -> Non 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. - """ + """The string form of the camelCase rename is gated on an SDK import, like the attribute form.""" 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. - """ + """A risky-tier rename inside a `getattr` string is marked for review, like the attribute form.""" source = 'import mcp\n\ncursor = getattr(result, "nextCursor", None)\n' result = transform(source) assert '"next_cursor"' in result.code @@ -983,9 +870,7 @@ def test_a_risky_camelcase_getattr_string_gets_a_review_marker() -> None: 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. - """ + """A file that never imports the SDK must never gain a removal marker.""" source = textwrap.dedent("""\ class DetailView(View): def render(self): @@ -997,10 +882,7 @@ def render(self): 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. - """ + """`import mcp.types` also bound `mcp`; the rewrite is marked when another reference still needs that binding.""" source = textwrap.dedent("""\ import httpx import mcp.types @@ -1016,9 +898,7 @@ def test_renaming_a_plain_import_still_needed_for_other_names_gets_a_review_mark 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. - """ + """When every reference through the import is itself rewritten, losing the `mcp` binding breaks nothing.""" 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' @@ -1026,9 +906,7 @@ def test_renaming_a_plain_import_whose_binding_nothing_else_needs_is_silent() -> 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. - """ + """Rewriting the usage would leave nothing importing `mcp_types`, so the site is marked instead.""" source = 'import mcp\n\ntool = mcp.types.Tool(name="x")\n' result = transform(source) assert "mcp.types.Tool" in result.code @@ -1038,17 +916,12 @@ def test_a_dotted_usage_through_a_bare_import_mcp_is_marked_not_rewritten() -> N 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. - """ + """`from mcp.server import fastmcp` 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. - """ + """Every v1 import spelling of the renamed class gets the same constructor keyword markers.""" source = textwrap.dedent("""\ from mcp.server import FastMCP @@ -1060,9 +933,7 @@ def test_constructor_flags_fire_for_every_import_path_of_the_renamed_class() -> 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. - """ + """A renamed class reached through a module alias rewrites at both the import and the access.""" source = textwrap.dedent("""\ import mcp.server.fastmcp as fm @@ -1078,10 +949,7 @@ def test_a_renamed_symbol_reached_through_a_module_alias_is_rewritten() -> None: 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. - """ + """A types name with no v2 home is marked, never silently rewritten into an import that cannot resolve.""" source = textwrap.dedent("""\ from mcp.types import Cursor, Tool @@ -1094,9 +962,7 @@ def test_an_import_of_a_types_name_with_no_v2_home_is_marked() -> None: 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. - """ + """A removed API spelled `module.symbol` gets the same marker as the bare imported name.""" source = textwrap.dedent("""\ from mcp.shared import memory @@ -1109,9 +975,7 @@ def test_a_removed_api_reached_through_its_module_is_marked() -> None: 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. - """ + """Only the full path is rewritten; its renamed prefix must not also 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" @@ -1119,9 +983,7 @@ def test_a_plain_import_of_a_deeper_renamed_module_is_not_double_flagged() -> No 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. - """ + """Client keyword and yield-shape markers fire even when the call is not itself the `with` item.""" source = textwrap.dedent("""\ from mcp.client.streamable_http import streamablehttp_client @@ -1135,9 +997,7 @@ async def connect(stack, url): 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. - """ + """A call through the v2 name proves nothing about v1 surroundings, so no yield-shape marker.""" source = textwrap.dedent("""\ from mcp.client.streamable_http import streamable_http_client @@ -1151,9 +1011,7 @@ async def connect(stack, url): 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. - """ + """Identical findings on one statement collapse into one comment but stay separate diagnostics.""" source = "import mcp\n\nflag = a.isError or b.isError\n" result = transform(source) assert result.code.count("# mcp-codemod:") == 1 @@ -1161,9 +1019,7 @@ def test_two_identical_findings_on_one_statement_produce_one_marker() -> None: 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. - """ + """A single-name `as` target hides the unpacking, so the call gets the yield-shape marker.""" source = textwrap.dedent("""\ from mcp.client.streamable_http import streamablehttp_client @@ -1178,9 +1034,7 @@ async def connect(url): 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. - """ + """An annotated assignment binds the server exactly like the un-annotated form.""" source = textwrap.dedent("""\ from mcp.server.lowlevel import Server @@ -1192,14 +1046,12 @@ 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 + assert result.rewrites["lowlevel_registration"] == 1 + assert "# mcp-codemod:" not in result.code 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`. - """ + """`import mcp_types` is as much the SDK as `import mcp` for gating the attribute renames.""" source = textwrap.dedent("""\ import mcp_types @@ -1211,10 +1063,7 @@ def show(result: mcp_types.CallToolResult) -> None: 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. - """ + """A name-only match cannot tell the removed `Server.request_context` from the live idiom; neither is flagged.""" source = textwrap.dedent("""\ from mcp.server.fastmcp import Context, FastMCP @@ -1228,18 +1077,14 @@ async def query(ctx: Context) -> object: 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. - """ + """The whole-statement rewrite keeps the 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. - """ + """A comment above the first statement parses into the module header; the dedup must look there too.""" source = "# Application entrypoint.\nfrom mcp.client.websocket import websocket_client\n" once = transform(source).code assert once.count("# mcp-codemod:") == 1 @@ -1254,10 +1099,7 @@ def test_an_empty_module_is_returned_unchanged() -> None: 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. - """ + """v1's second positional was `instructions`, v2's is `title`; leaving it would silently swap meaning.""" source = textwrap.dedent("""\ from mcp.server.fastmcp import FastMCP @@ -1270,10 +1112,7 @@ def test_positional_constructor_arguments_after_the_name_are_flagged() -> None: 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. - """ + """Renaming uses of a camelCase field that a class in this file also declares would break that class.""" source = textwrap.dedent("""\ from pydantic import BaseModel @@ -1295,10 +1134,7 @@ def show(row: Row) -> None: 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. - """ + """A subclass `super().__init__(ErrorData(...))` gets the same flatten as a direct `McpError` call.""" source = textwrap.dedent("""\ from mcp import McpError from mcp.types import INVALID_PARAMS, ErrorData @@ -1314,9 +1150,7 @@ def __init__(self, message: str) -> None: 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. - """ + """A variable argument cannot be unpacked, so the site is marked rather than left to fail at raise time.""" source = textwrap.dedent("""\ from mcp import McpError @@ -1331,9 +1165,7 @@ def __init__(self, err) -> None: 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. - """ + """The qualified-name check sees the whole dotted path to a removed nested class.""" source = textwrap.dedent("""\ from mcp.types import RequestParams @@ -1346,10 +1178,7 @@ 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 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. - """ + """Module-level names stay on the v2 submodule; `Context` alone is rehomed to the package.""" source = "from mcp.server.fastmcp.server import Context, Settings\n" assert transform(source).code == snapshot( """\ @@ -1360,9 +1189,7 @@ def test_the_server_submodule_import_targets_the_v2_submodule() -> None: 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. - """ + """A receiver the imports prove is another package is never name-matched.""" source = textwrap.dedent("""\ import multiprocessing @@ -1376,9 +1203,7 @@ def test_a_resolvable_non_mcp_receiver_is_never_flagged() -> None: 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. - """ + """Another surviving plain `mcp.` import keeps the root bound, so no review marker is added.""" source = textwrap.dedent("""\ import mcp.client.session import mcp.types @@ -1393,9 +1218,7 @@ def test_no_unbind_marker_when_another_import_keeps_the_root_bound() -> None: 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. - """ + """An import of a deleted module is kept 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 @@ -1404,9 +1227,7 @@ def test_an_import_of_a_removed_module_is_marked_and_kept() -> None: 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. - """ + """One 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 @@ -1415,9 +1236,7 @@ def test_a_from_import_out_of_a_removed_namespace_gets_one_marker() -> None: 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. - """ + """The per-name check resolves a module bound through its parent against the removed roots.""" source = "from mcp.client import websocket\n" result = transform(source) assert result.code.count("# mcp-codemod:") == 1 @@ -1425,10 +1244,7 @@ def test_a_removed_module_imported_from_its_parent_package_is_marked() -> None: 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. - """ + """Importing `Context` from `server.py` would be private usage on v2, so it is split out to the package.""" source = "from mcp.server.fastmcp.server import Context, FastMCP, Settings\n" assert transform(source).code == snapshot( """\ @@ -1445,11 +1261,7 @@ def test_a_rehomed_import_keeps_its_alias_and_takes_the_statement_over_when_alon 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). - """ + """Only a receiver the pre-pass proved holds a lowlevel `Server` is flagged, sparing the live v2 idiom.""" source = textwrap.dedent("""\ from mcp.server.lowlevel import Server @@ -1467,8 +1279,7 @@ async def progress(token: str) -> None: 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.""" + """An attribute binding gets the same treatment as a plain name binding.""" source = textwrap.dedent("""\ from mcp.server.lowlevel import Server @@ -1486,8 +1297,7 @@ def current(self) -> object: 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.""" + """A flag on an import that is also being split lands above the split's first piece.""" result = transform("from mcp.server import websocket, fastmcp\n") assert result.code == snapshot( """\ @@ -1499,8 +1309,7 @@ def test_a_marker_survives_a_statement_split() -> None: 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.""" + """A tuple target has no single dotted spelling to track, so the pre-pass records nothing.""" source = textwrap.dedent("""\ from mcp.server.lowlevel import Server @@ -1512,8 +1321,7 @@ def test_a_tuple_assignment_involving_a_server_call_is_passed_over() -> None: 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.""" + """An unpacked call result has no single dotted spelling to track, so the pre-pass records nothing.""" source = textwrap.dedent("""\ from mcp.server.lowlevel import Server @@ -1525,8 +1333,7 @@ def test_unpacking_a_call_result_is_passed_over() -> None: def test_lowlevel_server_positional_arguments_become_keywords() -> None: - """v2 makes everything after `name` keyword-only on the lowlevel `Server` but keeps - v1's parameter names and order, so positionals convert one for one.""" + """v2 keeps v1's parameter names and order but makes them keyword-only, so positionals convert one for one.""" source = textwrap.dedent("""\ from mcp.server.lowlevel import Server @@ -1538,8 +1345,7 @@ def test_lowlevel_server_positional_arguments_become_keywords() -> None: def test_a_lowlevel_server_call_with_a_splat_is_left_for_v2_to_reject() -> None: - """A `*`-splat hides how many positions it fills, so the call is left as written -- - v2 raises a TypeError at construction, which is loud and immediate.""" + """A `*`-splat hides how many positions it fills; v2's own TypeError at construction is loud enough.""" source = textwrap.dedent("""\ from mcp.server.lowlevel import Server @@ -1556,3 +1362,623 @@ def test_lowlevel_keyword_arguments_are_never_touched() -> None: server = Server("srv", version="1.2.0") """) assert transform(source).code == source + + +def test_a_module_level_decorator_site_is_rewritten_to_registration_at_site() -> None: + """The user's function survives byte-identical; the adapter and registration land at the decorator's position.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + import mcp.types as types + + app = Server("demo") + + @app.list_prompts() + async def list_prompts() -> list[types.Prompt]: + return [] + + run(app) + """) + result = transform(source) + assert result.code == snapshot("""\ +from typing import cast +from mcp.server import ServerRequestContext +import mcp_types +from mcp.server.lowlevel import Server +import mcp_types as types + +app = Server("demo") + +async def list_prompts() -> list[types.Prompt]: + return [] + + +async def _list_prompts_handler( + ctx: ServerRequestContext, params: mcp_types.PaginatedRequestParams +) -> mcp_types.ListPromptsResult: + result = cast("object", await list_prompts()) + if isinstance(result, mcp_types.ListPromptsResult): + return result + return mcp_types.ListPromptsResult(prompts=cast("list[mcp_types.Prompt]", result)) + + +app.add_request_handler("prompts/list", mcp_types.PaginatedRequestParams, _list_prompts_handler) + +run(app) +""") + assert result.rewrites["lowlevel_registration"] == 1 + assert [d.severity for d in result.diagnostics] == ["info"] + + +def test_a_decorator_nested_inside_a_function_is_rewritten_in_place() -> None: + """v1 servers built inside `main()` register at the same nesting depth.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + def main(): + app = Server("demo") + + @app.set_logging_level() + async def set_level(level) -> None: + configure(level) + + return app + """) + result = transform(source) + assert ( + ' app.add_request_handler("logging/setLevel", mcp_types.SetLevelRequestParams, _set_level_handler)' + in result.code + ) + assert " async def _set_level_handler(" in result.code + assert result.code.count("# mcp-codemod:") == 0 + + +def test_a_stacked_decorator_blocks_the_rewrite_with_a_marker() -> None: + """A second decorator changes what the module name binds, so the site is marked.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @observed + @app.list_tools() + async def list_tools(): + return [] + """) + result = transform(source) + assert "@observed" in result.code + assert "another decorator is stacked on it" in result.diagnostics[0].message + assert "add_request_handler" in result.diagnostics[0].message + + +def test_an_attribute_receiver_blocks_the_rewrite_with_a_marker() -> None: + """The emitted module-level adapter cannot close over `self`, so the site is marked.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + class Wrapper: + def __init__(self): + self.server = Server("demo") + + @self.server.list_tools() + async def list_tools(): + return [] + """) + result = transform(source) + assert "@self.server.list_tools()" in result.code + assert "the server is reached through an attribute" in result.diagnostics[0].message + + +def test_a_wrong_arity_handler_blocks_the_rewrite_with_a_marker() -> None: + """A handler signature that is not v1's old style is not guessed at.""" + source = textwrap.dedent("""\ + import mcp.types as types + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.list_tools() + async def list_tools(req: types.ListToolsRequest) -> types.ListToolsResult: + return types.ListToolsResult(tools=[]) + """) + result = transform(source) + assert "the handler signature does not match the v1 form" in result.diagnostics[0].message + + +def test_a_sync_handler_blocks_the_rewrite_with_a_marker() -> None: + """v1 lowlevel handlers were async; a sync def is not a shape the adapter can call.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.list_tools() + def list_tools(): + return [] + """) + result = transform(source) + assert "the handler is not `async def`" in result.diagnostics[0].message + + +def test_a_non_literal_decorator_argument_blocks_the_rewrite() -> None: + """`@app.call_tool(validate_input=flag)` cannot be evaluated statically.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.call_tool(validate_input=flag) + async def call_tool(name, arguments): + return [] + """) + result = transform(source) + assert "arguments the codemod cannot evaluate" in result.diagnostics[0].message + + +def test_a_taken_generated_name_blocks_the_rewrite() -> None: + """The adapter's module-level name must not shadow existing user code.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + _list_tools_handler = object() + + @app.list_tools() + async def list_tools(): + return [] + """) + result = transform(source) + assert "a generated name is already bound in this file" in result.diagnostics[0].message + + +def test_validate_input_false_omits_only_the_input_validation() -> None: + """Only the input-validation block is dropped; v1 validated output regardless of the flag.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.call_tool(validate_input=False) + async def call_tool(name, arguments): + return [] + """) + result = transform(source) + assert "instance=arguments" not in result.code + assert "output_schema" in result.code + assert "_app_tool_cache" in result.code + + +def test_adapter_imports_are_not_injected_when_already_bound() -> None: + """A file that already imports `json` and `mcp_types` gets neither again.""" + source = textwrap.dedent("""\ + import json + import mcp_types + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.call_tool() + async def call_tool(name, arguments): + return [json.dumps(arguments)] + """) + result = transform(source) + assert result.code.count("import json\n") == 1 + assert result.code.count("import mcp_types") == 1 + + +def test_an_inline_timedelta_timeout_converts_to_seconds() -> None: + """An inline `timedelta` timeout converts to seconds; on v2 the `timedelta` form fails on first request.""" + source = textwrap.dedent("""\ + from datetime import timedelta + from mcp import ClientSession + + session = ClientSession(read, write, read_timeout_seconds=timedelta(seconds=5)) + """) + result = transform(source) + assert "read_timeout_seconds=timedelta(seconds=5).total_seconds()" in result.code + assert [d.severity for d in result.diagnostics] == ["info"] + + +def test_a_positional_timeout_variable_is_marked_not_guessed() -> None: + """A variable in v1's `timedelta` position cannot be proven convertible, so it gets a marker.""" + source = textwrap.dedent("""\ + from mcp import ClientSession + + session = ClientSession(read, write, timeout) + """) + result = transform(source) + assert "session = ClientSession(read, write, timeout)" in result.code + assert "pass this value's `.total_seconds()`" in result.diagnostics[0].message + + +def test_a_none_timeout_is_left_alone() -> None: + """`None` is valid on both v1 and v2; nothing fires.""" + source = textwrap.dedent("""\ + from mcp import ClientSession + + session = ClientSession(read, write, None) + """) + result = transform(source) + assert result.diagnostics == [] + + +def test_a_cursor_keyword_on_an_annotated_session_wraps_into_params() -> None: + """`cursor=` becomes the v2 `params=` form when the receiver is proven a `ClientSession`.""" + source = textwrap.dedent("""\ + from mcp import ClientSession + + async def load(session: ClientSession): + return await session.list_tools(cursor=token) + """) + result = transform(source) + assert "session.list_tools(params=mcp_types.PaginatedRequestParams(cursor=token))" in result.code + assert "import mcp_types" in result.code + + +def test_a_url_wrapper_into_a_proven_session_read_is_unwrapped() -> None: + """The `AnyUrl` wrapper is dropped when the receiver is a with-bound `ClientSession`.""" + source = textwrap.dedent("""\ + from pydantic import AnyUrl + from mcp import ClientSession + + async def read(streams): + async with ClientSession(streams[0], streams[1]) as session: + return await session.read_resource(AnyUrl("demo://x")) + """) + result = transform(source) + assert 'session.read_resource("demo://x")' in result.code + + +def test_a_url_wrapper_in_a_sdk_uri_keyword_is_unwrapped() -> None: + """The wrapper is dropped on a callee that provably resolves into the SDK.""" + source = textwrap.dedent("""\ + import mcp.types as types + from pydantic import AnyUrl + + resource = types.Resource(uri=AnyUrl(f"file://{path}"), name="n") + """) + result = transform(source) + assert 'resource = types.Resource(uri=f"file://{path}", name="n")' in result.code + + +def test_a_url_wrapper_in_an_unproven_uri_keyword_is_marked() -> None: + """On an unresolvable callee the value may still land in an mcp model, so mark rather than rewrite.""" + source = textwrap.dedent("""\ + import mcp + from pydantic import AnyUrl + + notify(uri=AnyUrl("demo://x"), audience="all") + """) + result = transform(source) + assert 'notify(uri=AnyUrl("demo://x"), audience="all")' in result.code + assert "drop this URL wrapper" in result.diagnostics[0].message + + +def test_the_private_mcp_server_attribute_is_marked() -> None: + """The marker names the v2 spelling of v1's widely-used private attribute.""" + source = textwrap.dedent("""\ + from mcp.server.mcpserver import MCPServer + + mcp = MCPServer("demo") + server = mcp._mcp_server + """) + result = transform(source) + assert "_lowlevel_server" in result.diagnostics[0].message + + +def test_the_handler_dicts_on_a_proven_lowlevel_server_are_marked() -> None: + """Handler-dict introspection has no mechanical rewrite; the marker names the v2 lookup API.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + handler = app.request_handlers[CallToolRequest] + """) + result = transform(source) + assert "get_request_handler(method)" in result.diagnostics[0].message + + +def test_a_class_body_handler_blocks_the_rewrite() -> None: + """A decorated method in a class body cannot take a module-level adapter.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + class Handlers: + @app.list_tools() + async def list_tools(self): + return [] + """) + result = transform(source) + assert "the handler is defined in a class body" in result.diagnostics[0].message + + +def test_a_decorator_argument_on_a_non_call_tool_kind_blocks_the_rewrite() -> None: + """Only `call_tool` ever took a decorator argument on v1; anything else is not a known shape.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.list_tools("extra") + async def list_tools(): + return [] + """) + result = transform(source) + assert "arguments the codemod cannot evaluate" in result.diagnostics[0].message + + +def test_a_star_kwargs_handler_blocks_the_rewrite() -> None: + """`**kwargs` hides the real signature, so the site is marked.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.get_prompt() + async def get_prompt(name, arguments, **kwargs): + return None + """) + result = transform(source) + assert "the handler signature does not match the v1 form" in result.diagnostics[0].message + + +def test_a_single_positional_argument_to_a_session_list_method_is_left_alone() -> None: + """Only the exact v1 `cursor=` keyword form is wrapped.""" + source = textwrap.dedent("""\ + from mcp import ClientSession + + async def load(session: ClientSession): + return await session.list_tools(token) + """) + result = transform(source) + assert "session.list_tools(token)" in result.code + assert result.diagnostics == [] + + +def test_a_plain_string_uri_to_a_session_read_is_left_alone() -> None: + """`session.read_resource("demo://x")` is already the v2 shape.""" + source = textwrap.dedent("""\ + from mcp import ClientSession + + async def read(session: ClientSession): + return await session.read_resource("demo://x") + """) + result = transform(source) + assert 'session.read_resource("demo://x")' in result.code + assert result.diagnostics == [] + + +def test_a_url_wrapper_in_a_file_without_sdk_imports_is_never_touched() -> None: + """Without an SDK import the value cannot land in an mcp type; no marker, no rewrite.""" + source = textwrap.dedent("""\ + from pydantic import AnyUrl + + notify(uri=AnyUrl("demo://x"), audience="all") + """) + result = transform(source) + assert result.code == source + assert result.diagnostics == [] + + +def test_constructing_a_union_alias_is_marked() -> None: + """`JSONRPCMessage(...)` imports on v2 but is a plain union: calling it fails.""" + source = textwrap.dedent("""\ + from mcp.types import JSONRPCMessage + + message = JSONRPCMessage(payload) + """) + result = transform(source) + assert "cannot be constructed" in result.diagnostics[0].message + + +def test_a_pydantic_method_on_a_union_alias_is_marked() -> None: + """`JSONRPCMessage.model_validate_json(...)` has no pydantic methods on v2.""" + source = textwrap.dedent("""\ + import mcp.types as types + + message = types.JSONRPCMessage.model_validate_json(raw) + """) + result = transform(source) + assert any("pydantic.TypeAdapter(JSONRPCMessage)" in d.message for d in result.diagnostics) + + +def test_a_str_annotated_uri_handler_gets_the_wire_string() -> None: + """A handler declaring `uri: str` gets the wire string passed through, with no `AnyUrl` import.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.read_resource() + async def read_resource(uri: str) -> str: + return uri + """) + result = transform(source) + assert "await read_resource(params.uri)" in result.code + assert "AnyUrl" not in result.code + + +def test_an_unannotated_uri_handler_keeps_v1_anyurl_parity() -> None: + """Without a `str` annotation the adapter passes `AnyUrl(params.uri)`, exactly what v1 passed.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.subscribe_resource() + async def subscribe(uri): + record(uri) + """) + result = transform(source) + assert "await subscribe(AnyUrl(params.uri))" in result.code + assert "from pydantic import AnyUrl" in result.code + + +def test_a_model_method_on_a_non_alias_receiver_is_not_marked() -> None: + """`model_validate` on a concrete model (or anything else) is live v2 API.""" + source = textwrap.dedent("""\ + from mcp.types import Tool + + tool = Tool.model_validate(payload) + own = config.model_dump() + """) + result = transform(source) + assert not any(d.transform == "union_alias" for d in result.diagnostics) + + +def test_imports_inject_at_the_top_even_with_a_late_import() -> None: + """A mid-file import must not anchor injection below the registration code.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.list_tools() + async def list_tools(): + return [] + + import late_helper + """) + result = transform(source) + lines = result.code.splitlines() + assert lines.index("import mcp_types") < lines.index('app = Server("demo")') + + +def test_a_docstring_and_future_import_stay_first() -> None: + """Injected imports respect the docstring and `__future__` position rules.""" + source = textwrap.dedent('''\ + """Module docs.""" + + from __future__ import annotations + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.get_prompt() + async def get_prompt(name, arguments): + return None + ''') + result = transform(source) + lines = result.code.splitlines() + assert lines[0] == '"""Module docs."""' + assert lines.index("from __future__ import annotations") < lines.index("import mcp_types") + + +def test_a_conditional_import_does_not_suppress_injection() -> None: + """A TYPE_CHECKING-gated import does not bind at runtime, so the adapter's + import is still injected at module level.""" + source = textwrap.dedent("""\ + from typing import TYPE_CHECKING + from mcp.server.lowlevel import Server + + if TYPE_CHECKING: + from collections.abc import Iterable + + app = Server("demo") + + @app.call_tool() + async def call_tool(name, arguments): + return [] + """) + result = transform(source) + top_level = [line for line in result.code.splitlines() if line.startswith("from collections.abc")] + assert top_level == ["from collections.abc import Iterable"] + + +def test_a_module_binding_of_an_adapter_import_name_blocks_the_rewrite() -> None: + """`json = None` at module level would shadow the injected import inside the + adapter, so the site is marked instead of silently broken.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + json = None + app = Server("demo") + + @app.call_tool() + async def call_tool(name, arguments): + return [] + """) + result = transform(source) + assert "a name the generated adapter needs is already bound" in result.diagnostics[0].message + + +def test_a_handler_named_like_a_template_local_blocks_the_rewrite() -> None: + """A handler called `completion` would be shadowed by the adapter's own local.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.completion() + async def completion(ref, argument, context): + return None + """) + result = transform(source) + assert "collides with a name the generated adapter uses" in result.diagnostics[0].message + + +def test_a_blocked_progress_site_names_the_notification_api() -> None: + """Progress is a notification; the guidance must not send users to the + request-handler API where the handler would never fire.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.progress_notification() + def on_progress(token, progress, total, message): + pass + """) + result = transform(source) + assert "add_notification_handler" in result.diagnostics[0].message + + +def test_a_list_handler_returning_the_full_result_passes_through() -> None: + """v1's wrapper isinstance-passed a returned result model through; the adapter + must not double-wrap it.""" + source = textwrap.dedent("""\ + import mcp.types as types + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.list_tools() + async def list_tools(): + return types.ListToolsResult(tools=[]) + """) + result = transform(source) + assert "if isinstance(result, mcp_types.ListToolsResult):" in result.code + assert "return result" in result.code + + +def test_the_timeout_rewrite_is_idempotent_and_floats_are_untouched() -> None: + """A second run over `.total_seconds()` output and a plain float timeout both + produce nothing -- no rewrite, no marker.""" + source = textwrap.dedent("""\ + from datetime import timedelta + from mcp import ClientSession + + a = ClientSession(read, write, read_timeout_seconds=timedelta(seconds=5)) + b = ClientSession(read, write, 30.0) + """) + once = transform(source) + assert "timedelta(seconds=5).total_seconds()" in once.code + assert not any(d.severity == "manual" for d in once.diagnostics) + again = transform(once.code) + assert again.code == once.code + assert again.diagnostics == [] + + +def test_no_injection_happens_when_everything_needed_is_bound() -> None: + """A rewrite that needs only `mcp_types` injects nothing into a file that + already imports it.""" + source = textwrap.dedent("""\ + import mcp_types + from mcp import ClientSession + + async def load(session: ClientSession): + return await session.list_tools(cursor=token) + """) + result = transform(source) + assert result.code.count("import mcp_types") == 1