diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 41b127f92..f278dc066 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 9b9971ec3..59967a07f 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 42d420bf0..c3426b7be 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 22ba4d4f4..5647a876b 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 000000000..84248fe78 --- /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 000000000..3ad6a6ccc --- /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 000000000..638241156 --- /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 000000000..4e71a777e --- /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 000000000..220d20a33 --- /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 000000000..856e41e1b --- /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 000000000..e69de29bb diff --git a/src/mcp-codemod/pyproject.toml b/src/mcp-codemod/pyproject.toml new file mode 100644 index 000000000..4c75dcff6 --- /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 000000000..e69de29bb diff --git a/tests/codemod/test_cli.py b/tests/codemod/test_cli.py new file mode 100644 index 000000000..36f46258d --- /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 000000000..34911c3cd --- /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 000000000..1196e7dd5 --- /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 000000000..2a846c03a --- /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 a1e8a7e35..40685a2ea 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"