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.md b/README.md index 0c0876bb6..195922b65 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ > > **v1.x is the only stable release line and remains recommended for production.** It lives on the [`v1.x` branch](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x) and continues to receive critical bug fixes and security patches; see [the v1.x README](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/README.md) for its documentation. `pip` and `uv` don't select a pre-release unless you explicitly request one, so existing installs are unaffected. **If your package depends on `mcp`, add a `<2` upper bound to your version constraint (for example `mcp>=1.27,<2`) before the stable release lands.** > -> v2 is a major rework of the SDK, both to support the [2026-07-28 MCP specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) and to fix long-standing architectural issues. See the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/) for what's changed. Stable v2 is targeted for 2026-07-27, alongside the spec release. Try the pre-releases and [tell us what breaks](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) — or discuss in [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX). +> v2 is a major rework of the SDK, both to support the [2026-07-28 MCP specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) and to fix long-standing architectural issues. See the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/) for what's changed; `uvx mcp-codemod v1-to-v2 ./src` automates the mechanical half of it and marks the rest with `# mcp-codemod:` comments. Stable v2 is targeted for 2026-07-27, alongside the spec release. Try the pre-releases and [tell us what breaks](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) — or discuss in [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX). ## Documentation diff --git a/docs/migration.md b/docs/migration.md index a671ea493..e05c76ca3 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, the camelCase to snake_case field renames, the lowlevel `@server.*()` decorator registrations (through generated adapters that keep your handler bodies untouched), and the `mcp` requirement in `pyproject.toml` / `requirements*.txt` -- and inserts a `# mcp-codemod:` comment above every site it recognized but would not guess at. Run it on a clean branch first, then work through what it marked: + +```bash +uvx mcp-codemod v1-to-v2 ./src +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` @@ -48,6 +61,13 @@ the raised `code`, `message`, and `data` intact. Previously the tool wrapper caught it like any other exception and returned `CallToolResult(isError=True)`, which discarded the error code and structured `data`. +The same applies to `@mcp.prompt()` and resource-template functions: an +`MCPError` raised there propagates verbatim as the JSON-RPC error. On v1 +those wrappers converted it into a generic rendering error (for prompts, a +`ValueError("Error rendering prompt ...")`), so a client matching on the +error code or message will see the raised values instead of the wrapped +generic ones. + `MCPError` carries `ErrorData` and is the SDK's protocol-error type — raise it when the request itself should be rejected (missing client capability, elicitation required, invalid parameters). For tool *execution* failures the @@ -537,9 +557,14 @@ from mcp.shared.exceptions import MCPError try: result = await session.call_tool("my_tool") except MCPError as e: - print(f"Error: {e.message}") + print(f"Error: {e.error.message}") ``` +Only the exception's name changes: `MCPError.error` still carries the full +`ErrorData`, so an existing handler body keeps working as written. `e.code`, +`e.message`, and `e.data` also exist as direct read-only properties if you +prefer the shorter spelling. + `MCPError` is also exported from the top-level `mcp` package: ```python @@ -950,6 +975,17 @@ async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestPar server = Server("my-server", on_call_tool=handle_call_tool) ``` +Registration does not have to move to the constructor: `add_request_handler` +(see [below](#lowlevel-server-add_request_handler-is-now-public-and-takes-params_type)) +registers the same handler into the same registry at any point before `run()`, +which preserves your module's statement order — `mcp-codemod` migrates decorator +registrations this way for exactly that reason: + +```python +server = Server("my-server") +server.add_request_handler("tools/call", CallToolRequestParams, handle_call_tool) +``` + ### `RequestContext` type parameters simplified The `mcp.shared.context` module has been removed. `RequestContext` is now split into `ClientRequestContext` (in `mcp.client.context`) and `ServerRequestContext` (in `mcp.server.context`). diff --git a/pyproject.toml b/pyproject.toml index 7b947588f..b73546ee7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,9 @@ build-constraint-dependencies = [ dev = [ # We add mcp[cli] so `uv sync` considers the extras. "mcp[cli]", + # The codemod is a standalone tool, not a dependency of `mcp`; pull it in here + # so the workspace's test environment has it. + "mcp-codemod", "mcp-example-stories", "tomli>=2.0; python_version < '3.11'", "pyright>=1.1.400", @@ -135,6 +138,7 @@ packages = ["src/mcp"] typeCheckingMode = "strict" include = [ "src/mcp", + "src/mcp-codemod/mcp_codemod", "src/mcp-types/mcp_types", "tests", "docs_src", @@ -212,10 +216,18 @@ max-returns = 13 # Default is 6 max-statements = 102 # Default is 50 [tool.uv.workspace] -members = ["src/mcp-types", "examples", "examples/clients/*", "examples/servers/*", "examples/snippets"] +members = [ + "src/mcp-codemod", + "src/mcp-types", + "examples", + "examples/clients/*", + "examples/servers/*", + "examples/snippets", +] [tool.uv.sources] mcp = { workspace = true } +mcp-codemod = { workspace = true } mcp-example-stories = { workspace = true } mcp-types = { workspace = true } strict-no-cover = { git = "https://github.com/pydantic/strict-no-cover" } @@ -264,7 +276,7 @@ MD059 = false # descriptive-link-text branch = true patch = ["subprocess"] concurrency = ["multiprocessing", "thread"] -source = ["src", "src/mcp-types/mcp_types", "tests"] +source = ["src", "src/mcp-codemod/mcp_codemod", "src/mcp-types/mcp_types", "tests"] omit = [ "src/mcp/client/__main__.py", "src/mcp/server/__main__.py", diff --git a/scripts/codemod-batch-test/.gitignore b/scripts/codemod-batch-test/.gitignore new file mode 100644 index 000000000..d22ffb1ff --- /dev/null +++ b/scripts/codemod-batch-test/.gitignore @@ -0,0 +1,2 @@ +work/ +work/ diff --git a/scripts/codemod-batch-test/README.md b/scripts/codemod-batch-test/README.md new file mode 100644 index 000000000..9ad234250 --- /dev/null +++ b/scripts/codemod-batch-test/README.md @@ -0,0 +1,42 @@ +# Codemod batch test + +Runs the `mcp-codemod` v1 -> v2 migration against real, pinned repositories and +audits the result with pyright, to find silent misses the unit tests and the +in-repo example corpus cannot. + +## How it works + +For each repository in `repos.json`: + +1. Clone the pinned commit (shallow). +2. Run the codemod (sources and dependency files) over a copy. +3. Type-check the pristine clone against an environment holding the latest v1 + SDK, and the migrated copy against this workspace's v2 environment, with + identical pyright settings. +4. Diff the two error sets. Errors only on the migrated side are the migration + surface; baseline noise (the repo's own issues, missing third-party stubs) + appears on both sides and cancels out. +5. Correlate each new error with the inserted `# mcp-codemod:` markers. + +The codemod's contract is that the markers are the complete list of remaining +manual work, so every new error should sit on or next to a marker. **A new +error with no nearby marker is a silent miss** -- those are printed, written to +`work/results/.json`, and make the run exit 1. + +## Usage + +From the repository root (the v1 environment is created on first run): + +```bash +uv run --frozen python scripts/codemod-batch-test/run.py # all repos +uv run --frozen python scripts/codemod-batch-test/run.py --repo mcp-obsidian +uv run --frozen python scripts/codemod-batch-test/run.py --fresh # re-clone +``` + +## Adding a repository + +Add an entry to `repos.json` with a pinned `sha` (never a branch), an +`include` list when only part of the repository uses the SDK (empty means the +whole tree), and a one-line `note`. Prefer repositories that depend on the +`mcp` package directly; servers built on the external FastMCP library exercise +that library's surface, not this SDK's. diff --git a/scripts/codemod-batch-test/repos.json b/scripts/codemod-batch-test/repos.json new file mode 100644 index 000000000..9fdc5565b --- /dev/null +++ b/scripts/codemod-batch-test/repos.json @@ -0,0 +1,99 @@ +[ + { + "slug": "official-servers", + "url": "https://github.com/modelcontextprotocol/servers", + "sha": "7b1170d1da1e36bc9f553f51e76e64cbfd652b3e", + "include": [ + "src/fetch", + "src/git", + "src/time" + ], + "note": "The official reference servers; lowlevel Server and FastMCP usage." + }, + { + "slug": "mcp-obsidian", + "url": "https://github.com/MarkusPfundstein/mcp-obsidian", + "sha": "32285e9ac07049a8a23ea7d7903603a3e48a1bf7", + "include": [], + "note": "Popular community server; lowlevel Server with mcp.types throughout." + }, + { + "slug": "awslabs-aws-documentation", + "url": "https://github.com/awslabs/mcp", + "sha": "3a5294539de4de3a91d0ee72d5487bc8b8b1fcd7", + "include": [ + "src/aws-documentation-mcp-server" + ], + "note": "One server from the awslabs monorepo; production FastMCP usage." + }, + { + "slug": "android-mcp-server", + "url": "https://github.com/minhalvp/android-mcp-server", + "sha": "451d255a7305e6efef8a1a2b7374a21c512bba45", + "include": [], + "note": "Small community FastMCP server." + }, + { + "slug": "mysql-mcp-server", + "url": "https://github.com/designcomputer/mysql_mcp_server", + "sha": "e25be7fc4e9e79d7efc52eb69d776129429a837f", + "include": [ + "src/mysql_mcp_server" + ], + "note": "Seven lowlevel decorators on a module-level Server in one file; stdio + SSE wiring." + }, + { + "slug": "kaltura-mcp", + "url": "https://github.com/zoharbabin/kaltura-mcp", + "sha": "567f016e536692959e294c1ee94c9fc901576cd8", + "include": [ + "src/kaltura_mcp" + ], + "note": "Full seven-decorator lowlevel server with an explicit mcp>=1,<2 pin; handlers dispatch into other modules." + }, + { + "slug": "arxiv-mcp-server", + "url": "https://github.com/blazickjp/arxiv-mcp-server", + "sha": "d58901760d7ede4adb162eaba1725209a933f100", + "include": [ + "src/arxiv_mcp_server" + ], + "note": "Decorator-registered lowlevel server whose handlers live across tools/, prompts/, resources/ subpackages." + }, + { + "slug": "fastapi-mcp", + "url": "https://github.com/tadata-org/fastapi_mcp", + "sha": "e5cad13cabfc725bbcb047e526816d887d96da62", + "include": [ + "fastapi_mcp" + ], + "note": "Decorators on a method-local Server closing over self, with request_context introspection: the marker path." + }, + { + "slug": "langchain-mcp-adapters", + "url": "https://github.com/langchain-ai/langchain-mcp-adapters", + "sha": "6a10b83516e825b8ff73870e5595113acc1c8c6d", + "include": [ + "langchain_mcp_adapters" + ], + "note": "Client library exercising every v1 transport, session kwargs pass-through, and cursor pagination." + }, + { + "slug": "mcpadapt", + "url": "https://github.com/grll/mcpadapt", + "sha": "538cd85628b555ef4ad9392b7270b52274f444d4", + "include": [ + "src/mcpadapt" + ], + "note": "Client library on the old streamablehttp_client spelling with a positional timedelta session timeout." + }, + { + "slug": "chroma-mcp", + "url": "https://github.com/chroma-core/chroma-mcp", + "sha": "98ff67589bdcc31b730a5415ff9529433f949077", + "include": [ + "src/chroma_mcp" + ], + "note": "Org-backed FastMCP server frozen on mcp[cli]==1.6.0: the exact-pin dependency rewrite." + } +] diff --git a/scripts/codemod-batch-test/run.py b/scripts/codemod-batch-test/run.py new file mode 100644 index 000000000..07514f84d --- /dev/null +++ b/scripts/codemod-batch-test/run.py @@ -0,0 +1,360 @@ +"""Run the v1 -> v2 codemod against real pinned repositories and audit the result. + +Each pinned repo is migrated and pyright-checked on both sides (pristine against the +latest v1 SDK, migrated against this workspace's v2). Every new error must sit on or +near a `# mcp-codemod:` marker; an uncovered error is a silent miss and exits 1. + +Usage: uv run --frozen python scripts/codemod-batch-test/run.py [--repo SLUG] [--fresh] +""" + +import argparse +import ast +import json +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +from mcp_codemod._dependencies import update_dependencies +from mcp_codemod._runner import discover +from mcp_codemod._runner import run as run_codemod +from mcp_codemod._transformer import MARKER + +HARNESS_DIR = Path(__file__).resolve().parent +WORKSPACE_ROOT = HARNESS_DIR.parents[1] +# Dot-directory: pytest's default norecursedirs keeps cloned repos' test suites out of `./scripts/test`. +WORK_DIR = HARNESS_DIR / ".work" + +# Max line distance for an error to still count as explained by a marker. +MARKER_RADIUS = 3 + +# Rules written off as v2 strictness drift, but only in a file the codemod did not touch and with +# no mcp symbol in the message. `reportAttributeAccessIssue` is absent: a missed removal looks like it. +DRIFT_RULES = frozenset({"reportArgumentType", "reportOptionalSubscript", "reportOptionalMemberAccess"}) + +# A `reportArgumentType` error naming one of these is a real runtime break on v2, never strictness drift. +DETONATOR_TYPES = ("timedelta", "AnyUrl") + +# Rules that carry a break's downstream type propagation rather than its source. +CASCADE_RULES = frozenset({"reportArgumentType", "reportAssignmentType", "reportCallIssue", "reportReturnType"}) + +# Outside the SDK checkout: inside it, uv resolves the SDK workspace itself and the env would hold v2. +V1_ENV_DIR = Path.home() / ".cache" / "mcp-codemod-batch-test" / "v1env" + +V1_ENV_PYPROJECT = """\ +[project] +name = "codemod-batch-test-v1-env" +version = "0" +requires-python = ">=3.10" +dependencies = ["mcp[cli,ws]>=1.9,<2"] + +# Belt and braces: never resolve as a member of some enclosing workspace. +[tool.uv.workspace] +""" + + +@dataclass(frozen=True, slots=True) +class Repo: + slug: str + url: str + sha: str + include: tuple[str, ...] + note: str + + +@dataclass(frozen=True, slots=True) +class PyrightError: + file: str + line: int + rule: str + message: str + + @property + def key(self) -> tuple[str, str, str]: + """Line-independent identity, so unrelated baseline noise cancels out.""" + return (self.file, self.rule, self.message) + + +def _run(command: list[str], *, cwd: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, cwd=cwd, capture_output=True, text=True, check=False) + + +def _load_repos(only: str | None) -> list[Repo]: + raw: object = json.loads((HARNESS_DIR / "repos.json").read_text()) + assert isinstance(raw, list) + repos: list[Repo] = [] + for entry in raw: + assert isinstance(entry, dict) + repo = Repo( + slug=str(entry["slug"]), + url=str(entry["url"]), + sha=str(entry["sha"]), + include=tuple(str(item) for item in entry["include"]), + note=str(entry["note"]), + ) + if only is None or repo.slug == only: + repos.append(repo) + return repos + + +def _ensure_v1_environment() -> Path: + """Create (once) an environment holding the latest v1 SDK; return its python. + + Fails loudly unless it really holds v1: a v2 baseline would report no migration delta. + """ + env_dir = V1_ENV_DIR + python = env_dir / ".venv" / "bin" / "python" + if not python.is_file(): + env_dir.mkdir(parents=True, exist_ok=True) + (env_dir / "pyproject.toml").write_text(V1_ENV_PYPROJECT) + print("setting up the v1 environment (one-time)...") + sync = _run(["uv", "sync"], cwd=env_dir) + if sync.returncode != 0: + sys.exit(f"v1 environment setup failed:\n{sync.stderr}") + probe = _run([str(python), "-c", "import mcp.types"], cwd=env_dir) + if probe.returncode != 0: + sys.exit(f"the v1 environment does not hold a v1 SDK:\n{probe.stderr}") + return python + + +def _clone_pinned(repo: Repo, destination: Path, *, fresh: bool) -> None: + if destination.is_dir(): + if not fresh: + return + shutil.rmtree(destination) + destination.mkdir(parents=True) + for command in ( + ["git", "init", "-q"], + ["git", "remote", "add", "origin", repo.url], + ["git", "fetch", "-q", "--depth", "1", "origin", repo.sha], + ["git", "checkout", "-q", "FETCH_HEAD"], + ): + result = _run(command, cwd=destination) + if result.returncode != 0: + sys.exit(f"{repo.slug}: `{' '.join(command)}` failed:\n{result.stderr}") + + +def _side_roots(repo: Repo, side: Path) -> list[Path]: + return [side / sub for sub in repo.include] if repo.include else [side] + + +def _pyright_errors(repo: Repo, *, python: Path, side: Path) -> list[PyrightError] | None: + """Type-check one side against the env of `python`, or None when pyright dies. + + The config is written into the side's own root with relative includes, so + nothing outside it is ever scanned. + + `--pythonpath` beats the implicit `VIRTUAL_ENV` that `uv run` exports, which a config `venvPath` does not. + """ + config = { + "include": list(repo.include) or ["."], + "typeCheckingMode": "basic", + } + (side / "pyrightconfig.json").write_text(json.dumps(config)) + result = _run( + ["uv", "run", "--frozen", "pyright", "--project", str(side), "--pythonpath", str(python), "--outputjson"], + cwd=WORKSPACE_ROOT, + ) + try: + output: object = json.loads(result.stdout) + except json.JSONDecodeError: + print(f" pyright produced no JSON (exit {result.returncode}):\n{result.stderr}", file=sys.stderr) + return None + assert isinstance(output, dict) + summary = output.get("summary") + assert isinstance(summary, dict) + if not summary.get("filesAnalyzed"): + # A bad include path makes pyright "succeed" over nothing; fail the repo instead. + print(f" pyright analyzed zero files in {side} -- check the include paths", file=sys.stderr) + return None + diagnostics = output.get("generalDiagnostics") + assert isinstance(diagnostics, list) + errors: list[PyrightError] = [] + for diagnostic in diagnostics: + assert isinstance(diagnostic, dict) + if diagnostic.get("severity") != "error": + continue + file = str(Path(str(diagnostic["file"])).relative_to(side)) + start = diagnostic["range"]["start"]["line"] + assert isinstance(start, int) + errors.append( + PyrightError( + file=file, + line=start + 1, # pyright lines are zero-based + rule=str(diagnostic.get("rule", "")), + message=str(diagnostic["message"]), + ) + ) + return errors + + +def _statement_spans(source: str) -> list[tuple[int, int]]: + """The (lineno, end_lineno) of every statement in a parseable Python file. + + A compound statement contributes only its HEADER lines (up to its first body + statement): a marker above a `with` covers the multi-line call in its header, + never the hundreds of lines inside a def or class body. + """ + try: + tree = ast.parse(source) + except SyntaxError: + return [] + spans: list[tuple[int, int]] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.stmt): + continue + end = node.end_lineno or node.lineno + body = getattr(node, "body", None) + if isinstance(body, list) and body and isinstance(body[0], ast.stmt): + end = min(end, body[0].lineno - 1) + spans.append((node.lineno, end)) + return spans + + +def _collect_markers(roots: list[Path], side: Path) -> dict[str, list[tuple[int, int]]]: + """Every `# mcp-codemod:` line in the migrated tree, by file, as covered spans. + + A marker covers `MARKER_RADIUS` around itself plus any statement starting within that radius below it. + """ + markers: dict[str, list[tuple[int, int]]] = {} + needle = f"# {MARKER}:" + for root in roots: + candidates = [path for path in root.rglob("*") if path.suffix == ".py" or path.name == "pyproject.toml"] + candidates += list(root.rglob("requirements*.txt")) + for path in candidates: + try: + source = path.read_bytes().decode("utf-8") + except (OSError, UnicodeDecodeError): + continue + lines = source.splitlines() + hits = [number for number, line in enumerate(lines, start=1) if needle in line] + if not hits: + continue + spans = _statement_spans(source) if path.suffix == ".py" else [] + covered: list[tuple[int, int]] = [] + for hit in hits: + end = hit + MARKER_RADIUS + for start, stop in spans: + if hit < start <= hit + MARKER_RADIUS: + end = max(end, stop) + covered.append((hit - MARKER_RADIUS, end)) + markers[str(path.relative_to(side))] = covered + return markers + + +def _audit_repo(repo: Repo, *, v1_python: Path, fresh: bool) -> tuple[dict[str, object], int] | None: + print(f"\n=== {repo.slug} ({repo.note})") + pristine = WORK_DIR / "repos" / repo.slug / "pristine" + migrated = WORK_DIR / "repos" / repo.slug / "migrated" + _clone_pinned(repo, pristine, fresh=fresh) + + if migrated.is_dir(): + shutil.rmtree(migrated) + shutil.copytree(pristine, migrated, ignore=shutil.ignore_patterns(".git")) + + roots = _side_roots(repo, migrated) + report = run_codemod(discover(roots), write=True) + dependency_reports = update_dependencies(roots, write=True) + severities = report.diagnostics + rewritten_files = {str(file.path.relative_to(migrated)) for file in report.changed} + print( + f" codemod: {len(report.changed)} of {len(report.files)} files rewritten, " + f"{severities['manual'] + severities['review']} flagged sites, " + f"{sum(1 for dependency in dependency_reports if dependency.changed)} dependency files updated" + ) + + baseline = _pyright_errors(repo, python=v1_python, side=pristine) + post = _pyright_errors(repo, python=WORKSPACE_ROOT / ".venv" / "bin" / "python", side=migrated) + if baseline is None or post is None: + return None + baseline_keys = {error.key for error in baseline} + new_errors = [error for error in post if error.key not in baseline_keys] + resolved = len(baseline) - len([error for error in baseline if error.key in {e.key for e in post}]) + + markers = _collect_markers(roots, migrated) + actionable: list[PyrightError] = [] + drift: list[PyrightError] = [] + cascade: list[PyrightError] = [] + for error in new_errors: + spans = markers.get(error.file, []) + if any(start <= error.line <= end for start, end in spans): + continue + # A break's source always errors without "Unknown" in its message, so + # "Unknown" only appears in downstream propagation -- and in a marked file + # the roots are the marked ones. Detonators stay actionable regardless. + is_detonator = any(f'of type "{detonator}"' in error.message for detonator in DETONATOR_TYPES) + if "Unknown" in error.message and spans and not is_detonator and error.rule in CASCADE_RULES: + cascade.append(error) + continue + if ( + error.file not in rewritten_files + and "mcp" not in error.message.lower() + and error.rule in DRIFT_RULES + and not any(f'of type "{detonator}"' in error.message for detonator in DETONATOR_TYPES) + ): + drift.append(error) + else: + actionable.append(error) + + covered = len(new_errors) - len(actionable) - len(drift) - len(cascade) + print( + f" pyright: {len(baseline)} baseline errors, {len(new_errors)} new after migration " + f"({resolved} resolved): {covered} covered by markers, {len(cascade)} marked-break cascade, " + f"{len(drift)} v2 strictness drift" + ) + for error in actionable: + print(f" UNCOVERED {error.file}:{error.line} [{error.rule}] {error.message.splitlines()[0]}") + + result: dict[str, object] = { + "slug": repo.slug, + "sha": repo.sha, + "files_rewritten": len(report.changed), + "files_total": len(report.files), + "flagged_sites": severities["manual"] + severities["review"], + "baseline_errors": len(baseline), + "new_errors": len(new_errors), + "covered_by_markers": covered, + "strictness_drift": [ + {"file": error.file, "line": error.line, "rule": error.rule, "message": error.message} for error in drift + ], + "uncovered": [ + {"file": error.file, "line": error.line, "rule": error.rule, "message": error.message} + for error in actionable + ], + } + return result, len(actionable) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", help="run a single repository by slug") + parser.add_argument("--fresh", action="store_true", help="re-clone repositories even when present") + args = parser.parse_args() + + repos = _load_repos(args.repo) + if not repos: + sys.exit(f"no repository matches {args.repo!r}") + WORK_DIR.mkdir(exist_ok=True) + v1_python = _ensure_v1_environment() + + results: list[dict[str, object]] = [] + total_uncovered = 0 + for repo in repos: + audited = _audit_repo(repo, v1_python=v1_python, fresh=args.fresh) + if audited is not None: + result, uncovered = audited + results.append(result) + total_uncovered += uncovered + + results_dir = WORK_DIR / "results" + results_dir.mkdir(exist_ok=True) + for result in results: + (results_dir / f"{result['slug']}.json").write_text(json.dumps(result, indent=2) + "\n") + + print(f"\n{len(results)} repositories audited; {total_uncovered} uncovered new errors.") + return 1 if total_uncovered or len(results) != len(repos) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/mcp-codemod/README.md b/src/mcp-codemod/README.md new file mode 100644 index 000000000..ebc3b6426 --- /dev/null +++ b/src/mcp-codemod/README.md @@ -0,0 +1,97 @@ +# 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(...)` calls to `MCPError.from_error_data(...)`, which takes the + same single `ErrorData` argument the v1 constructor did. (`e.error.code` and + friends are deliberately left alone: they still work on v2.) +- camelCase attribute reads on `mcp.types` models to their snake_case v2 + spellings (`.inputSchema` -> `.input_schema`), restricted to the field names + the v1 types actually declared. Other camelCase APIs (`logging.getLogger`, a + 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. +- Lowlevel `@server.list_tools()` / `@server.call_tool()` / ... decorator + registrations, to `server.add_request_handler(...)` calls at the same source + position, each wired through a generated adapter that reproduces the v1 + wrapper semantics your handler relied on: bare-list wrapping, the `call_tool` + isError contract with jsonschema input/output validation, `read_resource` + content conversion, and the completion None-mapping. Your handler bodies are + not touched. A shape the adapter cannot serve honestly (a stacked decorator, + a `self.`-attribute server, a non-v1 signature) is marked instead. +- Positional arguments after the name on the lowlevel `Server(...)` constructor + to keywords (v2 is keyword-only there but kept v1's names and order). +- An inline `timedelta(...)` passed as a `ClientSession` timeout to + `.total_seconds()` (v2 takes float seconds and would only fail on the first + request), `cursor=` on the session's `list_*` methods to the v2 + `params=PaginatedRequestParams(...)` form, and a pydantic `AnyUrl(...)` / + `FileUrl(...)` wrapper around a resource URI to the plain string v2 expects. +- The `mcp` requirement in `pyproject.toml` and `requirements*.txt`, to + `>=2,<3`, wherever the current constraint cannot accept any v2 release. Only + the version specifier changes; the name, extras, environment marker, and + formatting keep your spelling. A constraint that already admits v2, a Poetry + dependency table, and the removed `ws` extra are marked instead of guessed at. + +## What it marks instead + +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()`), and imports + of whole module namespaces v2 deleted (the removed experimental tasks + API). Together with the renames these account for every public + module v1 shipped, so an import is never left to fail unexplained. +- The v1 `mcp.types` names with no v2 home (`Cursor`, the `TASK_*` constants, the + type-machinery aliases). `mcp_types` is not a name-superset of v1's `mcp.types`, + 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 decorator registrations the generated adapters cannot serve + honestly: a second decorator stacked on the handler, a server reached through + an attribute (`self.server`), a handler signature away from the v1 form, or a + decorator argument the codemod cannot evaluate. The marker names the reason + and the `add_request_handler(...)` destination. +- Renames the codemod applied but cannot prove are right: a camelCase rename + whose receiver could plausibly not be an mcp type gets a `# mcp-codemod: review:` + marker so you look at it instead of trusting it. + +`--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..a964c5267 --- /dev/null +++ b/src/mcp-codemod/mcp_codemod/__init__.py @@ -0,0 +1,13 @@ +"""Automated rewrites for migrating code between major versions of the MCP Python SDK. + +Run as a tool (`uvx mcp-codemod v1-to-v2 ./src`) or call `transform(source)` as a library. + +Rewrites are conservative by construction: names are resolved through the file's +imports rather than matched as text, and anything whose correct rewrite depends on +information outside the file gets an inline `# mcp-codemod:` comment instead of a +guess; `grep -rn '# mcp-codemod:'` after a run lists everything left for a human. +""" + +from mcp_codemod._transformer import MARKER, Diagnostic, Result, transform + +__all__ = ["MARKER", "Diagnostic", "Result", "transform"] diff --git a/src/mcp-codemod/mcp_codemod/_adapters.py b/src/mcp-codemod/mcp_codemod/_adapters.py new file mode 100644 index 000000000..db3d89251 --- /dev/null +++ b/src/mcp-codemod/mcp_codemod/_adapters.py @@ -0,0 +1,304 @@ +"""Emitted-source templates for the lowlevel decorator -> registration rewrite. + +Adapters reproduce v1's wrapper semantics against the public v2 surface only: a +migrated file never imports from mcp_codemod, and nothing 2026-era is emitted. +Templates use `__FN__`/`__RECV__` placeholders instead of `str.format` because +the emitted code is full of braces. +""" + +import ast +from dataclasses import dataclass, field + +__all__ = ["LOWLEVEL_HANDLER_SPECS", "TEMPLATE_LOCALS", "HandlerSpec", "build_adapter", "cache_name", "handler_name"] + +# Injected into the migrated module only when the name is not already bound by an import there. +ADAPTER_IMPORTS: dict[str, str] = { + "base64": "import base64", + "Iterable": "from collections.abc import Iterable", + "cast": "from typing import cast", + "json": "import json", + "jsonschema": "import jsonschema", + "AnyUrl": "from pydantic import AnyUrl", + "MCPError": "from mcp import MCPError", + "ServerRequestContext": "from mcp.server import ServerRequestContext", + "ReadResourceContents": "from mcp.server.lowlevel.helper_types import ReadResourceContents", + "mcp_types": "import mcp_types", +} + + +@dataclass(frozen=True, slots=True) +class HandlerSpec: + """How one v1 decorator kind maps onto a generated v2 registration.""" + + template: str + arity: int + """Positional-parameter count of the v1 handler signature.""" + imports: tuple[str, ...] = field(default=("ServerRequestContext", "mcp_types")) + """Names from ADAPTER_IMPORTS the emitted code references.""" + notification: bool = False + """Whether the kind registers through add_notification_handler.""" + + +# v1's list wrappers passed an already-full result model through at runtime, so +# the adapter must too; the cast keeps the user's return annotation out of it. +_BARE_LIST = """\ + +async def ___FN___handler( + ctx: ServerRequestContext, params: mcp_types.PaginatedRequestParams +) -> mcp_types.{result}: + result = cast("object", await __FN__()) + if isinstance(result, mcp_types.{result}): + return result + return mcp_types.{result}({field}=cast("list[mcp_types.{item}]", result)) + + +__RECV__.add_request_handler("{method}", mcp_types.PaginatedRequestParams, ___FN___handler) +""" + +_GET_PROMPT = """\ + +async def ___FN___handler( + ctx: ServerRequestContext, params: mcp_types.GetPromptRequestParams +) -> mcp_types.GetPromptResult: + return await __FN__(params.name, params.arguments) + + +__RECV__.add_request_handler("prompts/get", mcp_types.GetPromptRequestParams, ___FN___handler) +""" + +_COMPLETION = """\ + +async def ___FN___handler( + ctx: ServerRequestContext, params: mcp_types.CompleteRequestParams +) -> mcp_types.CompleteResult: + completion = await __FN__(params.ref, params.argument, params.context) + if completion is None: + completion = mcp_types.Completion(values=[], total=None, has_more=None) + return mcp_types.CompleteResult(completion=completion) + + +__RECV__.add_request_handler("completion/complete", mcp_types.CompleteRequestParams, ___FN___handler) +""" + +_URI_EMPTY = """\ + +async def ___FN___handler( + ctx: ServerRequestContext, params: mcp_types.{params} +) -> mcp_types.EmptyResult: + await __FN__(__URI__) + return mcp_types.EmptyResult() + + +__RECV__.add_request_handler("{method}", mcp_types.{params}, ___FN___handler) +""" + +_SET_LOGGING_LEVEL = """\ + +async def ___FN___handler( + ctx: ServerRequestContext, params: mcp_types.SetLevelRequestParams +) -> mcp_types.EmptyResult: + await __FN__(params.level) + return mcp_types.EmptyResult() + + +__RECV__.add_request_handler("logging/setLevel", mcp_types.SetLevelRequestParams, ___FN___handler) +""" + +_PROGRESS = """\ + +async def ___FN___handler( + ctx: ServerRequestContext, params: mcp_types.ProgressNotificationParams +) -> None: + await __FN__(params.progress_token, params.progress, params.total, params.message) + + +__RECV__.add_notification_handler("notifications/progress", mcp_types.ProgressNotificationParams, ___FN___handler) +""" + +# Reproduces v1's `@read_resource()` return conversion: bare `str`/`bytes` is a single +# content item; iterables of `ReadResourceContents` convert with v1's default MIME types. +_READ_RESOURCE = """\ + +async def ___FN___handler( + ctx: ServerRequestContext, params: mcp_types.ReadResourceRequestParams +) -> mcp_types.ReadResourceResult: + result: object = await __FN__(__URI__) + if isinstance(result, str | bytes): + items = [ReadResourceContents(content=result)] + else: + items = list(cast("Iterable[ReadResourceContents]", result)) + contents: list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents] = [] + for item in items: + if isinstance(item.content, str): + contents.append( + mcp_types.TextResourceContents( + uri=params.uri, text=item.content, mime_type=item.mime_type or "text/plain", _meta=item.meta + ) + ) + else: + contents.append( + mcp_types.BlobResourceContents( + uri=params.uri, + blob=base64.b64encode(item.content).decode(), + mime_type=item.mime_type or "application/octet-stream", + _meta=item.meta, + ) + ) + return mcp_types.ReadResourceResult(contents=contents) + + +__RECV__.add_request_handler("resources/read", mcp_types.ReadResourceRequestParams, ___FN___handler) +""" + +# Reproduces v1's `@call_tool()` dispatch in v1's order with v1's error strings, looking +# tools up through the registered tools/list handler; `MCPError` re-raises per v2's contract. +_CALL_TOOL = """\ + +___RECV___tool_cache: dict[str, mcp_types.Tool] = {} + + +async def ___FN___handler( + ctx: ServerRequestContext, params: mcp_types.CallToolRequestParams +) -> mcp_types.CallToolResult: + def _error(message: str) -> mcp_types.CallToolResult: + return mcp_types.CallToolResult(content=[mcp_types.TextContent(type="text", text=message)], is_error=True) + + try: + arguments = params.arguments or {} + if params.name not in ___RECV___tool_cache: + listed = __RECV__.get_request_handler("tools/list") + if listed is not None: + tools = await listed.handler(ctx, mcp_types.PaginatedRequestParams()) + if isinstance(tools, mcp_types.ListToolsResult): + ___RECV___tool_cache.clear() + ___RECV___tool_cache.update({tool.name: tool for tool in tools.tools}) + tool = ___RECV___tool_cache.get(params.name) +__VALIDATION__ results = cast("object", await __FN__(params.name, arguments)) + if isinstance(results, mcp_types.CallToolResult): + return results + if isinstance(results, tuple) and len(results) == 2: + content, structured = results + elif isinstance(results, dict): + content = [mcp_types.TextContent(type="text", text=json.dumps(results, indent=2))] + structured = results + elif isinstance(results, Iterable): + content, structured = results, None + else: + return _error(f"Unexpected return type from tool: {type(results).__name__}") + if tool is not None and tool.output_schema is not None: + if structured is None: + return _error("Output validation error: outputSchema defined but no structured output returned") + try: + jsonschema.validate(instance=structured, schema=tool.output_schema) + except jsonschema.ValidationError as exc: + return _error(f"Output validation error: {exc.message}") + return mcp_types.CallToolResult(content=list(content), structured_content=structured, is_error=False) + except MCPError: + raise + except Exception as exc: + return _error(str(exc)) + + +__RECV__.add_request_handler("tools/call", mcp_types.CallToolRequestParams, ___FN___handler) +""" + +_CALL_TOOL_VALIDATION = """\ + if tool is not None: + try: + jsonschema.validate(instance=arguments, schema=tool.input_schema) + except jsonschema.ValidationError as exc: + return _error(f"Input validation error: {exc.message}") +""" + +_URI_IMPORTS = ("ServerRequestContext", "mcp_types") + +LOWLEVEL_HANDLER_SPECS: dict[str, HandlerSpec] = { + "list_tools": HandlerSpec( + _BARE_LIST.format(result="ListToolsResult", field="tools", method="tools/list", item="Tool"), + 0, + ("ServerRequestContext", "mcp_types", "cast"), + ), + "list_resources": HandlerSpec( + _BARE_LIST.format(result="ListResourcesResult", field="resources", method="resources/list", item="Resource"), + 0, + ("ServerRequestContext", "mcp_types", "cast"), + ), + "list_prompts": HandlerSpec( + _BARE_LIST.format(result="ListPromptsResult", field="prompts", method="prompts/list", item="Prompt"), + 0, + ("ServerRequestContext", "mcp_types", "cast"), + ), + "list_resource_templates": HandlerSpec( + _BARE_LIST.format( + result="ListResourceTemplatesResult", + field="resource_templates", + method="resources/templates/list", + item="ResourceTemplate", + ), + 0, + ("ServerRequestContext", "mcp_types", "cast"), + ), + "get_prompt": HandlerSpec(_GET_PROMPT, 2), + "completion": HandlerSpec(_COMPLETION, 3), + "subscribe_resource": HandlerSpec( + _URI_EMPTY.format(params="SubscribeRequestParams", method="resources/subscribe"), 1, _URI_IMPORTS + ), + "unsubscribe_resource": HandlerSpec( + _URI_EMPTY.format(params="UnsubscribeRequestParams", method="resources/unsubscribe"), 1, _URI_IMPORTS + ), + "set_logging_level": HandlerSpec(_SET_LOGGING_LEVEL, 1), + "progress_notification": HandlerSpec(_PROGRESS, 4, notification=True), + "read_resource": HandlerSpec( + _READ_RESOURCE, + 1, + ("ServerRequestContext", "mcp_types", "ReadResourceContents", "Iterable", "cast", "base64"), + ), + "call_tool": HandlerSpec( + _CALL_TOOL, 2, ("ServerRequestContext", "mcp_types", "MCPError", "Iterable", "cast", "json", "jsonschema") + ), +} + + +def handler_name(fn: str) -> str: + """The emitted adapter's name for a handler function.""" + return f"_{fn}_handler" + + +def cache_name(recv: str) -> str: + """The emitted call_tool tool-cache name for a server variable.""" + return f"_{recv}_tool_cache" + + +def _template_locals() -> dict[str, frozenset[str]]: + """Names each rendered template binds, derived from the templates themselves. + + A user function sharing one of these names would be shadowed inside its own + adapter (UnboundLocalError), so the transformer blocks those sites. + """ + locals_by_kind: dict[str, frozenset[str]] = {} + for kind in LOWLEVEL_HANDLER_SPECS: + names: set[str] = set() + for node in ast.walk(ast.parse(build_adapter(kind, "no_fn", "no_recv"))): + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store): + names.add(node.id) + elif isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + names.add(node.name) + elif isinstance(node, ast.ExceptHandler) and node.name: + names.add(node.name) + locals_by_kind[kind] = frozenset(names) + return locals_by_kind + + +def build_adapter(kind: str, fn: str, recv: str, *, validate_input: bool = True, uri_as_str: bool = False) -> str: + """Render the emitted block for one rewritten decorator site. + + `validate_input=False` omits only the input-validation block -- v1 validated output + schemas regardless. `uri_as_str` passes the wire string through for `str`-annotated uris. + """ + template = LOWLEVEL_HANDLER_SPECS[kind].template + template = template.replace("__VALIDATION__", _CALL_TOOL_VALIDATION if validate_input else "") + template = template.replace("__URI__", "params.uri" if uri_as_str else "AnyUrl(params.uri)") + return template.replace("__FN__", fn).replace("__RECV__", recv) + + +TEMPLATE_LOCALS: dict[str, frozenset[str]] = _template_locals() diff --git a/src/mcp-codemod/mcp_codemod/_dependencies.py b/src/mcp-codemod/mcp_codemod/_dependencies.py new file mode 100644 index 000000000..32087f345 --- /dev/null +++ b/src/mcp-codemod/mcp_codemod/_dependencies.py @@ -0,0 +1,319 @@ +"""Update a project's dependency declarations for the v2 SDK. + +Rewrites v1-era `mcp` requirements that exclude v2 to `>=2,<3` in every +`pyproject.toml` and `requirements*.txt` under the given paths; what cannot be +rewritten safely gets a `# mcp-codemod:` marker instead. +""" + +import os +import re +from collections.abc import Iterator, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import TypeGuard + +import tomllib +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name +from packaging.version import InvalidVersion, Version + +from mcp_codemod._mappings import REMOVED_EXTRAS +from mcp_codemod._runner import IGNORED_DIRECTORIES +from mcp_codemod._transformer import MARKER, Diagnostic + +__all__ = ["DependencyReport", "update_dependencies"] + +V2_SPECIFIER = ">=2,<3" + +# Era probes for `_needs_v2`; the prerelease probe makes a `==2.0.0a1` pin count as a v2 choice. +_V1_PROBES = ("1.0.0", "1.99.99") +_V2_PROBES = ("2.0.0a1", "2.0.0", "2.99.99") + +# Name-plus-extras prefix of a requirement string already validated with `Requirement`. +_REQUIREMENT_PREFIX = re.compile(r"^\s*[A-Za-z0-9][A-Za-z0-9._-]*\s*(\[[^\]]*\])?") + +# An `mcp = ...` key in a Poetry dependency table, whose constraint syntax is never rewritten. +_POETRY_MCP_KEY = re.compile(r"^[ \t]*([\"']?)mcp\1[ \t]*=", re.MULTILINE) + +# A requirements.txt line that names mcp but did not parse; skipping it silently would hide a v1 pin. +_UNPARSEABLE_MCP_LINE = re.compile(r"^\s*mcp\b", re.IGNORECASE) + +# The pyproject tables holding PEP 508 strings; edits stay inside them so lookalike text elsewhere is untouched. +_DEPENDENCY_TABLES = re.compile(r"^(project|project\.optional-dependencies|dependency-groups)$") + + +@dataclass(frozen=True, slots=True) +class DependencyReport: + """The outcome for one dependency file. `error` is set when it failed.""" + + path: Path + original: str + updated: str | None + diagnostics: list[Diagnostic] + error: str | None + + @property + def changed(self) -> bool: + """Whether the updated text differs from what was read.""" + return self.updated is not None and self.updated != self.original + + +def _line_of(text: str, index: int) -> int: + return text.count("\n", 0, index) + 1 + + +def _needs_v2(requirement: Requirement) -> bool: + """Whether the constraint is a v1-era one that excludes every v2 release. + + Constraints not provably v1-era (e.g. a narrow v2 range) are the user's own v2 choice and stay. + """ + specifier = requirement.specifier + if not str(specifier): + return False + if any(specifier.contains(probe, prereleases=True) for probe in _V2_PROBES): + return False + v1_era = any(specifier.contains(probe, prereleases=True) for probe in _V1_PROBES) + for clause in specifier: + try: + spelled_version = Version(clause.version.rstrip(".*")) + except InvalidVersion: + continue + v1_era = v1_era or spelled_version.major < 2 + return v1_era + + +def _rewrite_specifier(spelled: str) -> str: + """Replace the version specifier with `V2_SPECIFIER`, keeping the user's spelling of everything else.""" + base, separator, env_marker = spelled.partition(";") + prefix = _REQUIREMENT_PREFIX.match(base) + assert prefix is not None # `Requirement` accepted it, so the prefix parses + spacing = base[len(base.rstrip()) :] + return f"{prefix.group(0)}{V2_SPECIFIER}{spacing}{separator}{env_marker}" + + +def _insert_marker_above(text: str, index: int, message: str) -> str: + """Insert a `# mcp-codemod:` comment line above the line containing `index`.""" + line_start = text.rfind("\n", 0, index) + 1 + line = text[line_start:] + indent = line[: len(line) - len(line.lstrip())] + ending = "\r\n" if text[line_start:].partition("\n")[0].endswith("\r") else "\n" + comment = f"{indent}# {MARKER}: {message}{ending}" + if comment in text: + return text + return text[:line_start] + comment + text[line_start:] + + +def _mcp_requirement(spelled: str) -> Requirement | None: + """Parse a dependency string, returning it only when it names `mcp` itself.""" + try: + requirement = Requirement(spelled) + except InvalidRequirement: + return None + return requirement if canonicalize_name(requirement.name) == "mcp" else None + + +def _is_table(value: object) -> TypeGuard[dict[str, object]]: + """Whether a parsed TOML value is a table (its keys are strings by grammar).""" + return isinstance(value, dict) + + +def _is_array(value: object) -> TypeGuard[list[object]]: + return isinstance(value, list) + + +def _pyproject_dependency_strings(parsed: dict[str, object]) -> Iterator[str]: + """Every PEP 508 string in the standard dependency tables of a pyproject.""" + project = parsed.get("project") + if _is_table(project): + dependencies = project.get("dependencies") + if _is_array(dependencies): + yield from (entry for entry in dependencies if isinstance(entry, str)) + optional = project.get("optional-dependencies") + if _is_table(optional): + for group in optional.values(): + if _is_array(group): + yield from (entry for entry in group if isinstance(entry, str)) + groups = parsed.get("dependency-groups") + if _is_table(groups): + for group in groups.values(): + if _is_array(group): + # A group entry may also be an `{include-group = ...}` table. + yield from (entry for entry in group if isinstance(entry, str)) + + +def _has_poetry_mcp(parsed: dict[str, object]) -> bool: + tool = parsed.get("tool") + poetry = tool.get("poetry") if _is_table(tool) else None + if not _is_table(poetry): + return False + tables = [poetry.get("dependencies"), poetry.get("dev-dependencies")] + groups = poetry.get("group") + if _is_table(groups): + tables.extend(group.get("dependencies") for group in groups.values() if _is_table(group)) + return any(_is_table(table) and "mcp" in table for table in tables) + + +def _dependency_region_occurrences(text: str, quoted: str) -> list[int]: + """Offsets of `quoted` inside the standard dependency tables, comments excluded.""" + occurrences: list[int] = [] + offset = 0 + table = "" + for line in text.splitlines(keepends=True): + header = re.match(r"\[([^\]]+)\]", line.strip()) + if header is not None: + table = header.group(1) + elif _DEPENDENCY_TABLES.match(table): + comment_at = line.find("#") + searchable = line if comment_at == -1 else line[:comment_at] + at = searchable.find(quoted) + if at != -1: + occurrences.append(offset + at) + offset += len(line) + return occurrences + + +def _classify(requirement: Requirement) -> tuple[str, str] | None: + """The action for one `mcp` requirement: (kind, message), or None to leave it. + + A removed extra or URL pin outranks the specifier; rewriting would lose something the user wrote deliberately. + """ + removed = sorted(extra for extra in requirement.extras if extra in REMOVED_EXTRAS) + if removed: + return ("flag", f"{REMOVED_EXTRAS[removed[0]]}; set `mcp{V2_SPECIFIER}` by hand") + if requirement.url is not None: + return ("flag", "this pins `mcp` by URL: point it at a v2 release by hand") + if _needs_v2(requirement): + return ("rewrite", "") + return None + + +def _update_pyproject(text: str, *, add_markers: bool) -> tuple[str, list[Diagnostic]]: + diagnostics: list[Diagnostic] = [] + parsed: dict[str, object] = tomllib.loads(text) + + for spelled in dict.fromkeys(_pyproject_dependency_strings(parsed)): + requirement = _mcp_requirement(spelled) + action = _classify(requirement) if requirement is not None else None + if requirement is None or action is None: + continue + # Locate by quoted form; a requirement needing TOML string escapes does not exist in practice. + quoted = next( + (q + spelled + q for q in ('"', "'") if _dependency_region_occurrences(text, q + spelled + q)), None + ) + if quoted is None: + continue + kind, message = action + if kind == "flag": + at = _dependency_region_occurrences(text, quoted)[0] + diagnostics.append(Diagnostic(_line_of(text, at), "dependency", "manual", message)) + if add_markers: + text = _insert_marker_above(text, at, message) + continue + replacement = quoted[0] + _rewrite_specifier(spelled) + quoted[0] + for at in reversed(_dependency_region_occurrences(text, quoted)): + text = text[:at] + replacement + text[at + len(quoted) :] + line = _line_of(text, at) + diagnostics.append( + Diagnostic(line, "dependency", "info", f"updated the `mcp` requirement to `{V2_SPECIFIER}`") + ) + + if _has_poetry_mcp(parsed): + message = f"update this Poetry constraint for v2 (`{V2_SPECIFIER}`) by hand" + # Only marker placement needs the key's location; an inline table defeats the line match. + keys = list(_POETRY_MCP_KEY.finditer(text)) + if not keys: + diagnostics.append(Diagnostic(1, "dependency", "manual", message)) + for key in reversed(keys): + diagnostics.append(Diagnostic(_line_of(text, key.start()), "dependency", "manual", message)) + if add_markers: + text = _insert_marker_above(text, key.start() + len(key.group(0)), message) + return text, diagnostics + + +def _update_requirements(text: str, *, add_markers: bool) -> tuple[str, list[Diagnostic]]: + diagnostics: list[Diagnostic] = [] + lines = text.splitlines(keepends=True) + out: list[str] = [] + for number, line in enumerate(lines, start=1): + body = line.split("#", 1)[0] + spelled = body.strip() + if not spelled or spelled.startswith("-"): + out.append(line) + continue + requirement = _mcp_requirement(spelled) + if requirement is None: + if _UNPARSEABLE_MCP_LINE.match(spelled) and _is_unparseable(spelled): + action = ("flag", f"could not parse this `mcp` line: update it for v2 (`{V2_SPECIFIER}`) by hand") + else: + out.append(line) + continue + else: + classified = _classify(requirement) + if classified is None: + out.append(line) + continue + action = classified + kind, message = action + if kind == "flag": + diagnostics.append(Diagnostic(number, "dependency", "manual", message)) + if add_markers: + ending = "\r\n" if line.endswith("\r\n") else "\n" + comment = f"# {MARKER}: {message}{ending}" + if out[-1:] != [comment]: + out.append(comment) + out.append(line) + continue + out.append(line.replace(spelled, _rewrite_specifier(spelled), 1)) + diagnostics.append( + Diagnostic(number, "dependency", "info", f"updated the `mcp` requirement to `{V2_SPECIFIER}`") + ) + return "".join(out), diagnostics + + +def _is_unparseable(spelled: str) -> bool: + try: + Requirement(spelled) + except InvalidRequirement: + return True + return False + + +def _dependency_files(paths: Sequence[Path]) -> Iterator[Path]: + for path in paths: + if not path.is_dir(): + continue + found: list[Path] = [] + for directory, child_directories, files in os.walk(path): + child_directories[:] = [name for name in child_directories if name not in IGNORED_DIRECTORIES] + found.extend( + Path(directory, name) + for name in files + if name == "pyproject.toml" or (name.startswith("requirements") and name.endswith(".txt")) + ) + yield from sorted(found) + + +def update_dependencies(paths: Sequence[Path], *, write: bool, add_markers: bool = True) -> list[DependencyReport]: + """Update the `mcp` requirement in every dependency file under `paths`. + + A file that cannot be read, decoded as UTF-8, or parsed is reported with its error and left as found. + """ + reports: list[DependencyReport] = [] + for path in _dependency_files(paths): + source = "" + try: + source = path.read_bytes().decode("utf-8") + if path.name == "pyproject.toml": + updated, diagnostics = _update_pyproject(source, add_markers=add_markers) + else: + updated, diagnostics = _update_requirements(source, add_markers=add_markers) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: + reports.append(DependencyReport(path, source, None, [], f"{type(exc).__name__}: {exc}")) + continue + if not diagnostics and updated == source: + continue + report = DependencyReport(path, source, updated, diagnostics, None) + if write and report.changed: + path.write_bytes(updated.encode("utf-8")) + reports.append(report) + return reports diff --git a/src/mcp-codemod/mcp_codemod/_mappings.py b/src/mcp-codemod/mcp_codemod/_mappings.py new file mode 100644 index 000000000..428f7f922 --- /dev/null +++ b/src/mcp-codemod/mcp_codemod/_mappings.py @@ -0,0 +1,335 @@ +"""The v1 -> v2 rename and removal tables. + +Every transform in `_transformer.py` is driven by one of these tables, and +`tests/codemod/test_mappings.py` pins them against the installed v2 packages. +""" + +import re +from typing import Literal, NamedTuple + +__all__ = [ + "CAMEL_FIELDS", + "ERRORDATA_QNAMES", + "FASTMCP_QNAMES", + "CLIENT_SESSION_QNAMES", + "LOWLEVEL_CTOR_POSITIONAL_PARAMS", + "LOWLEVEL_REMOVED_ATTRS", + "LOWLEVEL_SERVER_QNAMES", + "MCPERROR_QNAMES", + "PYDANTIC_URL_QNAMES", + "SESSION_LIST_METHODS", + "SESSION_URI_METHODS", + "TIMEDELTA_QNAMES", + "UNION_TYPE_ALIASES", + "MODULE_RENAMES", + "REHOMED_IMPORTS", + "REMOVED_APIS", + "REMOVED_ATTRS", + "REMOVED_CTOR_PARAMS", + "REMOVED_EXTRAS", + "REMOVED_MODULES", + "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 imports and fully-dotted usages. +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", +} + +# (renamed module, imported name) -> the name's PUBLIC v2 home, applied after +# `MODULE_RENAMES`: a type checker treats a name a module does not re-export as private. +REHOMED_IMPORTS: dict[tuple[str, str], str] = { + ("mcp.server.mcpserver.server", "Context"): "mcp.server.mcpserver", +} + +# v1 module roots with no v2 home under any name, matched by longest prefix. Imports +# are marked, never rewritten; with `MODULE_RENAMES` these cover every public v1 module. +REMOVED_MODULES: dict[str, str] = { + "mcp.client.experimental": ("removed: the v1 experimental tasks API was deleted and has no replacement"), + "mcp.server.experimental": ("removed: the v1 experimental tasks API was deleted and has no replacement"), + "mcp.server.lowlevel.experimental": ("removed: the v1 experimental tasks API was deleted and has no replacement"), + "mcp.shared.experimental": ("removed: the v1 experimental tasks API was deleted and has no replacement"), + "mcp.client.websocket": "removed: the WebSocket transport was deleted", + "mcp.server.websocket": "removed: the WebSocket transport was deleted", + "mcp.server.lowlevel.func_inspection": "removed: it was an internal helper of the lowlevel server", + "mcp.shared.progress": "removed: report progress with `ctx.report_progress()` inside a handler", + "mcp.shared.response_router": "removed: it was internal session machinery; there is no public replacement", +} + +# Symbol renames, keyed by every v1 qualified name the symbol was reachable from. +# Usages resolve through the file's imports, so aliases and same-named user symbols are safe. +SYMBOL_RENAMES: dict[str, str] = { + "mcp.server.FastMCP": "MCPServer", + "mcp.server.fastmcp.FastMCP": "MCPServer", + "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 with no v2 home: never rewritten, a `# mcp-codemod:` marker carries the guidance. +REMOVED_APIS: dict[str, str] = { + "mcp.shared.memory.create_connected_server_and_client_session": ( + "removed: pair `create_client_server_memory_streams()` with `Server.run()` and a `ClientSession` " + "to keep the v1 test shape, or use `mcp.Client(server)`" + ), + "mcp.shared.progress.progress": "removed: report progress with `ctx.report_progress()` inside a handler", + "mcp.shared.progress.Progress": "removed: `mcp.shared.progress` was deleted", + "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: use `ClientSession` or `ServerSession` directly", + "mcp.server.lowlevel.server.request_ctx": ( + "removed: the module-level ContextVar is gone; handlers now receive `ctx` explicitly" + ), + # Every v1 `mcp.types` name with no same-name home in `mcp_types`. Enumerating + # them all is what lets the tests prove every other rewritten import resolves. + "mcp.types.Cursor": "removed: it was an alias of `str`; use `str`", + # A nested class, so the per-name module check in the tests cannot see it. + "mcp.types.RequestParams.Meta": ( + "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 with the v1 experimental tasks API", + "mcp.types.TASK_REQUIRED": "removed with the v1 experimental tasks API", + "mcp.types.TASK_OPTIONAL": "removed with the v1 experimental tasks API", + "mcp.types.TASK_FORBIDDEN": "removed with the v1 experimental tasks API", + "mcp.types.TASK_STATUS_WORKING": "removed with the v1 experimental tasks API", + "mcp.types.TASK_STATUS_INPUT_REQUIRED": "removed with the v1 experimental tasks API", + "mcp.types.TASK_STATUS_COMPLETED": "removed with the v1 experimental tasks API", + "mcp.types.TASK_STATUS_FAILED": "removed with the v1 experimental tasks API", + "mcp.types.TASK_STATUS_CANCELLED": "removed with the v1 experimental tasks API", +} + +# Extras the v1 `mcp` distribution declared that v2 does not. +REMOVED_EXTRAS: dict[str, str] = { + "ws": "the `ws` extra was removed with the WebSocket transport", +} + +# Removed attributes matched by NAME only (receiver types are unknown): an entry must be +# distinctive AND not spelled by any surviving v2 API (see `LOWLEVEL_REMOVED_ATTRS`). +REMOVED_ATTRS: dict[str, str] = { + "get_context": "`MCPServer.get_context()` was removed: accept a `ctx: Context` parameter on the handler instead", + "get_server_capabilities": "removed: read `session.initialize_result` instead", + "_mcp_server": "renamed on v2: the wrapped lowlevel server is the private `_lowlevel_server` attribute", +} + + +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"(? v2 transformer to files on disk.""" + +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"] + +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. + """ + 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 when `write` is true. + + Failures are recorded per file; the run continues to the next file. + """ + reports: list[FileReport] = [] + for path in paths: + source = "" + try: + # UTF-8 bytes rather than `read_text()`: locale-independent, and line endings round-trip unchanged. + source = path.read_bytes().decode("utf-8") + result = transform(source, add_markers=add_markers) + except (OSError, UnicodeDecodeError, ParserSyntaxError) as exc: + 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..c9e29c052 --- /dev/null +++ b/src/mcp-codemod/mcp_codemod/_transformer.py @@ -0,0 +1,1156 @@ +"""The v1 -> v2 source transformer. + +`transform()` is the whole programmatic surface: one module's source text in, +rewritten text plus diagnostics out. Rewrites are deliberately conservative: a +construct is rewritten only when its meaning is unambiguous from the file alone +(names resolved through the imports, camelCase renames restricted to v1's declared +field names); anything else is left as written under an inline `# mcp-codemod:` +marker. Running the transformer over its own output is a no-op. +""" + +from collections import Counter +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, + MetadataWrapper, + PositionProvider, + QualifiedNameProvider, + QualifiedNameSource, +) + +from mcp_codemod._adapters import ( + ADAPTER_IMPORTS, + LOWLEVEL_HANDLER_SPECS, + TEMPLATE_LOCALS, + build_adapter, + cache_name, + handler_name, +) +from mcp_codemod._mappings import ( + CAMEL_FIELDS, + CLIENT_SESSION_QNAMES, + ERRORDATA_QNAMES, + FASTMCP_QNAMES, + LOWLEVEL_CTOR_POSITIONAL_PARAMS, + LOWLEVEL_REMOVED_ATTRS, + LOWLEVEL_SERVER_QNAMES, + MCPERROR_QNAMES, + MODULE_RENAMES, + PYDANTIC_URL_QNAMES, + REHOMED_IMPORTS, + REMOVED_APIS, + REMOVED_ATTRS, + REMOVED_CTOR_PARAMS, + REMOVED_MODULES, + SESSION_LIST_METHODS, + SESSION_URI_METHODS, + SYMBOL_RENAMES, + TIMEDELTA_QNAMES, + TRANSPORT_CLIENT_QNAMES, + TRANSPORT_CLIENT_REMOVED_PARAMS, + TRANSPORT_CLIENT_V1_QNAMES, + TRANSPORT_CTOR_PARAMS, + UNION_TYPE_ALIASES, +) + +__all__ = ["Diagnostic", "MARKER", "Result", "transform"] + +MARKER = "mcp-codemod" +"""The prefix of every inserted comment; `grep -rn '# mcp-codemod:'` lists the sites still needing a human.""" + +Severity = Literal["info", "review", "manual"] + +# Longest prefix wins, should overlapping keys ever be added. +_MODULE_RENAMES_LONGEST_FIRST: tuple[tuple[str, str], ...] = tuple( + sorted(MODULE_RENAMES.items(), key=lambda item: -len(item[0])) +) + +_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. + + `info`: a safe rewrite was applied, reported for the record; `review`: a rewrite + was applied but rests on a heuristic; `manual`: nothing was rewritten. + """ + + line: int + 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 _removed_module(dotted: str) -> str | None: + """Return the guidance for a module path v2 deleted, or None if it survives.""" + for root, guidance in REMOVED_MODULES.items(): + if dotted == root or dotted.startswith(root + "."): + return guidance + return None + + +def _dotted_name(dotted: str) -> cst.Attribute | cst.Name: + # A dotted path always parses to a Name or Attribute chain; `parse_expression` 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 _split_rehomed_imports( + statement: cst.SimpleStatementLine, imported: cst.ImportFrom +) -> cst.SimpleStatementLine | cst.FlattenSentinel[cst.BaseStatement] | None: + """Split `REHOMED_IMPORTS` names into their own from-import, or return None when there are none.""" + assert imported.module is not None and not isinstance(imported.names, cst.ImportStar) + module = get_full_name_for_node(imported.module) or "" + moved: list[cst.ImportAlias] = [] + kept: list[cst.ImportAlias] = [] + targets: set[str] = set() + for alias in imported.names: + name = cst.ensure_type(alias.name, cst.Name).value + target = REHOMED_IMPORTS.get((module, name)) + if target is None: + kept.append(alias) + else: + moved.append(alias.with_changes(comma=cst.MaybeSentinel.DEFAULT)) + targets.add(target) + if not moved: + return None + # Every current row rehomes to one module; revisit if a second target appears. + replacement = cst.SimpleStatementLine( + body=[cst.ImportFrom(module=_dotted_name(targets.pop()), names=moved)], + ) + if not kept: + return replacement.with_changes( + leading_lines=statement.leading_lines, trailing_whitespace=statement.trailing_whitespace + ) + kept[-1] = kept[-1].with_changes(comma=cst.MaybeSentinel.DEFAULT) + remaining = statement.with_changes(body=[imported.with_changes(names=kept)]) + return cst.FlattenSentinel([remaining, replacement]) + + +def _import_binds(node: cst.BaseSmallStatement) -> set[str]: + """The module-level names one import statement binds.""" + binds: set[str] = set() + if isinstance(node, cst.Import): + for alias in node.names: + if alias.asname is not None: + binds.add(cst.ensure_type(alias.asname.name, cst.Name).value) + else: + binds.add((get_full_name_for_node(alias.name) or "").split(".")[0]) + elif isinstance(node, cst.ImportFrom) and not isinstance(node.names, cst.ImportStar): + for alias in node.names: + bound = alias.asname.name if alias.asname is not None else alias.name + binds.add(cst.ensure_type(bound, cst.Name).value) + return binds + + +def _statement_binds(node: cst.BaseSmallStatement) -> set[str]: + """The plain names one small statement binds (assignment targets and imports).""" + binds = _import_binds(node) + if isinstance(node, cst.Assign): + for target in node.targets: + if isinstance(target.target, cst.Name): + binds.add(target.target.value) + elif isinstance(node, cst.AnnAssign) and isinstance(node.target, cst.Name): + binds.add(node.target.value) + return binds + + +def _is_v2_timeout_shape(value: cst.BaseExpression) -> bool: + """Whether a timeout expression is already valid v2: `None`, a numeric literal, + or a `.total_seconds()` call (including the one a previous run emitted).""" + if isinstance(value, cst.Name) and value.value == "None": + return True + if isinstance(value, cst.Integer | cst.Float): + return True + return ( + isinstance(value, cst.Call) + and isinstance(value.func, cst.Attribute) + and value.func.attr.value == "total_seconds" + ) + + +def _with_markers(statement: _StatementT, messages: Sequence[str]) -> _StatementT: + """Prepend a `# mcp-codemod:` comment per distinct message not already present.""" + existing = {line.comment.value for line in statement.leading_lines if line.comment is not None} + # `dict.fromkeys` rather than a set: dedupe while keeping first-seen order. + comments = list(dict.fromkeys(f"# {MARKER}: {message}" for message in messages)) + fresh = [comment for comment in comments if comment not in existing] + if not fresh: + 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 to files that import the SDK + (v1's `mcp` or v2's `mcp_types` -- a half-migrated file is still in scope). + `lowlevel_server_vars` tells a lowlevel `Server`'s decorators apart from the + syntactically identical `MCPServer` ones; `user_declared_camel` is every + allowlisted camelCase name a class body in this file declares itself. + `client_session_vars` backs the session-method rewrites, and `bound_names` / + `import_binds` back the adapter name-collision and import-injection checks. + """ + + METADATA_DEPENDENCIES = (QualifiedNameProvider,) + + 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.client_session_vars: set[str] = set() + self.bound_names: set[str] = set() + self.import_binds: set[str] = set() + # Module-level bindings only: a function-local `json = ...` cannot shadow + # an injected module import, and a TYPE_CHECKING-gated import does not + # bind at runtime, so both are computed from the module body directly. + self.module_bindings: set[str] = set() + self.module_import_binds: set[str] = set() + self._class_depth = 0 + + def visit_Module(self, node: cst.Module) -> None: + for statement in node.body: + if isinstance(statement, cst.FunctionDef | cst.ClassDef): + self.module_bindings.add(statement.name.value) + continue + if not isinstance(statement, cst.SimpleStatementLine): + continue + for small in statement.body: + for bind in _statement_binds(small): + self.module_bindings.add(bind) + if isinstance(small, cst.Import | cst.ImportFrom): + for bind in _import_binds(small): + self.module_import_binds.add(bind) + + def visit_ClassDef(self, node: cst.ClassDef) -> None: + self.bound_names.add(node.name.value) + self._class_depth += 1 + + def leave_ClassDef(self, original_node: cst.ClassDef) -> None: + self._class_depth -= 1 + + def visit_ImportFrom(self, node: cst.ImportFrom) -> None: + if not isinstance(node.names, cst.ImportStar): + for alias in node.names: + # A from-import alias (and its `as` name) is always a plain Name. + bound = cst.ensure_type(alias.asname.name if alias.asname is not None else alias.name, cst.Name) + self.import_binds.add(bound.value) + self.bound_names.add(bound.value) + if node.relative or node.module is None: + return + if _names_the_sdk(get_full_name_for_node(node.module) or ""): + 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) + # `import a.b` binds `a`; `import a.b as c` binds `c`. + bound = alias.asname.name if alias.asname is not None else None + bind = bound.value if isinstance(bound, cst.Name) else name.split(".")[0] + self.import_binds.add(bind) + self.bound_names.add(bind) + if _names_the_sdk(name): + self.imports_mcp = True + + def visit_FunctionDef(self, node: cst.FunctionDef) -> None: + self.bound_names.add(node.name.value) + for param in (*node.params.posonly_params, *node.params.params, *node.params.kwonly_params): + if param.annotation is not None: + annotated = { + q.name + for q in self.get_metadata(QualifiedNameProvider, param.annotation.annotation, frozenset()) + if q.source is not QualifiedNameSource.LOCAL + } + if annotated & CLIENT_SESSION_QNAMES: + self.client_session_vars.add(param.name.value) + + def visit_WithItem(self, node: cst.WithItem) -> None: + if node.asname is not None: + self._record_binding(node.item, node.asname.name) + + def visit_Attribute(self, node: cst.Attribute) -> None: + # Renaming `import mcp.types` to `import mcp_types` also unbinds `mcp` -- a + # problem only when a reference no module rename covers still resolves through it. + for qualified in self.get_metadata(QualifiedNameProvider, node, frozenset()): + if qualified.source is not QualifiedNameSource.LOCAL and _rename_module(qualified.name) is None: + self.unrenamed_reference_roots.add(qualified.name.split(".")[0]) + + def _record_binding(self, value: cst.BaseExpression | None, target: cst.BaseExpression) -> None: + """Record a name bound to a lowlevel `Server(...)` or a `ClientSession(...)`, `self.server` included.""" + bound = get_full_name_for_node(target) + if bound is not None and isinstance(target, cst.Name): + self.bound_names.add(bound) + if not isinstance(value, cst.Call) or bound is None: + return + qualified = { + q.name + 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(bound) + elif qualified & CLIENT_SESSION_QNAMES: + self.client_session_vars.add(bound) + + def _record_class_field(self, target: cst.BaseExpression) -> None: + """Remember a camelCase name a class body in this file declares as its own.""" + 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_binding(node.value, target.target) + + def visit_AnnAssign(self, node: cst.AnnAssign) -> None: + self._record_class_field(node.target) + self._record_binding(node.value, node.target) + + +class _V1ToV2(cst.CSTTransformer): + METADATA_DEPENDENCIES = (QualifiedNameProvider, PositionProvider) + + 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._client_session_vars = prepass.client_session_vars + self._bound_names = prepass.bound_names + self._module_bindings = prepass.module_bindings + self._module_import_binds = prepass.module_import_binds + self._add_markers = add_markers + # `ADAPTER_IMPORTS` names the emitted adapters reference; `leave_Module` injects the missing imports. + self._needed_imports: set[str] = set() + # One frame per open class definition: whether it subclasses `McpError`. + self._in_mcperror_class: list[bool] = [] + self.diagnostics: list[Diagnostic] = [] + self.rewrites: Counter[str] = Counter() + # Name nodes that are not references (an attribute's `.attr`, a `kwarg=`, a parameter). + self._not_a_reference: set[int] = set() + # Pending marker texts per open statement, attached on the way out; the bottom frame is a sentinel. + self._pending_markers: list[list[str]] = [[]] + # Calls that are a `with` item bound to a three-element tuple: the one form `leave_WithItem` rewrites. + self._narrowable_calls: set[int] = set() + + # -------------------------------------------------------------- bookkeeping + + def _qualified(self, node: cst.CSTNode) -> set[str]: + """The dotted names `node` resolves to through an import or to a builtin. + + LOCAL-only resolutions are excluded: `mcp` is the most common variable name + in real MCP code, and an attribute chain on such a variable carries a + qualified name spelled exactly like a module path (`mcp.types`). + """ + return { + q.name + 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`.""" + 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: + # The cast: pyright cannot solve `get_metadata`'s generic for `PositionProvider`. + line = cast(CodeRange, self.get_metadata(PositionProvider, node)).start.line + self.diagnostics.append(Diagnostic(line, transform, severity, message)) + if severity != "info": + 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: + # Statement-level transforms only return the statement itself or a FlattenSentinel. + if isinstance(result, cst.FlattenSentinel): + # Markers on a split statement go above its first piece. + pieces = list(result) + statement = cast("cst.SimpleStatementLine | cst.BaseCompoundStatement", pieces[0]) + pieces[0] = cast(_NodeT, _with_markers(statement, pending)) + result = cst.FlattenSentinel(pieces) + else: + narrowed = cast("cst.SimpleStatementLine | cst.BaseCompoundStatement", result) + result = cast(_NodeT, _with_markers(narrowed, pending)) + return result + + def visit_ClassDef(self, node: cst.ClassDef) -> None: + 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)) + + # ------------------------------------------------------------------ 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 "" + + # One statement-level marker covers everything imported from a deleted module. + if (module_guidance := _removed_module(module)) is not None: + self._diag(original_node, "removed_module", "manual", f"`{module}` {module_guidance}") + return updated_node + + # `QualifiedNameProvider` resolves references, not the import alias that + # creates the binding, so renames and removed-name markers apply here directly. + if not isinstance(updated_node.names, cst.ImportStar): + aliases: list[cst.ImportAlias] = [] + renamed_any = False + 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_module(qualified) or REMOVED_APIS.get(qualified)) is not None: + self._diag(original_node, "removed_api", "manual", f"`{qualified}` {guidance}") + elif new := SYMBOL_RENAMES.get(qualified): + renamed_any = True + 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 (guidance := _removed_module(dotted)) is not None: + self._diag(original_node, "removed_module", "manual", f"`{dotted}` {guidance}") + elif (renamed := _rename_module(dotted)) is not None: + renamed_any = True + self.rewrites["module_rename"] += 1 + root = dotted.split(".")[0] + # `import mcp.types` also bound `mcp`; renaming to a different root drops + # that binding, a problem only when the pre-pass saw another reference still + # resolving through it. (`PositionProvider` has no entry for an `ImportAlias`.) + if ( + alias.asname is None + and renamed.split(".")[0] != root + 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 ` of a renamed module bound the OLD module + # object; only a real import of the new module can rebind the local name. + if len(updated_node.body) != 1: + return updated_node + imported = updated_node.body[0] + 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 + # `leave_ImportFrom` has already run, so the split is against the renamed spelling. + rehomed = _split_rehomed_imports(updated_node, imported) + if rehomed is not None: + self.rewrites["import_rehome"] += 1 + return rehomed + 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: + # Keep the original line's leading lines and trailing comment (`# noqa`, ...). + return replacement.with_changes( + leading_lines=updated_node.leading_lines, trailing_whitespace=updated_node.trailing_whitespace + ) + 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 keeps its local spelling; only the original name is rewritten. + if new is not None and original_node.value == qualified.rsplit(".", 1)[-1]: + self.rewrites["symbol_rename"] += 1 + return updated_node.with_changes(value=new) + return updated_node + + def leave_Attribute(self, original_node: cst.Attribute, updated_node: cst.Attribute) -> cst.BaseExpression: + # `e.error.code` is deliberately NOT collapsed to `e.code`: v2 keeps a typed + # `.error`, so the v1 spelling still runs -- migration, not modernization. + + # An attribute the lowlevel `Server` lost, on a receiver the pre-pass proved is one. + if (get_full_name_for_node(original_node.value) or "") in self._lowlevel_server_vars and ( + lowlevel_guidance := LOWLEVEL_REMOVED_ATTRS.get(original_node.attr.value) + ) is not None: + self._diag(original_node, "removed_attr", "manual", lowlevel_guidance) + return updated_node + + qualified_names = self._qualified(original_node) + # Pydantic classmethods are gone from the union aliases on v2. + if original_node.attr.value.startswith("model_"): + receiver_names = self._qualified(original_node.value) + for qualified in receiver_names: + if (alias := UNION_TYPE_ALIASES.get(qualified)) is not None: + self._diag( + original_node, + "union_alias", + "manual", + f"`{alias}` is a plain union type on v2 with no pydantic methods: " + f"validate with `pydantic.TypeAdapter({alias})` instead", + ) + break + + dotted = get_full_name_for_node(original_node) + # The innermost node naming a renamed module (`mcp.types` inside `mcp.types.Tool`), + # rewritten only in lockstep with a backing plain import: after a bare + # `import mcp`, rewriting would leave nothing importing the new module. + if dotted in MODULE_RENAMES and dotted in qualified_names: + if dotted in self._plain_imports: + self.rewrites["module_rename"] += 1 + return _dotted_name(MODULE_RENAMES[dotted]) + # A prefix of some plain import needs nothing here: the longer node is being rewritten. + if not any(plain.startswith(f"{dotted}.") for plain in self._plain_imports): + self._diag( + original_node, + "module_rename", + "manual", + f"`{dotted}` no longer exists: import `{MODULE_RENAMES[dotted]}` and use it here instead", + ) + return updated_node + + # The mirror of `leave_Name`: removed or renamed symbols reached as a module attribute. + for qualified in qualified_names: + if qualified in REMOVED_APIS: + self._diag(original_node, "removed_api", "manual", f"`{qualified}` {REMOVED_APIS[qualified]}") + 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 the bare attribute name alone: only in an + # SDK-importing file, never on a receiver the imports prove is something else. + if not self._imports_mcp or any(not _names_the_sdk(qualified) for qualified in qualified_names): + return updated_node + + 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 file declares this same field, so some receivers + # are the user's own objects: mark every use rather than break them. + self._diag( + original_node, + "attr_snake_case", + "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 _rewrite_session_timeout(self, callee: set[str], original_node: cst.Call, updated_node: cst.Call) -> cst.Call: + """Convert `ClientSession`'s v1 `timedelta` timeout to v2's float seconds. + + Only an inline `timedelta(...)` is provably convertible; any other non-None + value gets a marker instead of a guess. + """ + if not callee & CLIENT_SESSION_QNAMES: + return updated_node + arguments = list(updated_node.args) + # Qualified-name metadata exists only for ORIGINAL nodes; the rewrite applies to updated ones. + for index, argument in enumerate(original_node.args): + positional_timeout = index == 2 and argument.keyword is None and argument.star == "" + keyword_timeout = argument.keyword is not None and argument.keyword.value == "read_timeout_seconds" + if not (positional_timeout or keyword_timeout): + continue + value = argument.value + if isinstance(value, cst.Call) and self._qualified(value.func) & TIMEDELTA_QNAMES: + self.rewrites["timeout_seconds"] += 1 + self._diag(original_node, "timeout_seconds", "info", "converted a `timedelta` timeout to seconds") + arguments[index] = arguments[index].with_changes( + value=cst.Call(func=cst.Attribute(value=arguments[index].value, attr=cst.Name("total_seconds"))) + ) + updated_node = updated_node.with_changes(args=arguments) + elif not _is_v2_timeout_shape(value): + self._diag( + original_node, + "timeout_seconds", + "manual", + "v1's `read_timeout_seconds` was a `timedelta`; v2 takes float seconds: " + "pass this value's `.total_seconds()`", + ) + return updated_node + + def _rewrite_session_method(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Call: + """Rewrite `cursor=` and pydantic-URL uris on a receiver the pre-pass proved is a `ClientSession`.""" + function = original_node.func + if ( + isinstance(function, cst.Attribute) + and (get_full_name_for_node(function.value) or "") in self._client_session_vars + ): + method = function.attr.value + if method in SESSION_LIST_METHODS and len(original_node.args) == 1: + argument = original_node.args[0] + if argument.keyword is not None and argument.keyword.value == "cursor": + self._needed_imports.add("mcp_types") + self.rewrites["session_cursor"] += 1 + self._diag(original_node, "session_cursor", "info", "wrapped `cursor=` in `PaginatedRequestParams`") + wrapped = cst.Call( + func=_dotted_name("mcp_types.PaginatedRequestParams"), + args=[ + cst.Arg( + keyword=cst.Name("cursor"), + value=updated_node.args[0].value, + equal=cst.AssignEqual( + whitespace_before=cst.SimpleWhitespace(""), + whitespace_after=cst.SimpleWhitespace(""), + ), + ) + ], + ) + updated_node = updated_node.with_changes( + args=[updated_node.args[0].with_changes(keyword=cst.Name("params"), value=wrapped)] + ) + elif method in SESSION_URI_METHODS and len(original_node.args) == 1: + value = original_node.args[0].value + if ( + original_node.args[0].keyword is None + and isinstance(value, cst.Call) + and self._qualified(value.func) & PYDANTIC_URL_QNAMES + and len(value.args) == 1 + and value.args[0].keyword is None + ): + self.rewrites["uri_str"] += 1 + self._diag(original_node, "uri_str", "info", f"`{method}` takes a plain `str` uri on v2") + unwrapped = cst.ensure_type(updated_node.args[0].value, cst.Call).args[0].value + updated_node = updated_node.with_changes(args=[updated_node.args[0].with_changes(value=unwrapped)]) + return updated_node + + def _rewrite_uri_kwargs(self, callee: set[str], original_node: cst.Call, updated_node: cst.Call) -> cst.Call: + """Unwrap a pydantic URL passed as `uri=`: rewritten on a proven SDK callee, marked elsewhere.""" + # Qualified-name metadata exists only for ORIGINAL nodes; the rewrite applies to updated ones. + for index, argument in enumerate(original_node.args): + value = argument.value + if ( + argument.keyword is not None + and argument.keyword.value == "uri" + and isinstance(value, cst.Call) + and self._qualified(value.func) & PYDANTIC_URL_QNAMES + and len(value.args) == 1 + and value.args[0].keyword is None + ): + if any(name == "mcp" or name.startswith(("mcp.", "mcp_types.")) for name in callee): + self.rewrites["uri_str"] += 1 + self._diag(original_node, "uri_str", "info", "resource URIs are plain `str` on v2") + arguments = list(updated_node.args) + unwrapped = cst.ensure_type(arguments[index].value, cst.Call).args[0].value + arguments[index] = arguments[index].with_changes(value=unwrapped) + updated_node = updated_node.with_changes(args=arguments) + elif self._imports_mcp: + self._diag( + original_node, + "uri_str", + "manual", + "v2 resource URIs are plain `str`: drop this URL wrapper if the value lands in an mcp type", + ) + return updated_node + + def _flag_union_construction(self, callee: set[str], original_node: cst.Call) -> None: + """Flag construction of a v1 RootModel wrapper that is a plain union alias on v2.""" + for qualified in callee: + if (alias := UNION_TYPE_ALIASES.get(qualified)) is not None: + self._diag( + original_node, + "union_alias", + "manual", + f"`{alias}` is a plain union type on v2 and cannot be constructed: " + f"build the concrete message type instead", + ) + + def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Call: + callee = self._qualified(original_node.func) + + # v1's single-`ErrorData` constructor maps exactly onto v2's classmethod + # `MCPError.from_error_data(...)`; `leave_Name` has already renamed the name itself. + if callee & MCPERROR_QNAMES and len(original_node.args) == 1: + self.rewrites["mcperror_ctor"] += 1 + return updated_node.with_changes( + func=cst.Attribute(value=updated_node.func, attr=cst.Name("from_error_data")) + ) + + # `super().__init__(...)` cannot become a classmethod call, so an inline + # `ErrorData(...)` is flattened into v2's `(code, message, data=None)`. + if self._is_mcperror_super_init(original_node) and len(original_node.args) == 1: + wrapped = original_node.args[0].value + if isinstance(wrapped, cst.Call) and self._qualified(wrapped.func) & ERRORDATA_QNAMES: + 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", + ) + + self._flag_union_construction(callee, original_node) + + # camelCase kwargs still work at RUNTIME on v2 (fields accept their aliases) + # but fail type-checking against the snake_case `__init__` signatures. The + # rename cannot break the call, so no review marker even for the risky tier. + if any(name == "mcp" or name.startswith(("mcp.", "mcp_types.")) for name in callee): + arguments: list[cst.Arg] = [] + renamed_any = False + 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 moved off the constructor; where they belong depends on + # how the server is started, so they stay put (v2 rejects them loudly). + if callee & FASTMCP_QNAMES: + for index, argument in enumerate(original_node.args): + keyword = argument.keyword.value if argument.keyword is not None else "" + # v1's positional order was `(name, instructions, ...)`; v2's second + # parameter is `title`, so later positionals would silently land wrong. + 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]}") + + # v2's lowlevel `Server` ctor is keyword-only after `name` but kept v1's + # parameter names and order, so positionals convert one for one; a `*`-splat + # hides how many positions it fills and is left for v2 to reject. + if ( + callee & LOWLEVEL_SERVER_QNAMES + and 1 < len(original_node.args) <= 1 + len(LOWLEVEL_CTOR_POSITIONAL_PARAMS) + and not any(argument.star for argument in original_node.args) + ): + arguments = [] + for index, argument in enumerate(updated_node.args): + if index > 0 and argument.keyword is None: + self.rewrites["lowlevel_ctor_kwargs"] += 1 + argument = argument.with_changes( + keyword=cst.Name(LOWLEVEL_CTOR_POSITIONAL_PARAMS[index - 1]), + equal=cst.AssignEqual( + whitespace_before=cst.SimpleWhitespace(""), whitespace_after=cst.SimpleWhitespace("") + ), + ) + arguments.append(argument) + if arguments != list(updated_node.args): + updated_node = updated_node.with_changes(args=arguments) + + updated_node = self._rewrite_session_timeout(callee, original_node, updated_node) + updated_node = self._rewrite_session_method(original_node, updated_node) + updated_node = self._rewrite_uri_kwargs(callee, original_node, updated_node) + + # The keyword check lives here so it fires however the call is used; only the + # `as (read, write, _)` with-item form gets its unpacking rewritten + # (`leave_WithItem` does), every other use of the v1 name is flagged. + if callee & TRANSPORT_CLIENT_QNAMES: + for argument in original_node.args: + keyword = argument.keyword.value if argument.keyword is not None else "" + 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 `getattr`/`hasattr`/`setattr` name string is the one string position the + # rename applies to; other literals never are -- camelCase IS the wire format. + if ( + self._imports_mcp + and callee & {"builtins.getattr", "builtins.hasattr", "builtins.setattr"} + 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 _lowlevel_decorator(self, node: cst.FunctionDef) -> tuple[str, str, cst.Call] | None: + """The (receiver, kind, decorator call) of a lowlevel registration, or None.""" + for wrapper in node.decorators: + decorator = wrapper.decorator + if ( + isinstance(decorator, cst.Call) + and isinstance(decorator.func, cst.Attribute) + and (get_full_name_for_node(decorator.func.value) or "") in self._lowlevel_server_vars + and decorator.func.attr.value in LOWLEVEL_HANDLER_SPECS + ): + return ( + cast(str, get_full_name_for_node(decorator.func.value)), + decorator.func.attr.value, + decorator, + ) + return None + + def _lowlevel_blocker(self, node: cst.FunctionDef, receiver: str, kind: str, decorator: cst.Call) -> str | None: + """Why this decorator site cannot be rewritten, or None when it can. + + Each check guards a way the generated adapter could silently misbehave + rather than fail loudly. + """ + if len(node.decorators) > 1: + return "another decorator is stacked on it" + if "." in receiver: + return "the server is reached through an attribute" + if self._in_mcperror_class: + return "the handler is defined in a class body" + if node.asynchronous is None: + return "the handler is not `async def`" + arguments = decorator.args + if kind == "call_tool" and len(arguments) == 1: + argument = arguments[0] + if not ( + argument.keyword is not None + and argument.keyword.value == "validate_input" + and isinstance(argument.value, cst.Name) + and argument.value.value in ("True", "False") + ): + return "the decorator call has arguments the codemod cannot evaluate" + elif arguments: + return "the decorator call has arguments the codemod cannot evaluate" + parameters = node.params + if ( + parameters.star_kwarg is not None + or parameters.kwonly_params + or not isinstance(parameters.star_arg, cst.MaybeSentinel) + ): + return "the handler signature does not match the v1 form" + positional = [*parameters.posonly_params, *parameters.params] + required = sum(1 for parameter in positional if parameter.default is None) + if not required <= LOWLEVEL_HANDLER_SPECS[kind].arity <= len(positional): + return "the handler signature does not match the v1 form" + emitted = {handler_name(node.name.value)} + if kind == "call_tool": + emitted.add(cache_name(receiver)) + if emitted & self._bound_names: + return "a generated name is already bound in this file" + if node.name.value in TEMPLATE_LOCALS[kind]: + return "the handler's name collides with a name the generated adapter uses" + # A module-level non-import binding of a name the adapter references would + # shadow the injected import (`json = None` breaks `json.dumps` at runtime). + needed = set(LOWLEVEL_HANDLER_SPECS[kind].imports) | {"AnyUrl"} + if needed & (self._module_bindings - self._module_import_binds): + return "a name the generated adapter needs is already bound in this file" + return None + + def leave_FunctionDef( + self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef + ) -> cst.BaseStatement | cst.FlattenSentinel[cst.BaseStatement]: + found = self._lowlevel_decorator(original_node) + if found is None: + return updated_node + receiver, kind, decorator = found + blocked = self._lowlevel_blocker(original_node, receiver, kind, decorator) + if blocked is not None: + register = ( + "add_notification_handler" if LOWLEVEL_HANDLER_SPECS[kind].notification else "add_request_handler" + ) + self._diag( + original_node, + "lowlevel_registration", + "manual", + f"the lowlevel `@{receiver}.{kind}()` decorator was removed and this site was not rewritten " + f"automatically ({blocked}): register the handler with `{receiver}.{register}(...)` " + f"taking `(ctx, params)`", + ) + return updated_node + validate_input = True + for argument in decorator.args: + validate_input = cst.ensure_type(argument.value, cst.Name).value == "True" + # v1 always passed `AnyUrl` to the uri kinds, but a handler annotated + # `uri: str` declared its own contract -- honor it and skip the wrapper. + uri_as_str = False + if kind in ("read_resource", "subscribe_resource", "unsubscribe_resource"): + parameter = [*original_node.params.posonly_params, *original_node.params.params][0] + annotation = parameter.annotation.annotation if parameter.annotation is not None else None + uri_as_str = isinstance(annotation, cst.Name) and annotation.value == "str" + if not uri_as_str: + self._needed_imports.add("AnyUrl") + spec = LOWLEVEL_HANDLER_SPECS[kind] + self._needed_imports.update(spec.imports) + self.rewrites["lowlevel_registration"] += 1 + self._diag( + original_node, + "lowlevel_registration", + "info", + f"registered `{original_node.name.value}` for `{kind}` through a generated v1-compat adapter", + ) + adapter = list( + cst.parse_module( + build_adapter( + kind, original_node.name.value, receiver, validate_input=validate_input, uri_as_str=uri_as_str + ) + ).body + ) + # `parse_module` files leading blank lines under `Module.header`; restore the separation. + adapter[0] = adapter[0].with_changes(leading_lines=[cst.EmptyLine(), cst.EmptyLine()]) + stripped = updated_node.with_changes( + decorators=[], + leading_lines=[*updated_node.leading_lines, *updated_node.decorators[0].leading_lines], + ) + return cst.FlattenSentinel([stripped, *adapter]) + + def visit_WithItem(self, node: cst.WithItem) -> None: + # Only `as (a, b, c)` can have its unpacking rewritten; every other use of a + # v1 client call gets the yield-shape marker from `leave_Call` instead. + if ( + isinstance(node.item, cst.Call) + and node.asname is not None + 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: + # `leave_Call` covers the removed keywords; this narrows the one rewritable form. + if not isinstance(original_node.item, cst.Call): + return updated_node + if not self._qualified(original_node.item.func) & TRANSPORT_CLIENT_QNAMES: + 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 + # A third element bound to a real name (not `_`) leaves broken uses behind. + third = elements[2].value + if not (isinstance(third, cst.Name) and third.value == "_"): + self._diag( + 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: + # Imports the generated adapters need. Inserted at the TOP of the module + # (below only the docstring and `__future__` imports) so they precede the + # registration code wherever the decorator sat -- a mid-file import as the + # anchor would leave the adapter running before its imports bind. Dedup is + # against the updated module's top-level import binds, so a rename this + # run produced (`import mcp_types as types`) counts and a conditional or + # function-local import does not. + if self._needed_imports: + bound: set[str] = set() + body = list(updated_node.body) + insert_at = 0 + for index, statement in enumerate(body): + if not isinstance(statement, cst.SimpleStatementLine): + continue + for small in statement.body: + bound |= _import_binds(small) + is_docstring = index == 0 and isinstance(small, cst.Expr) + is_future = ( + isinstance(small, cst.ImportFrom) + and small.module is not None + and get_full_name_for_node(small.module) == "__future__" + ) + if (is_docstring or is_future) and insert_at == index: + insert_at = index + 1 + missing = [name for name in ADAPTER_IMPORTS if name in self._needed_imports and name not in bound] + if missing: + body[insert_at:insert_at] = [cst.parse_statement(ADAPTER_IMPORTS[name]) for name in missing] + updated_node = updated_node.with_changes(body=body) + + # libCST parses a comment above the module's FIRST statement into + # `Module.header`, not `leading_lines`, so `_with_markers` cannot see a + # marker a previous run put there; drop any already rendered in the header. + if not updated_node.body: + return updated_node + in_header = {line.comment.value for line in original_node.header if line.comment is not None} + 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 output is always valid Python with the input's formatting preserved outside + rewrites; unless `add_markers` is false, each non-info diagnostic also gets an + inline `# mcp-codemod:` comment. + + Raises: + libcst.ParserSyntaxError: if `source` is not parseable as Python. + """ + 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..b9ab13f81 --- /dev/null +++ b/src/mcp-codemod/mcp_codemod/cli.py @@ -0,0 +1,120 @@ +"""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._dependencies import DependencyReport, update_dependencies +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, + dependencies: Sequence[DependencyReport], + *, + roots: Sequence[Path], + dry_run: bool, + markers: bool, +) -> None: + for file in report.files: + if file.result is None: + print(f"{file.path}: failed ({file.error})", file=sys.stderr) + 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") + for dependency in dependencies: + if dependency.error is not None: + print(f"{dependency.path}: failed ({dependency.error})", file=sys.stderr) + elif dependency.changed: + flagged = sum(1 for diagnostic in dependency.diagnostics if diagnostic.severity != "info") + updated = len(dependency.diagnostics) - flagged + note = "mcp requirement updated for v2" if updated else f"{flagged} need review" + print(f"{dependency.path}: {note}") + + print(f"\n{len(report.changed)} of {len(report.files)} files rewritten.") + severities = report.diagnostics + pending = [ + (dependency.path, diagnostic) + for dependency in dependencies + for diagnostic in dependency.diagnostics + if diagnostic.severity != "info" + ] + attention = severities["review"] + severities["manual"] + len(pending) + if attention: + if markers and not dry_run: + targets = " ".join(str(root) for root in roots) + 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}") + for path, diagnostic in pending: + print(f" {path}:{diagnostic.line}: {diagnostic.message}") + if dry_run: + print("Dry run: nothing was written.") + failures = len(report.failed) + sum(1 for dependency in dependencies if dependency.error is not None) + if failures: + print(f"{failures} files failed.", file=sys.stderr) + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the codemod, returning 1 if any file failed and 0 otherwise.""" + args = _build_parser().parse_args(argv) + report = run(discover(args.paths), write=not args.dry_run, add_markers=not args.no_markers) + dependencies = update_dependencies(args.paths, write=not args.dry_run, add_markers=not args.no_markers) + if args.diff: + _print_diffs(report) + _print_summary(report, dependencies, roots=args.paths, dry_run=args.dry_run, markers=not args.no_markers) + failed = report.failed or any(dependency.error is not None for dependency in dependencies) + return 1 if failed else 0 diff --git a/src/mcp-codemod/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..1211f37ff --- /dev/null +++ b/src/mcp-codemod/pyproject.toml @@ -0,0 +1,60 @@ +[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", + # Parses the PEP 508 requirement strings the dependency updater rewrites. + "packaging>=24.0", +] + +[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_adapters.py b/tests/codemod/test_adapters.py new file mode 100644 index 000000000..c577c34e7 --- /dev/null +++ b/tests/codemod/test_adapters.py @@ -0,0 +1,178 @@ +"""The generated lowlevel adapters, pinned against the installed v2 at runtime. + +Real v1 registration code is migrated and served to a v1-shaped `ClientSession`, +so every template is proven against the installed package, not expectations. +""" + +import textwrap +from typing import Any, cast + +import anyio +import mcp_types +import pytest +from mcp_codemod import transform +from mcp_codemod._adapters import ADAPTER_IMPORTS, LOWLEVEL_HANDLER_SPECS, build_adapter + +from mcp import ClientSession +from mcp.server.lowlevel import Server +from mcp.shared.memory import create_client_server_memory_streams + +KITCHEN_SINK_V1 = textwrap.dedent("""\ + import mcp.types as types + from mcp.server.lowlevel import Server + from pydantic import AnyUrl + + app = Server("kitchen-sink") + SUBSCRIBED: list[str] = [] + + + @app.list_tools() + async def list_tools() -> list[types.Tool]: + return [ + types.Tool( + name="add", + description="Add two numbers", + inputSchema={ + "type": "object", + "required": ["a", "b"], + "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, + }, + ) + ] + + + @app.call_tool() + async def call_tool(name: str, arguments: dict) -> list[types.ContentBlock]: + if name != "add": + raise ValueError(f"Unknown tool: {name}") + return [types.TextContent(type="text", text=str(arguments["a"] + arguments["b"]))] + + + @app.list_resources() + async def list_resources() -> list[types.Resource]: + return [types.Resource(uri=AnyUrl("demo://greeting"), name="greeting", mimeType="text/plain")] + + + @app.read_resource() + async def read_resource(uri: AnyUrl) -> str: + return f"resource at {uri}" + + + @app.subscribe_resource() + async def subscribe(uri: AnyUrl) -> None: + SUBSCRIBED.append(str(uri)) + + + @app.get_prompt() + async def get_prompt(name: str, arguments: dict | None) -> types.GetPromptResult: + return types.GetPromptResult( + messages=[ + types.PromptMessage(role="user", content=types.TextContent(type="text", text=f"prompt {name}")) + ] + ) +""") + + +def _load_migrated(source: str) -> dict[str, Any]: + result = transform(source) + assert result.code.count("# mcp-codemod:") == 0, result.code + namespace: dict[str, Any] = {"__name__": "migrated"} + exec(compile(result.code, "migrated.py", "exec"), namespace) + return namespace + + +@pytest.mark.anyio +async def test_a_migrated_kitchen_sink_server_serves_a_v1_client_over_the_legacy_protocol() -> None: + """Unknown tools and schema-invalid arguments come back as `is_error` results, not protocol errors.""" + namespace = _load_migrated(KITCHEN_SINK_V1) + app = cast(Server[Any], namespace["app"]) + async with create_client_server_memory_streams() as (client_streams, server_streams): + async with anyio.create_task_group() as task_group: + + async def serve() -> None: + await app.run(server_streams[0], server_streams[1], app.create_initialization_options()) + + task_group.start_soon(serve) + async with ClientSession(client_streams[0], client_streams[1]) as session: + with anyio.fail_after(5): + init = await session.initialize() + assert init.protocol_version == "2025-11-25" + tools = await session.list_tools() + assert [tool.name for tool in tools.tools] == ["add"] + ok = await session.call_tool("add", {"a": 2, "b": 3}) + assert not ok.is_error + assert cast(mcp_types.TextContent, ok.content[0]).text == "5" + unknown = await session.call_tool("nope", {}) + assert unknown.is_error + assert "Unknown tool: nope" in cast(mcp_types.TextContent, unknown.content[0]).text + invalid = await session.call_tool("add", {"a": 1}) + assert invalid.is_error + assert "Input validation error" in cast(mcp_types.TextContent, invalid.content[0]).text + resources = await session.list_resources() + assert resources.resources[0].name == "greeting" + read = await session.read_resource("demo://greeting") + assert cast(mcp_types.TextResourceContents, read.contents[0]).text == "resource at demo://greeting" + await session.subscribe_resource("demo://greeting") + assert namespace["SUBSCRIBED"] == ["demo://greeting"] + prompt = await session.get_prompt("hello", None) + content = cast(mcp_types.TextContent, prompt.messages[0].content) + assert content.text == "prompt hello" + task_group.cancel_scope.cancel() + + +def test_the_migration_is_idempotent_on_its_own_output() -> None: + once = transform(KITCHEN_SINK_V1).code + assert transform(once).code == once + + +def test_every_template_renders_to_parseable_python() -> None: + import ast + + for kind in LOWLEVEL_HANDLER_SPECS: + ast.parse(build_adapter(kind, "user_fn", "srv")) + ast.parse(build_adapter(kind, "user_fn", "srv", validate_input=False)) + + +def test_no_template_emits_a_2026_era_surface() -> None: + """The codemod's goal forbids routing users onto 2026-era features.""" + for kind in LOWLEVEL_HANDLER_SPECS: + block = build_adapter(kind, "user_fn", "srv") + for forbidden in ("InputRequiredResult", "subscriptions/listen", "cache_hints", "extensions", "Resolve"): + assert forbidden not in block, (kind, forbidden) + + +def test_every_adapter_import_statement_resolves_on_the_installed_v2() -> None: + for statement in ADAPTER_IMPORTS.values(): + exec(statement, {}) + + +def test_every_spec_params_model_exists_in_mcp_types() -> None: + """The registration passes `mcp_types.` by name; the name must exist.""" + for kind in LOWLEVEL_HANDLER_SPECS: + rendered = build_adapter(kind, "user_fn", "srv") + registration = [ + line + for line in rendered.splitlines() + if "add_request_handler" in line or "add_notification_handler" in line + ] + assert len(registration) == 1, kind + model = registration[0].split("mcp_types.")[1].split(",")[0] + assert hasattr(mcp_types, model), (kind, model) + + +def test_every_spec_method_registers_on_the_installed_server() -> None: + """Pins the emitted method strings and registration calls against the installed `Server`.""" + server: Server[Any] = Server("ratchet") + + async def handler(ctx: object, params: object) -> None: + return None + + anyio.run(handler, None, None) + for kind in LOWLEVEL_HANDLER_SPECS: + rendered = build_adapter(kind, "user_fn", "srv") + line = next(line for line in rendered.splitlines() if ".add_" in line) + method = line.split('"')[1] + if "add_notification_handler" in line: + server.add_notification_handler(method, cast("type[Any]", object), cast(Any, handler)) + else: + server.add_request_handler(method, cast("type[Any]", object), cast(Any, handler)) diff --git a/tests/codemod/test_cli.py b/tests/codemod/test_cli.py new file mode 100644 index 000000000..a83bb82d8 --- /dev/null +++ b/tests/codemod/test_cli.py @@ -0,0 +1,193 @@ +"""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: + 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: + 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: + 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: + 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: + 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: + with pytest.raises(SystemExit): + main(["--version"]) + assert capsys.readouterr().out.startswith("mcp-codemod ") + + +def test_a_missing_migration_argument_is_an_argparse_error() -> None: + 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: + 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: + 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: + 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: + (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 summary lists each site + directly instead of the grep hint; info-only renames are excluded.""" + target = tmp_path / "server.py" + target.write_text( + 'from mcp.server.fastmcp import FastMCP\n\nmcp = FastMCP("demo", mount_path="/x")\nprint(tool.inputSchema)\n' + ) + 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 + + +def test_the_cli_updates_dependency_files_alongside_the_sources( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Dependency files migrate in the same run and their flags join the still-need-a-human accounting.""" + (tmp_path / "server.py").write_text("from mcp.server.fastmcp import FastMCP\n") + (tmp_path / "pyproject.toml").write_text('[project]\ndependencies = ["mcp>=1.2,<2"]\n') + (tmp_path / "requirements.txt").write_text("mcp[ws]==1.9.4\n") + code = main(["v1-to-v2", str(tmp_path)]) + captured = capsys.readouterr() + assert code == 0 + assert "mcp.server.mcpserver" in (tmp_path / "server.py").read_text() + assert '"mcp>=2,<3"' in (tmp_path / "pyproject.toml").read_text() + assert "# mcp-codemod:" in (tmp_path / "requirements.txt").read_text() + assert f"{tmp_path / 'pyproject.toml'}: mcp requirement updated for v2" in captured.out + assert f"{tmp_path / 'requirements.txt'}: 1 need review" in captured.out + assert "1 sites still need a human" in captured.out + + +def test_a_broken_pyproject_fails_the_run_without_stopping_it( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (tmp_path / "server.py").write_text("from mcp.server.fastmcp import FastMCP\n") + (tmp_path / "pyproject.toml").write_text("[broken") + code = main(["v1-to-v2", str(tmp_path)]) + captured = capsys.readouterr() + assert code == 1 + assert "mcp.server.mcpserver" in (tmp_path / "server.py").read_text() + assert "TOMLDecodeError" in captured.err + + +def test_no_markers_lists_dependency_sites_in_the_summary(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + requirements = tmp_path / "requirements.txt" + requirements.write_text("mcp[ws]==1.9.4\n") + code = main(["v1-to-v2", "--no-markers", str(tmp_path)]) + captured = capsys.readouterr() + assert code == 0 + assert requirements.read_text() == "mcp[ws]==1.9.4\n" + assert f"{requirements}:1: the `ws` extra was removed" in captured.out diff --git a/tests/codemod/test_dependencies.py b/tests/codemod/test_dependencies.py new file mode 100644 index 000000000..014713154 --- /dev/null +++ b/tests/codemod/test_dependencies.py @@ -0,0 +1,362 @@ +"""Dependency-file updating in `mcp_codemod._dependencies`.""" + +import textwrap +from pathlib import Path + +from inline_snapshot import snapshot +from mcp_codemod._dependencies import update_dependencies + + +def _write(path: Path, content: str) -> Path: + path.write_text(textwrap.dedent(content)) + return path + + +def test_a_v1_only_mcp_requirement_is_rewritten_to_the_v2_range(tmp_path: Path) -> None: + """Only the specifier changes; the rest of the file keeps its exact formatting.""" + pyproject = _write( + tmp_path / "pyproject.toml", + """\ + [project] + name = "demo" + dependencies = [ + "httpx>=0.27", + "mcp>=1.2,<2", + ] + """, + ) + reports = update_dependencies([tmp_path], write=True) + assert [report.changed for report in reports] == [True] + assert pyproject.read_text() == snapshot( + """\ +[project] +name = "demo" +dependencies = [ + "httpx>=0.27", + "mcp>=2,<3", +] +""" + ) + + +def test_a_requirement_that_already_admits_v2_is_untouched(tmp_path: Path) -> None: + pyproject = _write( + tmp_path / "pyproject.toml", + """\ + [project] + dependencies = ["mcp>=1.0", "anyio"] + + [project.optional-dependencies] + bare = ["mcp"] + """, + ) + original = pyproject.read_text() + assert update_dependencies([tmp_path], write=True) == [] + assert pyproject.read_text() == original + + +def test_extras_and_environment_markers_keep_their_original_spelling(tmp_path: Path) -> None: + pyproject = _write( + tmp_path / "pyproject.toml", + """\ + [project] + dependencies = ["mcp[cli,rich]==1.9.4 ; python_version >= '3.10'"] + """, + ) + update_dependencies([tmp_path], write=True) + assert pyproject.read_text() == snapshot( + """\ +[project] +dependencies = ["mcp[cli,rich]>=2,<3 ; python_version >= '3.10'"] +""" + ) + + +def test_a_requirement_with_a_removed_extra_is_marked_not_rewritten(tmp_path: Path) -> None: + pyproject = _write( + tmp_path / "pyproject.toml", + """\ + [project] + dependencies = [ + "mcp[ws]>=1.2,<2", + ] + """, + ) + reports = update_dependencies([tmp_path], write=True) + assert [diagnostic.severity for report in reports for diagnostic in report.diagnostics] == ["manual"] + assert pyproject.read_text() == snapshot( + """\ +[project] +dependencies = [ + # mcp-codemod: the `ws` extra was removed with the WebSocket transport; set `mcp>=2,<3` by hand + "mcp[ws]>=1.2,<2", +] +""" + ) + + +def test_optional_dependencies_and_dependency_groups_are_updated(tmp_path: Path) -> None: + """An `include-group` table entry is passed over, not treated as a requirement.""" + pyproject = _write( + tmp_path / "pyproject.toml", + """\ + [project.optional-dependencies] + server = ["mcp~=1.9"] + + [dependency-groups] + dev = ["pytest", {include-group = "lint"}, "mcp==1.16.0"] + lint = ["ruff"] + """, + ) + update_dependencies([tmp_path], write=True) + content = pyproject.read_text() + assert 'server = ["mcp>=2,<3"]' in content + assert '"mcp>=2,<3"]' in content + assert "1.16.0" not in content + + +def test_a_poetry_constraint_is_marked_for_a_hand_update(tmp_path: Path) -> None: + """Poetry's own constraint syntax cannot be rewritten safely, so the entry is marked.""" + pyproject = _write( + tmp_path / "pyproject.toml", + """\ + [tool.poetry.dependencies] + python = "^3.10" + mcp = "^1.2" + """, + ) + reports = update_dependencies([tmp_path], write=True) + assert [diagnostic.severity for report in reports for diagnostic in report.diagnostics] == ["manual"] + assert pyproject.read_text() == snapshot( + """\ +[tool.poetry.dependencies] +python = "^3.10" +# mcp-codemod: update this Poetry constraint for v2 (`>=2,<3`) by hand +mcp = "^1.2" +""" + ) + + +def test_requirements_txt_lines_are_rewritten_and_keep_their_comments(tmp_path: Path) -> None: + requirements = _write( + tmp_path / "requirements.txt", + """\ + -r base.txt + httpx>=0.27 + mcp[cli]>=1.2,<2 # the SDK + not a requirement!! + """, + ) + update_dependencies([tmp_path], write=True) + assert requirements.read_text() == snapshot( + """\ +-r base.txt +httpx>=0.27 +mcp[cli]>=2,<3 # the SDK +not a requirement!! +""" + ) + + +def test_a_requirements_line_with_a_removed_extra_is_marked(tmp_path: Path) -> None: + requirements = _write(tmp_path / "requirements-dev.txt", "mcp[ws]==1.9.4\n") + reports = update_dependencies([tmp_path], write=True) + assert [diagnostic.severity for report in reports for diagnostic in report.diagnostics] == ["manual"] + assert requirements.read_text() == snapshot( + """\ +# mcp-codemod: the `ws` extra was removed with the WebSocket transport; set `mcp>=2,<3` by hand +mcp[ws]==1.9.4 +""" + ) + + +def test_a_second_run_over_updated_files_is_a_noop(tmp_path: Path) -> None: + _write(tmp_path / "pyproject.toml", '[project]\ndependencies = ["mcp[ws]<2", "mcp==1.9"]\n') + _write(tmp_path / "requirements.txt", "mcp[ws]==1.9.4\nmcp==1.2\n") + update_dependencies([tmp_path], write=True) + first_pyproject = (tmp_path / "pyproject.toml").read_text() + first_requirements = (tmp_path / "requirements.txt").read_text() + update_dependencies([tmp_path], write=True) + assert (tmp_path / "pyproject.toml").read_text() == first_pyproject + assert (tmp_path / "requirements.txt").read_text() == first_requirements + + +def test_an_unparseable_pyproject_is_reported_and_left_untouched(tmp_path: Path) -> None: + pyproject = _write(tmp_path / "pyproject.toml", "[project\ndependencies = [") + original = pyproject.read_text() + reports = update_dependencies([tmp_path], write=True) + assert len(reports) == 1 + assert reports[0].error is not None and "TOMLDecodeError" in reports[0].error + assert pyproject.read_text() == original + + +def test_nothing_is_written_when_write_is_false(tmp_path: Path) -> None: + pyproject = _write(tmp_path / "pyproject.toml", '[project]\ndependencies = ["mcp<2"]\n') + original = pyproject.read_text() + reports = update_dependencies([tmp_path], write=False) + assert reports[0].changed + assert pyproject.read_text() == original + + +def test_dependency_files_inside_ignored_directories_are_skipped(tmp_path: Path) -> None: + """A pyproject inside `.venv` or `node_modules` is vendored, not the user's.""" + (tmp_path / ".venv").mkdir() + _write(tmp_path / ".venv" / "pyproject.toml", '[project]\ndependencies = ["mcp<2"]\n') + assert update_dependencies([tmp_path], write=True) == [] + + +def test_a_file_path_argument_yields_no_dependency_updates(tmp_path: Path) -> None: + """Dependency files are discovered under directory arguments only.""" + target = tmp_path / "server.py" + target.write_text("from mcp import ClientSession\n") + assert update_dependencies([target], write=True) == [] + + +def test_a_poetry_inline_dependency_table_still_gets_a_diagnostic(tmp_path: Path) -> None: + """An inline table leaves no line to place a marker on, but the diagnostic still fires.""" + pyproject = _write(tmp_path / "pyproject.toml", '[tool.poetry]\ndependencies = { mcp = "^1.2" }\n') + original = pyproject.read_text() + reports = update_dependencies([tmp_path], write=True) + assert [diagnostic.severity for report in reports for diagnostic in report.diagnostics] == ["manual"] + assert pyproject.read_text() == original + + +def test_a_requirement_hidden_behind_toml_escapes_is_left_alone(tmp_path: Path) -> None: + """A raw TOML spelling that differs from its parsed value cannot be safely rewritten.""" + pyproject = _write(tmp_path / "pyproject.toml", '[project]\ndependencies = ["mcp \\u003c 2"]\n') + original = pyproject.read_text() + assert update_dependencies([tmp_path], write=True) == [] + assert pyproject.read_text() == original + + +def test_non_list_table_values_and_comment_lines_are_passed_over(tmp_path: Path) -> None: + _write( + tmp_path / "pyproject.toml", + """\ + [project.optional-dependencies] + weird = "not-a-list" + + [dependency-groups] + odd = "also-not-a-list" + """, + ) + _write(tmp_path / "requirements.txt", "# just a comment\n\nhttpx\n") + assert update_dependencies([tmp_path], write=True) == [] + + +def test_add_markers_false_reports_without_writing_comments(tmp_path: Path) -> None: + pyproject = _write(tmp_path / "pyproject.toml", '[project]\ndependencies = ["mcp[ws]<2"]\n') + original = pyproject.read_text() + reports = update_dependencies([tmp_path], write=True, add_markers=False) + assert [diagnostic.severity for report in reports for diagnostic in report.diagnostics] == ["manual"] + assert not reports[0].changed + assert pyproject.read_text() == original + + +def test_constraints_already_on_v2_are_never_touched(tmp_path: Path) -> None: + pyproject = _write( + tmp_path / "pyproject.toml", + """\ + [project] + dependencies = ["mcp==2.1.4"] + + [project.optional-dependencies] + alpha = ["mcp==2.0.0a1"] + narrow = ["mcp>=2.1,<2.2"] + """, + ) + original = pyproject.read_text() + assert update_dependencies([tmp_path], write=True) == [] + assert pyproject.read_text() == original + + +def test_a_removed_extra_is_flagged_even_when_the_specifier_admits_v2(tmp_path: Path) -> None: + """On v2 the extra silently vanishes, so the extra check outranks the specifier check.""" + pyproject = _write(tmp_path / "pyproject.toml", '[project]\ndependencies = ["mcp[ws]>=1.0"]\n') + reports = update_dependencies([tmp_path], write=True) + assert [diagnostic.severity for report in reports for diagnostic in report.diagnostics] == ["manual"] + assert "# mcp-codemod:" in pyproject.read_text() + assert "mcp[ws]>=1.0" in pyproject.read_text() + + +def test_a_url_requirement_is_flagged_not_rewritten(tmp_path: Path) -> None: + """A VCS/URL reference has no specifier to rewrite but may pin v1 forever.""" + requirements = _write(tmp_path / "requirements.txt", "mcp @ git+https://github.com/o/r@v1.9.4\n") + reports = update_dependencies([tmp_path], write=True) + assert [diagnostic.severity for report in reports for diagnostic in report.diagnostics] == ["manual"] + assert "pins `mcp` by URL" in requirements.read_text() + + +def test_an_unparseable_mcp_line_is_flagged(tmp_path: Path) -> None: + """Passing over an unparseable `mcp` line silently would hide a v1 pin.""" + requirements = _write( + tmp_path / "requirements.txt", + "httpx==0.27.0\nmcp==1.9.4 --hash=sha256:abc123\n", + ) + reports = update_dependencies([tmp_path], write=True) + assert [diagnostic.severity for report in reports for diagnostic in report.diagnostics] == ["manual"] + content = requirements.read_text() + assert "could not parse this `mcp` line" in content + assert "mcp==1.9.4 --hash=sha256:abc123" in content + + +def test_a_poetry_group_dependency_is_marked(tmp_path: Path) -> None: + pyproject = _write( + tmp_path / "pyproject.toml", + """\ + [tool.poetry.group.dev.dependencies] + mcp = "^1.2" + """, + ) + reports = update_dependencies([tmp_path], write=True) + assert [diagnostic.severity for report in reports for diagnostic in report.diagnostics] == ["manual"] + assert "# mcp-codemod:" in pyproject.read_text() + + +def test_lookalike_strings_in_comments_and_other_tables_are_never_touched(tmp_path: Path) -> None: + pyproject = _write( + tmp_path / "pyproject.toml", + """\ + [project] + # keep "mcp>=1.2,<2" in sync with the docs + dependencies = ["mcp>=1.2,<2"] + + [tool.mytool] + note = "mcp>=1.2,<2" + """, + ) + update_dependencies([tmp_path], write=True) + content = pyproject.read_text() + assert '# keep "mcp>=1.2,<2" in sync with the docs' in content + assert 'note = "mcp>=1.2,<2"' in content + assert 'dependencies = ["mcp>=2,<3"]' in content + + +def test_an_arbitrary_equality_clause_is_left_alone(tmp_path: Path) -> None: + """Nothing about an arbitrary-equality pin is provably v1-era.""" + pyproject = _write(tmp_path / "pyproject.toml", '[project]\ndependencies = ["mcp===legacy1"]\n') + original = pyproject.read_text() + assert update_dependencies([tmp_path], write=True) == [] + assert pyproject.read_text() == original + + +def test_two_poetry_tables_each_get_a_diagnostic(tmp_path: Path) -> None: + _write( + tmp_path / "pyproject.toml", + """\ + [tool.poetry.dependencies] + mcp = "^1.2" + + [tool.poetry.group.dev.dependencies] + mcp = "^1.2" + """, + ) + reports = update_dependencies([tmp_path], write=True, add_markers=False) + assert [diagnostic.severity for report in reports for diagnostic in report.diagnostics] == ["manual", "manual"] + + +def test_an_mcp_prefixed_other_package_is_untouched(tmp_path: Path) -> None: + """Neither the rewrite nor the unparseable-line flag may fire on another distribution.""" + requirements = _write(tmp_path / "requirements.txt", "mcp-extra==1.0\n") + assert update_dependencies([tmp_path], write=True) == [] + assert requirements.read_text() == "mcp-extra==1.0\n" diff --git a/tests/codemod/test_mappings.py b/tests/codemod/test_mappings.py new file mode 100644 index 000000000..73feeb0b3 --- /dev/null +++ b/tests/codemod/test_mappings.py @@ -0,0 +1,568 @@ +"""Pin the codemod's mapping tables against the installed v2 package. + +Each table is pinned as an exact literal and checked against the installed +packages; a failure here means the table is wrong, not the transformer. +""" + +import inspect +from importlib import import_module +from importlib.metadata import metadata +from importlib.util import find_spec + +import mcp_types +import pytest +from mcp_codemod import transform +from mcp_codemod._adapters import LOWLEVEL_HANDLER_SPECS +from mcp_codemod._mappings import ( + CAMEL_FIELDS, + LOWLEVEL_CTOR_POSITIONAL_PARAMS, + LOWLEVEL_REMOVED_ATTRS, + MODULE_RENAMES, + REHOMED_IMPORTS, + REMOVED_APIS, + REMOVED_ATTRS, + REMOVED_CTOR_PARAMS, + REMOVED_EXTRAS, + REMOVED_MODULES, + SYMBOL_RENAMES, + TRANSPORT_CLIENT_REMOVED_PARAMS, + TRANSPORT_CTOR_PARAMS, +) +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 Context, 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: + 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: + 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: + 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: + 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: + """`ProgressNotificationParams` renamed it to `progress_token`, but `RequestParams.Meta` + kept the camelCase wire spelling -- so an unconditional rename is wrong and needs human eyes.""" + assert CAMEL_FIELDS["progressToken"].tier == "risky" + + +def test_the_constructor_keyword_tables_match_the_v2_signatures() -> None: + """Flagging a keyword v2 kept would be a lie (`debug`, `log_level`, and `dependencies` + each survived one alpha or another). Landing spots are not asserted: `MCPServer.run` + forwards `**kwargs` to the app builders, so its signature cannot show them.""" + constructor = set(inspect.signature(MCPServer.__init__).parameters) + assert not (TRANSPORT_CTOR_PARAMS | set(REMOVED_CTOR_PARAMS)) & constructor + # If v2 grew a v1 decorator name back as a live method, deleting the decorator would break code. + assert not set(LOWLEVEL_HANDLER_SPECS) & set(dir(Server)) + + +# Every public top-level name of v1's `mcp/types.py`, frozen from `origin/v1.x`. +_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: + """Every public name of a renamed v1 module must import from the rename target, + or be in `SYMBOL_RENAMES` or `REMOVED_APIS`; anything else lets the codemod + emit an import that cannot resolve, with no diagnostic.""" + renamed_v1_modules = { + "mcp.types": _V1_TYPES_PUBLIC_NAMES, + # v1's `mcp/server/fastmcp/__init__.py` declared this `__all__` explicitly. + "mcp.server.fastmcp": ("FastMCP", "Context", "Image", "Audio", "Icon"), + # Only the names users import; the module's other definitions are internals. + "mcp.server.fastmcp.server": ("FastMCP", "Context", "Settings"), + "mcp.shared.version": ("LATEST_PROTOCOL_VERSION", "SUPPORTED_PROTOCOL_VERSIONS"), + } + 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: + """`REMOVED_ATTRS` matches by name alone, so a name qualifies only if nothing + public on v2 still spells it -- `request_context` fails exactly this bar.""" + assert set(REMOVED_ATTRS) == {"get_context", "get_server_capabilities", "_mcp_server"} + # The private-name row: v2 really renamed the wrapped server, both spellings private. + assert not hasattr(MCPServer, "_mcp_server") + assert "_lowlevel_server" in vars(MCPServer("probe")) + living = { + name + for module in (mcp, mcp.client.session, mcp.server.mcpserver, mcp_types) + 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: + """Flagging a keyword v2 kept would be a lie; missing one v2 dropped is a silent + `TypeError`. v1's signature is frozen history; v2's is introspected.""" + v1_parameters = frozenset( + {"url", "headers", "timeout", "sse_read_timeout", "terminate_on_close", "httpx_client_factory", "auth"} + ) + v2_parameters = frozenset(inspect.signature(streamable_http_client).parameters) + assert v1_parameters - v2_parameters == TRANSPORT_CLIENT_REMOVED_PARAMS + + +# Every public module v1 shipped (no underscore path segment), frozen from `origin/v1.x`. +_V1_PUBLIC_MODULES = ( + "mcp", + "mcp.cli", + "mcp.cli.claude", + "mcp.cli.cli", + "mcp.client", + "mcp.client.auth", + "mcp.client.auth.exceptions", + "mcp.client.auth.extensions", + "mcp.client.auth.extensions.client_credentials", + "mcp.client.auth.oauth2", + "mcp.client.auth.utils", + "mcp.client.experimental", + "mcp.client.experimental.task_handlers", + "mcp.client.experimental.tasks", + "mcp.client.session", + "mcp.client.session_group", + "mcp.client.sse", + "mcp.client.stdio", + "mcp.client.streamable_http", + "mcp.client.websocket", + "mcp.os", + "mcp.os.posix", + "mcp.os.posix.utilities", + "mcp.os.win32", + "mcp.os.win32.utilities", + "mcp.server", + "mcp.server.auth", + "mcp.server.auth.errors", + "mcp.server.auth.handlers", + "mcp.server.auth.handlers.authorize", + "mcp.server.auth.handlers.metadata", + "mcp.server.auth.handlers.register", + "mcp.server.auth.handlers.revoke", + "mcp.server.auth.handlers.token", + "mcp.server.auth.json_response", + "mcp.server.auth.middleware", + "mcp.server.auth.middleware.auth_context", + "mcp.server.auth.middleware.bearer_auth", + "mcp.server.auth.middleware.client_auth", + "mcp.server.auth.provider", + "mcp.server.auth.routes", + "mcp.server.auth.settings", + "mcp.server.elicitation", + "mcp.server.experimental", + "mcp.server.experimental.request_context", + "mcp.server.experimental.session_features", + "mcp.server.experimental.task_context", + "mcp.server.experimental.task_result_handler", + "mcp.server.experimental.task_scope", + "mcp.server.experimental.task_support", + "mcp.server.fastmcp", + "mcp.server.fastmcp.exceptions", + "mcp.server.fastmcp.prompts", + "mcp.server.fastmcp.prompts.base", + "mcp.server.fastmcp.prompts.manager", + "mcp.server.fastmcp.resources", + "mcp.server.fastmcp.resources.base", + "mcp.server.fastmcp.resources.resource_manager", + "mcp.server.fastmcp.resources.templates", + "mcp.server.fastmcp.resources.types", + "mcp.server.fastmcp.server", + "mcp.server.fastmcp.tools", + "mcp.server.fastmcp.tools.base", + "mcp.server.fastmcp.tools.tool_manager", + "mcp.server.fastmcp.utilities", + "mcp.server.fastmcp.utilities.context_injection", + "mcp.server.fastmcp.utilities.func_metadata", + "mcp.server.fastmcp.utilities.logging", + "mcp.server.fastmcp.utilities.types", + "mcp.server.lowlevel", + "mcp.server.lowlevel.experimental", + "mcp.server.lowlevel.func_inspection", + "mcp.server.lowlevel.helper_types", + "mcp.server.lowlevel.server", + "mcp.server.models", + "mcp.server.session", + "mcp.server.sse", + "mcp.server.stdio", + "mcp.server.streamable_http", + "mcp.server.streamable_http_manager", + "mcp.server.transport_security", + "mcp.server.validation", + "mcp.server.websocket", + "mcp.shared", + "mcp.shared.auth", + "mcp.shared.auth_utils", + "mcp.shared.context", + "mcp.shared.exceptions", + "mcp.shared.experimental", + "mcp.shared.experimental.tasks", + "mcp.shared.experimental.tasks.capabilities", + "mcp.shared.experimental.tasks.context", + "mcp.shared.experimental.tasks.helpers", + "mcp.shared.experimental.tasks.in_memory_task_store", + "mcp.shared.experimental.tasks.message_queue", + "mcp.shared.experimental.tasks.polling", + "mcp.shared.experimental.tasks.resolver", + "mcp.shared.experimental.tasks.store", + "mcp.shared.memory", + "mcp.shared.message", + "mcp.shared.metadata_utils", + "mcp.shared.progress", + "mcp.shared.response_router", + "mcp.shared.session", + "mcp.shared.tool_name_validation", + "mcp.shared.version", + "mcp.types", +) + + +def test_every_v1_module_resolves_on_v2_or_is_renamed_or_removed() -> None: + """An unaccounted module would mean an import the codemod neither fixes nor flags; + removed roots must really be gone from v2 and each must cover a v1 module.""" + + def covered_by(table: dict[str, str], module: str) -> bool: + return any(module == root or module.startswith(f"{root}.") for root in table) + + unaccounted = [ + module + for module in _V1_PUBLIC_MODULES + if not covered_by(MODULE_RENAMES, module) + and not covered_by(REMOVED_MODULES, module) + and find_spec(module) is None + ] + assert unaccounted == [] + for root in REMOVED_MODULES: + assert find_spec(root) is None, root + assert any(module == root or module.startswith(f"{root}.") for module in _V1_PUBLIC_MODULES), root + + +def test_the_removed_extras_are_exactly_v1_minus_the_installed_v2() -> None: + """Flagging an extra v2 kept would be a lie; missing one v2 dropped leaves a + constraint that cannot resolve. v1's set is frozen history.""" + v1_extras = {"cli", "rich", "ws"} + v2_extras = set(metadata("mcp").get_all("Provides-Extra") or []) + assert v1_extras - v2_extras == set(REMOVED_EXTRAS) + + +def test_every_rehomed_import_points_at_a_declared_public_export() -> None: + """The target must declare the name in `__all__`, and the source must still hold + it, so the rehome is never load-bearing for runtime behaviour.""" + for (source_module, name), target in REHOMED_IMPORTS.items(): + assert name in getattr(import_module(target), "__all__", []), (source_module, name) + assert hasattr(import_module(source_module), name), (source_module, name) + + +def test_every_lowlevel_removed_attribute_is_really_gone_from_the_v2_server() -> None: + """Each entry must be absent from the v2 `Server` yet spelled by some other + living API -- otherwise plain name-matched `REMOVED_ATTRS` is its cheaper home.""" + assert set(LOWLEVEL_REMOVED_ATTRS) == {"request_context", "request_handlers", "notification_handlers"} + for name in LOWLEVEL_REMOVED_ATTRS: + assert not hasattr(Server, name), name + # `request_context` survives on `Context` (the reason the table is receiver-gated); + # the handler dicts' replacement API must exist for their guidance to hold. + assert hasattr(Context, "request_context") + assert hasattr(Server, "add_request_handler") and hasattr(Server, "get_request_handler") + assert hasattr(Server, "add_notification_handler") + + +def test_the_lowlevel_positional_params_are_keyword_only_on_the_installed_server() -> None: + """The rewrite emits these as keywords, so each must exist under that name on v2.""" + parameters = inspect.signature(Server.__init__).parameters + for name in LOWLEVEL_CTOR_POSITIONAL_PARAMS: + assert parameters[name].kind is inspect.Parameter.KEYWORD_ONLY diff --git a/tests/codemod/test_runner.py b/tests/codemod/test_runner.py new file mode 100644 index 000000000..355cc5f3d --- /dev/null +++ b/tests/codemod/test_runner.py @@ -0,0 +1,201 @@ +"""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: + (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: + (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: + 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: + 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: + 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: + """Flag-only sites count as `manual` and heuristic rewrites as `review` in the summed counts.""" + (tmp_path / "lowlevel.py").write_text( + textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + server = Server("demo") + + + @traced + @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.""" + 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 but non-UTF-8 file is recorded as failed and left as found, without aborting the run.""" + good = tmp_path / "aaa.py" + good.write_text("from mcp.server.fastmcp import FastMCP\n") + weird = tmp_path / "bbb.py" + 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 write failure is recorded as a write failure -- never a parse failure -- and the run continues.""" + first = tmp_path / "aaa.py" + first.write_text("from mcp.server.fastmcp import FastMCP\n") + second = tmp_path / "bbb.py" + 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..ff2210676 --- /dev/null +++ b/tests/codemod/test_transformer.py @@ -0,0 +1,1984 @@ +"""Behaviour of `transform()`, the whole programmatic surface of the codemod. + +Properties that must not change are asserted byte-identical to the input; rewrites as 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; 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 dotted reference like `mcp.types.Tool` is rewritten together with the import that binds it.""" + 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` becomes `import mcp_types as t`; references through the alias are untouched.""" + 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` becomes `import mcp_types as types`, keeping the same local name.""" + result = transform("from mcp import types\n") + assert result.code == snapshot("import mcp_types as types\n") + + +def test_from_mcp_import_types_with_an_alias_keeps_the_alias() -> None: + """`from mcp import types as t` becomes `import mcp_types as t`.""" + result = transform("from mcp import types as t\n") + assert result.code == snapshot("import mcp_types as t\n") + + +def test_types_is_split_off_from_other_imported_names() -> None: + """Only `types` is split out of a mixed `from mcp import`; the other names stay put.""" + result = transform("from mcp import ClientSession, types\n") + assert result.code == snapshot( + """\ +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` round-trips 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 to split out.""" + source = "from mcp import *\n" + assert transform(source).code == source + + +def test_a_relative_import_is_never_touched() -> None: + """A relative import refers to the user's own package, never the SDK.""" + 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: + """Code already on v2 is a no-op: nothing is rewritten or reported.""" + source = textwrap.dedent("""\ + import mcp_types + from mcp.server.mcpserver import MCPServer + + 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: + """Non-mcp imports and references are outside every rename table.""" + 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: + """The do-no-harm contract: a module that never mentions mcp comes back byte-identical.""" + source = textwrap.dedent("""\ + # Shared logging setup for the example application. + + 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.""" + 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: + """A `FastMCP` import rewrites the module path, the imported name, and every call site.""" + 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: + """Only the imported name is renamed; the local alias and its uses are untouched.""" + source = textwrap.dedent("""\ + from mcp.server.fastmcp import FastMCP as F + + 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 dotted use has only its final segment renamed; the import and 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: + """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: + """`obj.FastMCP` and `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: + """A removed function keeps its v1 name and gains a manual diagnostic plus an inline marker.""" + source = textwrap.dedent("""\ + from mcp.shared.memory import create_connected_server_and_client_session + + + 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: + """A `websocket_client` use is flagged manual at the import and the call; only markers are inserted.""" + source = textwrap.dedent("""\ + from mcp.client.websocket import websocket_client + + + 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` 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: + """A removed attribute is matched by name alone (receiver types are invisible), flagged, and kept.""" + 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_call_tool_decorator_site_is_rewritten_with_full_v1_dispatch() -> None: + """The adapter carries v1's whole dispatch: tool cache, input validation, and the isError contract.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + server = Server("s") + + + @server.call_tool() + async def handle(name: str, arguments: dict): + return [] + """) + result = transform(source) + assert "async def handle(name: str, arguments: dict):\n return []\n" in result.code + assert "_server_tool_cache" in result.code + assert "jsonschema.validate(instance=arguments" in result.code + assert 'server.add_request_handler("tools/call", mcp_types.CallToolRequestParams, _handle_handler)' in result.code + assert "import jsonschema" in result.code + assert "# mcp-codemod:" not in result.code + + +def test_a_high_level_decorator_is_never_flagged() -> None: + """Only the receiver's binding separates `@mcp.tool()` from a lowlevel decorator; it gets no flag.""" + source = textwrap.dedent("""\ + from mcp.server.fastmcp import FastMCP + + 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 read is renamed, reported as info, 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 rename is reported as review, with an inline marker 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: + """The 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: + """Only 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: + """String spellings outside `getattr`/`hasattr` are left alone: 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 keywords on a call that resolves into the SDK are renamed to snake_case.""" + 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.""" + 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: + """A camelCase string in a `hasattr` call is renamed and reported as info, with no 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 outside the camelCase allowlist is never rewritten.""" + 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: + """The codemod only rewrites names it can read from the source; a variable argument is untouched.""" + source = textwrap.dedent("""\ + import mcp + + + def field(result: object, key: str) -> object: + return getattr(result, key) + """) + assert transform(source).code == source + + +def test_a_single_argument_mcperror_call_becomes_from_error_data() -> None: + """A one-argument `McpError(...)` call converts to `MCPError.from_error_data(...)` as written.""" + source = textwrap.dedent("""\ + from mcp.shared.exceptions import McpError + from mcp.types import ErrorData + + 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.from_error_data(ErrorData(code=1, message="x", data=None)) +""") + + +def test_a_mcperror_call_with_a_non_inline_argument_is_rewritten_without_a_marker() -> None: + """`McpError(err)` needs no unpacking under `from_error_data`, so it is rewritten without a marker.""" + source = textwrap.dedent("""\ + from mcp.shared.exceptions import McpError + + def reraise(err): + raise McpError(err) + """) + result = transform(source) + assert "raise MCPError.from_error_data(err)" in result.code + assert result.diagnostics == [] + + +def test_a_dotted_mcperror_call_converts_on_its_full_spelling() -> None: + """The `from_error_data` conversion composes with the symbol rename on a dotted spelling.""" + source = textwrap.dedent("""\ + import mcp.shared.exceptions + + raise mcp.shared.exceptions.McpError(build_error()) + """) + result = transform(source) + assert "raise mcp.shared.exceptions.MCPError.from_error_data(build_error())" in result.code + + +def test_error_attribute_chains_on_a_caught_error_are_left_alone() -> None: + """`e.error.code` and friends still work on v2, so only the exception name changes.""" + 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.error.code, e.error.message, e.error.data) +""") + + +def test_a_syntax_error_raises_parser_syntax_error() -> None: + """Unparseable source raises `libcst.ParserSyntaxError`, the one exception `transform()` documents.""" + with pytest.raises(libcst.ParserSyntaxError): + transform("def (") + + +def test_the_three_tuple_unpack_is_narrowed_to_two() -> None: + """v2 no longer yields the third `get_session_id` value, so a 3-tuple `as` target narrows to two.""" + source = textwrap.dedent("""\ + from mcp.client.streamable_http import streamable_http_client + + + 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: + """Dropping a real name (not `_`) breaks later uses, so the narrowing also raises a manual diagnostic.""" + source = textwrap.dedent("""\ + from mcp.client.streamable_http import streamable_http_client + + + 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: + """Each removed client keyword gets its own manual diagnostic; none are silently deleted.""" + source = textwrap.dedent("""\ + from mcp.client.streamable_http import streamable_http_client + + + 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 alias renames at the import and the call, and the 3-tuple `as` target narrows in the same pass.""" + source = textwrap.dedent("""\ + from mcp.client.streamable_http import streamablehttp_client + + + 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-identical.""" + 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: + """Only the 3-tuple `as` shape has a third element to drop; a single-name target is untouched.""" + source = textwrap.dedent("""\ + from mcp.client.streamable_http import streamable_http_client + + + 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-item that is not an mcp transport client is never rewritten.""" + 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: + """The codemod refuses to act on a name it cannot resolve through an import.""" + 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 is flagged but never deleted: where it belongs on v2 depends on server startup.""" + 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 keyword that still exists on the v2 `MCPServer` produces no diagnostic: a flag on a working + keyword is a lie the user cannot reconcile.""" + source = textwrap.dedent("""\ + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("d", instructions="hi", dependencies=["a"], debug=False, log_level="INFO") + """) + assert transform(source).diagnostics == [] + + +def test_transforming_already_transformed_code_is_a_noop() -> None: + """Running the codemod over its own output changes nothing.""" + 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 recognises an 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` findings still appear in `diagnostics` but no comment is written.""" + 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 lands 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() + 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: + """An info diagnostic never earns a `# mcp-codemod` comment.""" + 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: + """Only the innermost node naming the module is replaced, so the enclosing chain is not double-counted.""" + result = transform("import mcp.types\n\nx: mcp.types.Tool\n") + assert result.code == "import mcp_types\n\nx: mcp_types.Tool\n" + assert result.rewrites["module_rename"] == 2 + + +def test_a_local_variable_named_mcp_is_never_treated_as_the_package() -> None: + """`mcp` is the most common variable name in real MCP code; only a name bound by an import is rewritten.""" + source = "mcp = build()\nprint(mcp.types)\n" + assert transform(source).code == source + + +def test_a_semicolon_joined_statement_line_is_left_as_written() -> None: + """A semicolon-joined import cannot be split out, so the statement is left whole rather than half-rewritten.""" + source = "DEBUG = True; from mcp import types\n" + assert transform(source).code == source + + +def test_camelcase_keywords_on_a_local_variable_named_mcp_are_untouched() -> None: + """Keywords on a call through a local `mcp` variable are untouched when nothing imports the SDK.""" + source = 'mcp = Router()\nmcp.register(inputSchema={"a": 1}, isError=False)\n' + result = transform(source) + assert result.code == source + 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 an SDK import, like the attribute form.""" + source = 'value = getattr(row, "createdAt", None)\n' + assert transform(source).code == source + + +def test_a_risky_camelcase_getattr_string_gets_a_review_marker() -> None: + """A risky-tier rename inside a `getattr` string is marked for review, like the attribute form.""" + source = 'import mcp\n\ncursor = getattr(result, "nextCursor", None)\n' + result = transform(source) + assert '"next_cursor"' in result.code + assert "# mcp-codemod: review:" in result.code + + +def test_removed_attribute_names_are_untouched_in_a_file_that_never_imports_mcp() -> None: + """A file that never imports the SDK must never gain a removal marker.""" + 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 `mcp`; the rewrite is marked when another reference still needs that binding.""" + 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 the import is itself rewritten, losing the `mcp` binding breaks nothing.""" + source = 'import mcp.types\n\ntool = mcp.types.Tool(name="x", input_schema={})\n' + result = transform(source) + assert result.code == 'import mcp_types\n\ntool = mcp_types.Tool(name="x", input_schema={})\n' + assert result.diagnostics == [] + + +def test_a_dotted_usage_through_a_bare_import_mcp_is_marked_not_rewritten() -> None: + """Rewriting the usage would leave nothing importing `mcp_types`, so the site is marked instead.""" + source = 'import mcp\n\ntool = mcp.types.Tool(name="x")\n' + result = transform(source) + assert "mcp.types.Tool" in result.code + 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` 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: + """Every v1 import spelling of the renamed class gets the same constructor keyword markers.""" + 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 reached through a module alias rewrites at both the import and the access.""" + 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: + """A types name with no v2 home is marked, never silently rewritten into an import that cannot resolve.""" + source = textwrap.dedent("""\ + from mcp.types import Cursor, Tool + + 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.""" + 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: + """Only the full path is rewritten; its renamed prefix must not also be flagged.""" + source = "import mcp.server.fastmcp.server\n\nctx = mcp.server.fastmcp.server.Context()\n" + result = transform(source) + assert result.code == "import mcp.server.mcpserver.server\n\nctx = mcp.server.mcpserver.server.Context()\n" + assert result.diagnostics == [] + + +def test_transport_client_kwargs_are_flagged_in_any_call_form() -> None: + """Client keyword and yield-shape markers fire even when the call is not itself the `with` item.""" + source = textwrap.dedent("""\ + from mcp.client.streamable_http import streamablehttp_client + + + 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 v1 surroundings, so no 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: + """Identical findings on one statement collapse into one comment but stay separate diagnostics.""" + source = "import mcp\n\nflag = a.isError or b.isError\n" + result = transform(source) + assert result.code.count("# mcp-codemod:") == 1 + assert len(result.diagnostics) == 2 + + +def test_a_v1_client_with_item_bound_to_a_single_name_is_flagged() -> None: + """A single-name `as` target hides the unpacking, so the call gets the yield-shape marker.""" + source = textwrap.dedent("""\ + from mcp.client.streamable_http import streamablehttp_client + + + 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: + """An annotated assignment binds the server exactly like the un-annotated form.""" + 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 result.rewrites["lowlevel_registration"] == 1 + assert "# mcp-codemod:" not in result.code + + +def test_camelcase_attributes_are_renamed_in_a_file_importing_only_mcp_types() -> None: + """`import mcp_types` is as much the SDK as `import mcp` for gating the attribute renames.""" + 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: + """A name-only match cannot tell the removed `Server.request_context` from the live idiom; neither is flagged.""" + source = textwrap.dedent("""\ + from mcp.server.fastmcp import Context, FastMCP + + + 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 keeps the trailing comment -- a `# noqa` there is load-bearing.""" + assert transform("from mcp import types # noqa: F401\n").code == snapshot( + "import mcp_types as types # noqa: F401\n" + ) + + +def test_a_marker_on_the_first_statement_is_not_duplicated_on_a_rerun() -> None: + """A comment above the first statement parses into the module header; the dedup must look there too.""" + source = "# Application entrypoint.\nfrom mcp.client.websocket import websocket_client\n" + once = transform(source).code + assert once.count("# mcp-codemod:") == 1 + 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`; leaving it would silently swap meaning.""" + 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: + """Renaming uses of a camelCase field that a class in this file also declares would break that class.""" + source = textwrap.dedent("""\ + from pydantic import BaseModel + + 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: + """A subclass `super().__init__(ErrorData(...))` gets the same flatten as a direct `McpError` call.""" + source = textwrap.dedent("""\ + from mcp import McpError + from mcp.types import INVALID_PARAMS, ErrorData + + + 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: + """A variable argument cannot be unpacked, so the site is marked rather than left to fail at raise time.""" + source = textwrap.dedent("""\ + from mcp import McpError + + + 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: + """The qualified-name check sees the whole dotted path to a removed nested class.""" + 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: + """Module-level names stay on the v2 submodule; `Context` alone is rehomed to the package.""" + source = "from mcp.server.fastmcp.server import Context, Settings\n" + assert transform(source).code == snapshot( + """\ +from mcp.server.mcpserver.server import Settings +from mcp.server.mcpserver import Context +""" + ) + + +def test_a_resolvable_non_mcp_receiver_is_never_flagged() -> None: + """A receiver the imports prove is another package is never name-matched.""" + 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: + """Another surviving plain `mcp.` import keeps the root bound, so no review marker is added.""" + source = textwrap.dedent("""\ + import mcp.client.session + import mcp.types + + 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 == [] + + +def test_an_import_of_a_removed_module_is_marked_and_kept() -> None: + """An import of a deleted module is kept as written and marked with the replacement guidance.""" + source = "import mcp.shared.progress\n" + result = transform(source) + assert "import mcp.shared.progress\n" in result.code + assert [diagnostic.transform for diagnostic in result.diagnostics] == ["removed_module"] + assert "ctx.report_progress()" in result.diagnostics[0].message + + +def test_a_from_import_out_of_a_removed_namespace_gets_one_marker() -> None: + """One whole-statement marker; per-name markers would only repeat it.""" + source = "from mcp.shared.experimental.tasks import InMemoryTaskStore, task_execution\n" + result = transform(source) + assert result.code.count("# mcp-codemod:") == 1 + assert [diagnostic.severity for diagnostic in result.diagnostics] == ["manual"] + assert "has no replacement" in result.diagnostics[0].message + + +def test_a_removed_module_imported_from_its_parent_package_is_marked() -> None: + """The per-name check resolves a module bound through its parent against the removed roots.""" + source = "from mcp.client import websocket\n" + result = transform(source) + assert result.code.count("# mcp-codemod:") == 1 + assert "`mcp.client.websocket` removed" in result.diagnostics[0].message + + +def test_context_imported_from_the_server_module_is_rehomed_to_the_package() -> None: + """Importing `Context` from `server.py` would be private usage on v2, so it is split out to the package.""" + source = "from mcp.server.fastmcp.server import Context, FastMCP, Settings\n" + assert transform(source).code == snapshot( + """\ +from mcp.server.mcpserver.server import MCPServer, Settings +from mcp.server.mcpserver import Context +""" + ) + + +def test_a_rehomed_import_keeps_its_alias_and_takes_the_statement_over_when_alone() -> None: + """A lone rehomed name replaces the whole statement, `as` alias and all.""" + source = "from mcp.server.fastmcp.server import Context as Ctx\n" + assert transform(source).code == snapshot("from mcp.server.mcpserver import Context as Ctx\n") + + +def test_request_context_on_a_proven_lowlevel_server_is_flagged() -> None: + """Only a receiver the pre-pass proved holds a lowlevel `Server` is flagged, sparing the live v2 idiom.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + server = Server("git") + + + async def progress(token: str) -> None: + ctx = server.request_context + await ctx.session.send_progress_notification(token, 1.0) + """) + result = transform(source) + assert "server.request_context" in result.code + assert [diagnostic.severity for diagnostic in result.diagnostics] == ["manual"] + assert "handlers now receive `ctx` explicitly" in result.diagnostics[0].message + + +def test_a_lowlevel_server_bound_to_an_attribute_is_recognized() -> None: + """An attribute binding gets the same treatment as a plain name binding.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + + class App: + def __init__(self) -> None: + self.server = Server("demo") + + def current(self) -> object: + return self.server.request_context + """) + result = transform(source) + assert [diagnostic.severity for diagnostic in result.diagnostics] == ["manual"] + assert "handlers now receive `ctx` explicitly" in result.diagnostics[0].message + + +def test_a_marker_survives_a_statement_split() -> None: + """A flag on an import that is also being split lands above the split's first piece.""" + result = transform("from mcp.server import websocket, fastmcp\n") + assert result.code == snapshot( + """\ +# mcp-codemod: `mcp.server.websocket` removed: the WebSocket transport was deleted +from mcp.server import websocket +import mcp.server.mcpserver as fastmcp +""" + ) + + +def test_a_tuple_assignment_involving_a_server_call_is_passed_over() -> None: + """A tuple target has no single dotted spelling to track, so the pre-pass records nothing.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + primary, label = Server("a"), "main" + """) + result = transform(source) + assert result.code == source + assert result.diagnostics == [] + + +def test_unpacking_a_call_result_is_passed_over() -> None: + """An unpacked call result has no single dotted spelling to track, so the pre-pass records nothing.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + server, transport = build(Server("x")) + """) + result = transform(source) + assert result.code == source + assert result.diagnostics == [] + + +def test_lowlevel_server_positional_arguments_become_keywords() -> None: + """v2 keeps v1's parameter names and order but makes them keyword-only, so positionals convert one for one.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + server = Server("srv", "1.2.0", "does things") + """) + result = transform(source) + assert 'Server("srv", version="1.2.0", instructions="does things")' in result.code + assert result.diagnostics == [] + + +def test_a_lowlevel_server_call_with_a_splat_is_left_for_v2_to_reject() -> None: + """A `*`-splat hides how many positions it fills; v2's own TypeError at construction is loud enough.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + server = Server("srv", *extra) + """) + assert transform(source).code == source + + +def test_lowlevel_keyword_arguments_are_never_touched() -> None: + """A v1 call already passing keywords is valid v2; nothing changes.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + server = Server("srv", version="1.2.0") + """) + assert transform(source).code == source + + +def test_a_module_level_decorator_site_is_rewritten_to_registration_at_site() -> None: + """The user's function survives byte-identical; the adapter and registration land at the decorator's position.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + import mcp.types as types + + app = Server("demo") + + @app.list_prompts() + async def list_prompts() -> list[types.Prompt]: + return [] + + run(app) + """) + result = transform(source) + assert result.code == snapshot("""\ +from typing import cast +from mcp.server import ServerRequestContext +import mcp_types +from mcp.server.lowlevel import Server +import mcp_types as types + +app = Server("demo") + +async def list_prompts() -> list[types.Prompt]: + return [] + + +async def _list_prompts_handler( + ctx: ServerRequestContext, params: mcp_types.PaginatedRequestParams +) -> mcp_types.ListPromptsResult: + result = cast("object", await list_prompts()) + if isinstance(result, mcp_types.ListPromptsResult): + return result + return mcp_types.ListPromptsResult(prompts=cast("list[mcp_types.Prompt]", result)) + + +app.add_request_handler("prompts/list", mcp_types.PaginatedRequestParams, _list_prompts_handler) + +run(app) +""") + assert result.rewrites["lowlevel_registration"] == 1 + assert [d.severity for d in result.diagnostics] == ["info"] + + +def test_a_decorator_nested_inside_a_function_is_rewritten_in_place() -> None: + """v1 servers built inside `main()` register at the same nesting depth.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + def main(): + app = Server("demo") + + @app.set_logging_level() + async def set_level(level) -> None: + configure(level) + + return app + """) + result = transform(source) + assert ( + ' app.add_request_handler("logging/setLevel", mcp_types.SetLevelRequestParams, _set_level_handler)' + in result.code + ) + assert " async def _set_level_handler(" in result.code + assert result.code.count("# mcp-codemod:") == 0 + + +def test_a_stacked_decorator_blocks_the_rewrite_with_a_marker() -> None: + """A second decorator changes what the module name binds, so the site is marked.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @observed + @app.list_tools() + async def list_tools(): + return [] + """) + result = transform(source) + assert "@observed" in result.code + assert "another decorator is stacked on it" in result.diagnostics[0].message + assert "add_request_handler" in result.diagnostics[0].message + + +def test_an_attribute_receiver_blocks_the_rewrite_with_a_marker() -> None: + """The emitted module-level adapter cannot close over `self`, so the site is marked.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + class Wrapper: + def __init__(self): + self.server = Server("demo") + + @self.server.list_tools() + async def list_tools(): + return [] + """) + result = transform(source) + assert "@self.server.list_tools()" in result.code + assert "the server is reached through an attribute" in result.diagnostics[0].message + + +def test_a_wrong_arity_handler_blocks_the_rewrite_with_a_marker() -> None: + """A handler signature that is not v1's old style is not guessed at.""" + source = textwrap.dedent("""\ + import mcp.types as types + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.list_tools() + async def list_tools(req: types.ListToolsRequest) -> types.ListToolsResult: + return types.ListToolsResult(tools=[]) + """) + result = transform(source) + assert "the handler signature does not match the v1 form" in result.diagnostics[0].message + + +def test_a_sync_handler_blocks_the_rewrite_with_a_marker() -> None: + """v1 lowlevel handlers were async; a sync def is not a shape the adapter can call.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.list_tools() + def list_tools(): + return [] + """) + result = transform(source) + assert "the handler is not `async def`" in result.diagnostics[0].message + + +def test_a_non_literal_decorator_argument_blocks_the_rewrite() -> None: + """`@app.call_tool(validate_input=flag)` cannot be evaluated statically.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.call_tool(validate_input=flag) + async def call_tool(name, arguments): + return [] + """) + result = transform(source) + assert "arguments the codemod cannot evaluate" in result.diagnostics[0].message + + +def test_a_taken_generated_name_blocks_the_rewrite() -> None: + """The adapter's module-level name must not shadow existing user code.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + _list_tools_handler = object() + + @app.list_tools() + async def list_tools(): + return [] + """) + result = transform(source) + assert "a generated name is already bound in this file" in result.diagnostics[0].message + + +def test_validate_input_false_omits_only_the_input_validation() -> None: + """Only the input-validation block is dropped; v1 validated output regardless of the flag.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.call_tool(validate_input=False) + async def call_tool(name, arguments): + return [] + """) + result = transform(source) + assert "instance=arguments" not in result.code + assert "output_schema" in result.code + assert "_app_tool_cache" in result.code + + +def test_adapter_imports_are_not_injected_when_already_bound() -> None: + """A file that already imports `json` and `mcp_types` gets neither again.""" + source = textwrap.dedent("""\ + import json + import mcp_types + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.call_tool() + async def call_tool(name, arguments): + return [json.dumps(arguments)] + """) + result = transform(source) + assert result.code.count("import json\n") == 1 + assert result.code.count("import mcp_types") == 1 + + +def test_an_inline_timedelta_timeout_converts_to_seconds() -> None: + """An inline `timedelta` timeout converts to seconds; on v2 the `timedelta` form fails on first request.""" + source = textwrap.dedent("""\ + from datetime import timedelta + from mcp import ClientSession + + session = ClientSession(read, write, read_timeout_seconds=timedelta(seconds=5)) + """) + result = transform(source) + assert "read_timeout_seconds=timedelta(seconds=5).total_seconds()" in result.code + assert [d.severity for d in result.diagnostics] == ["info"] + + +def test_a_positional_timeout_variable_is_marked_not_guessed() -> None: + """A variable in v1's `timedelta` position cannot be proven convertible, so it gets a marker.""" + source = textwrap.dedent("""\ + from mcp import ClientSession + + session = ClientSession(read, write, timeout) + """) + result = transform(source) + assert "session = ClientSession(read, write, timeout)" in result.code + assert "pass this value's `.total_seconds()`" in result.diagnostics[0].message + + +def test_a_none_timeout_is_left_alone() -> None: + """`None` is valid on both v1 and v2; nothing fires.""" + source = textwrap.dedent("""\ + from mcp import ClientSession + + session = ClientSession(read, write, None) + """) + result = transform(source) + assert result.diagnostics == [] + + +def test_a_cursor_keyword_on_an_annotated_session_wraps_into_params() -> None: + """`cursor=` becomes the v2 `params=` form when the receiver is proven a `ClientSession`.""" + source = textwrap.dedent("""\ + from mcp import ClientSession + + async def load(session: ClientSession): + return await session.list_tools(cursor=token) + """) + result = transform(source) + assert "session.list_tools(params=mcp_types.PaginatedRequestParams(cursor=token))" in result.code + assert "import mcp_types" in result.code + + +def test_a_url_wrapper_into_a_proven_session_read_is_unwrapped() -> None: + """The `AnyUrl` wrapper is dropped when the receiver is a with-bound `ClientSession`.""" + source = textwrap.dedent("""\ + from pydantic import AnyUrl + from mcp import ClientSession + + async def read(streams): + async with ClientSession(streams[0], streams[1]) as session: + return await session.read_resource(AnyUrl("demo://x")) + """) + result = transform(source) + assert 'session.read_resource("demo://x")' in result.code + + +def test_a_url_wrapper_in_a_sdk_uri_keyword_is_unwrapped() -> None: + """The wrapper is dropped on a callee that provably resolves into the SDK.""" + source = textwrap.dedent("""\ + import mcp.types as types + from pydantic import AnyUrl + + resource = types.Resource(uri=AnyUrl(f"file://{path}"), name="n") + """) + result = transform(source) + assert 'resource = types.Resource(uri=f"file://{path}", name="n")' in result.code + + +def test_a_url_wrapper_in_an_unproven_uri_keyword_is_marked() -> None: + """On an unresolvable callee the value may still land in an mcp model, so mark rather than rewrite.""" + source = textwrap.dedent("""\ + import mcp + from pydantic import AnyUrl + + notify(uri=AnyUrl("demo://x"), audience="all") + """) + result = transform(source) + assert 'notify(uri=AnyUrl("demo://x"), audience="all")' in result.code + assert "drop this URL wrapper" in result.diagnostics[0].message + + +def test_the_private_mcp_server_attribute_is_marked() -> None: + """The marker names the v2 spelling of v1's widely-used private attribute.""" + source = textwrap.dedent("""\ + from mcp.server.mcpserver import MCPServer + + mcp = MCPServer("demo") + server = mcp._mcp_server + """) + result = transform(source) + assert "_lowlevel_server" in result.diagnostics[0].message + + +def test_the_handler_dicts_on_a_proven_lowlevel_server_are_marked() -> None: + """Handler-dict introspection has no mechanical rewrite; the marker names the v2 lookup API.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + handler = app.request_handlers[CallToolRequest] + """) + result = transform(source) + assert "get_request_handler(method)" in result.diagnostics[0].message + + +def test_a_class_body_handler_blocks_the_rewrite() -> None: + """A decorated method in a class body cannot take a module-level adapter.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + class Handlers: + @app.list_tools() + async def list_tools(self): + return [] + """) + result = transform(source) + assert "the handler is defined in a class body" in result.diagnostics[0].message + + +def test_a_decorator_argument_on_a_non_call_tool_kind_blocks_the_rewrite() -> None: + """Only `call_tool` ever took a decorator argument on v1; anything else is not a known shape.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.list_tools("extra") + async def list_tools(): + return [] + """) + result = transform(source) + assert "arguments the codemod cannot evaluate" in result.diagnostics[0].message + + +def test_a_star_kwargs_handler_blocks_the_rewrite() -> None: + """`**kwargs` hides the real signature, so the site is marked.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.get_prompt() + async def get_prompt(name, arguments, **kwargs): + return None + """) + result = transform(source) + assert "the handler signature does not match the v1 form" in result.diagnostics[0].message + + +def test_a_single_positional_argument_to_a_session_list_method_is_left_alone() -> None: + """Only the exact v1 `cursor=` keyword form is wrapped.""" + source = textwrap.dedent("""\ + from mcp import ClientSession + + async def load(session: ClientSession): + return await session.list_tools(token) + """) + result = transform(source) + assert "session.list_tools(token)" in result.code + assert result.diagnostics == [] + + +def test_a_plain_string_uri_to_a_session_read_is_left_alone() -> None: + """`session.read_resource("demo://x")` is already the v2 shape.""" + source = textwrap.dedent("""\ + from mcp import ClientSession + + async def read(session: ClientSession): + return await session.read_resource("demo://x") + """) + result = transform(source) + assert 'session.read_resource("demo://x")' in result.code + assert result.diagnostics == [] + + +def test_a_url_wrapper_in_a_file_without_sdk_imports_is_never_touched() -> None: + """Without an SDK import the value cannot land in an mcp type; no marker, no rewrite.""" + source = textwrap.dedent("""\ + from pydantic import AnyUrl + + notify(uri=AnyUrl("demo://x"), audience="all") + """) + result = transform(source) + assert result.code == source + assert result.diagnostics == [] + + +def test_constructing_a_union_alias_is_marked() -> None: + """`JSONRPCMessage(...)` imports on v2 but is a plain union: calling it fails.""" + source = textwrap.dedent("""\ + from mcp.types import JSONRPCMessage + + message = JSONRPCMessage(payload) + """) + result = transform(source) + assert "cannot be constructed" in result.diagnostics[0].message + + +def test_a_pydantic_method_on_a_union_alias_is_marked() -> None: + """`JSONRPCMessage.model_validate_json(...)` has no pydantic methods on v2.""" + source = textwrap.dedent("""\ + import mcp.types as types + + message = types.JSONRPCMessage.model_validate_json(raw) + """) + result = transform(source) + assert any("pydantic.TypeAdapter(JSONRPCMessage)" in d.message for d in result.diagnostics) + + +def test_a_str_annotated_uri_handler_gets_the_wire_string() -> None: + """A handler declaring `uri: str` gets the wire string passed through, with no `AnyUrl` import.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.read_resource() + async def read_resource(uri: str) -> str: + return uri + """) + result = transform(source) + assert "await read_resource(params.uri)" in result.code + assert "AnyUrl" not in result.code + + +def test_an_unannotated_uri_handler_keeps_v1_anyurl_parity() -> None: + """Without a `str` annotation the adapter passes `AnyUrl(params.uri)`, exactly what v1 passed.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.subscribe_resource() + async def subscribe(uri): + record(uri) + """) + result = transform(source) + assert "await subscribe(AnyUrl(params.uri))" in result.code + assert "from pydantic import AnyUrl" in result.code + + +def test_a_model_method_on_a_non_alias_receiver_is_not_marked() -> None: + """`model_validate` on a concrete model (or anything else) is live v2 API.""" + source = textwrap.dedent("""\ + from mcp.types import Tool + + tool = Tool.model_validate(payload) + own = config.model_dump() + """) + result = transform(source) + assert not any(d.transform == "union_alias" for d in result.diagnostics) + + +def test_imports_inject_at_the_top_even_with_a_late_import() -> None: + """A mid-file import must not anchor injection below the registration code.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.list_tools() + async def list_tools(): + return [] + + import late_helper + """) + result = transform(source) + lines = result.code.splitlines() + assert lines.index("import mcp_types") < lines.index('app = Server("demo")') + + +def test_a_docstring_and_future_import_stay_first() -> None: + """Injected imports respect the docstring and `__future__` position rules.""" + source = textwrap.dedent('''\ + """Module docs.""" + + from __future__ import annotations + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.get_prompt() + async def get_prompt(name, arguments): + return None + ''') + result = transform(source) + lines = result.code.splitlines() + assert lines[0] == '"""Module docs."""' + assert lines.index("from __future__ import annotations") < lines.index("import mcp_types") + + +def test_a_conditional_import_does_not_suppress_injection() -> None: + """A TYPE_CHECKING-gated import does not bind at runtime, so the adapter's + import is still injected at module level.""" + source = textwrap.dedent("""\ + from typing import TYPE_CHECKING + from mcp.server.lowlevel import Server + + if TYPE_CHECKING: + from collections.abc import Iterable + + app = Server("demo") + + @app.call_tool() + async def call_tool(name, arguments): + return [] + """) + result = transform(source) + top_level = [line for line in result.code.splitlines() if line.startswith("from collections.abc")] + assert top_level == ["from collections.abc import Iterable"] + + +def test_a_module_binding_of_an_adapter_import_name_blocks_the_rewrite() -> None: + """`json = None` at module level would shadow the injected import inside the + adapter, so the site is marked instead of silently broken.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + json = None + app = Server("demo") + + @app.call_tool() + async def call_tool(name, arguments): + return [] + """) + result = transform(source) + assert "a name the generated adapter needs is already bound" in result.diagnostics[0].message + + +def test_a_handler_named_like_a_template_local_blocks_the_rewrite() -> None: + """A handler called `completion` would be shadowed by the adapter's own local.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.completion() + async def completion(ref, argument, context): + return None + """) + result = transform(source) + assert "collides with a name the generated adapter uses" in result.diagnostics[0].message + + +def test_a_blocked_progress_site_names_the_notification_api() -> None: + """Progress is a notification; the guidance must not send users to the + request-handler API where the handler would never fire.""" + source = textwrap.dedent("""\ + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.progress_notification() + def on_progress(token, progress, total, message): + pass + """) + result = transform(source) + assert "add_notification_handler" in result.diagnostics[0].message + + +def test_a_list_handler_returning_the_full_result_passes_through() -> None: + """v1's wrapper isinstance-passed a returned result model through; the adapter + must not double-wrap it.""" + source = textwrap.dedent("""\ + import mcp.types as types + from mcp.server.lowlevel import Server + + app = Server("demo") + + @app.list_tools() + async def list_tools(): + return types.ListToolsResult(tools=[]) + """) + result = transform(source) + assert "if isinstance(result, mcp_types.ListToolsResult):" in result.code + assert "return result" in result.code + + +def test_the_timeout_rewrite_is_idempotent_and_floats_are_untouched() -> None: + """A second run over `.total_seconds()` output and a plain float timeout both + produce nothing -- no rewrite, no marker.""" + source = textwrap.dedent("""\ + from datetime import timedelta + from mcp import ClientSession + + a = ClientSession(read, write, read_timeout_seconds=timedelta(seconds=5)) + b = ClientSession(read, write, 30.0) + """) + once = transform(source) + assert "timedelta(seconds=5).total_seconds()" in once.code + assert not any(d.severity == "manual" for d in once.diagnostics) + again = transform(once.code) + assert again.code == once.code + assert again.diagnostics == [] + + +def test_no_injection_happens_when_everything_needed_is_bound() -> None: + """A rewrite that needs only `mcp_types` injects nothing into a file that + already imports it.""" + source = textwrap.dedent("""\ + import mcp_types + from mcp import ClientSession + + async def load(session: ClientSession): + return await session.list_tools(cursor=token) + """) + result = transform(source) + assert result.code.count("import mcp_types") == 1 diff --git a/uv.lock b/uv.lock index a1e8a7e35..4b2ef6d11 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,20 @@ docs = [ { name = "mkdocstrings-python", specifier = ">=2.0.1" }, ] +[[package]] +name = "mcp-codemod" +source = { editable = "src/mcp-codemod" } +dependencies = [ + { name = "libcst" }, + { name = "packaging" }, +] + +[package.metadata] +requires-dist = [ + { name = "libcst", specifier = ">=1.8.6" }, + { name = "packaging", specifier = ">=24.0" }, +] + [[package]] name = "mcp-everything-server" version = "0.1.0" @@ -1518,7 +1606,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 +1649,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 +2229,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 +2415,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 +2459,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"