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