Skip to content

Commit d99b140

Browse files
committed
Rewrite lowlevel decorator registrations through generated adapters
The twelve v1 @server.* decorator kinds are gone on v2. Their sites now become add_request_handler / add_notification_handler calls at the decorator's exact source position (registration there is when the v1 decorator ran, so execution order is preserved and the deprecated capabilities land on the warning-free path), wired through generated adapters that reproduce the v1 wrapper semantics: bare-list wrapping, call_tool's any-exception-to-isError contract with jsonschema input and output validation (tool lookup through the registered tools/list handler, v1's own cache mechanism, so cross-module list_tools works), read_resource content conversion, and the completion None-mapping. Handler bodies are never touched. Shapes the adapter cannot serve honestly -- a stacked decorator, an attribute receiver, a non-v1 signature, a non-literal decorator argument, a taken name -- are marked with the reason. The suite migrates a six-registration server and serves it to a v1-shaped ClientSession over the legacy protocol; the templates are pinned against the installed v2 (method strings register, params models exist, imports resolve, no 2026-era surface is emitted). Also on the client surface: inline timedelta session timeouts convert to float seconds and non-provable values are marked (the mismatch only fails on the first request); cursor= on session list_* methods wraps into params=PaginatedRequestParams(...); pydantic URL wrappers around resource URIs are dropped where the target provably takes v2's plain str and marked elsewhere; constructions of and pydantic method calls on the v1 RootModel wrappers that became plain union aliases are marked with the TypeAdapter fix; ._mcp_server and the type-keyed handler dicts are marked with their v2 homes. Adapters honor an explicit `uri: str` annotation and keep v1's AnyUrl otherwise, and keep the emitted code insensitive to user return annotations so a wrong annotation cannot manufacture type errors inside generated code. Batch harness: seven more pinned repositories (two seven-decorator servers, a multi-package lowlevel server, the method-local-server marker path, two client libraries including a positional timedelta timeout and the old streamablehttp spelling, and an exact ==1.6.0 pin). Markers now cover the full statement they precede rather than a fixed radius, Unknown-typed errors in files that carry markers classify as cascade of a marked break, and the work directory is a dot-directory so pytest never collects the cloned repositories' own suites. All eleven repositories audit at zero uncovered errors. An adversarial review round over the full change confirmed ten defects, all fixed with regression tests: adapter imports now inject at the top of the module (a mid-file import as the anchor left registration code running before its imports bound); the rewrite gates now also block a handler named like a template local, and any module-level non-import binding of a name the adapter references (both were silent runtime breaks past the gates); import injection dedup now reads the updated module's top-level import binds, so conditional or function-local imports no longer suppress a needed injection; list_* adapters pass a returned full result model through instead of double-wrapping (v1's runtime behavior); the blocked-progress marker names add_notification_handler (a request-handler registration would never fire); the timeout transform skips already-v2 shapes so re-runs stay no-ops; the emitted name scheme is defined once and shared between templates and gates; and the harness classifier no longer lets a marker cover a whole def/class body or write off arbitrary Unknown-typed errors (header-only spans; cascade restricted to propagation rules and never detonators).
1 parent ecafdc7 commit d99b140

17 files changed

Lines changed: 2059 additions & 939 deletions

File tree

docs/migration.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Version 2 of the MCP Python SDK introduces several breaking changes to improve t
88

99
## Automated migration
1010

11-
The `mcp-codemod` tool (published from `src/mcp-codemod` in this repository) rewrites every change in this guide whose meaning is unambiguous from the file alone -- the import moves, the symbol renames, the `MCPError` reshape, the camelCase to snake_case field renames, and the `mcp` requirement in `pyproject.toml` / `requirements*.txt` -- and inserts a `# mcp-codemod:` comment above every site it recognized but would not guess at. Run it on a clean branch first, then work through what it marked:
11+
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:
1212

1313
```bash
1414
uvx mcp-codemod v1-to-v2 ./src
@@ -61,6 +61,13 @@ the raised `code`, `message`, and `data` intact. Previously the tool wrapper
6161
caught it like any other exception and returned `CallToolResult(isError=True)`,
6262
which discarded the error code and structured `data`.
6363

64+
The same applies to `@mcp.prompt()` and resource-template functions: an
65+
`MCPError` raised there propagates verbatim as the JSON-RPC error. On v1
66+
those wrappers converted it into a generic rendering error (for prompts, a
67+
`ValueError("Error rendering prompt ...")`), so a client matching on the
68+
error code or message will see the raised values instead of the wrapped
69+
generic ones.
70+
6471
`MCPError` carries `ErrorData` and is the SDK's protocol-error type — raise it
6572
when the request itself should be rejected (missing client capability,
6673
elicitation required, invalid parameters). For tool *execution* failures the
@@ -550,9 +557,14 @@ from mcp.shared.exceptions import MCPError
550557
try:
551558
result = await session.call_tool("my_tool")
552559
except MCPError as e:
553-
print(f"Error: {e.message}")
560+
print(f"Error: {e.error.message}")
554561
```
555562

563+
Only the exception's name changes: `MCPError.error` still carries the full
564+
`ErrorData`, so an existing handler body keeps working as written. `e.code`,
565+
`e.message`, and `e.data` also exist as direct read-only properties if you
566+
prefer the shorter spelling.
567+
556568
`MCPError` is also exported from the top-level `mcp` package:
557569

558570
```python
@@ -963,6 +975,17 @@ async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestPar
963975
server = Server("my-server", on_call_tool=handle_call_tool)
964976
```
965977

978+
Registration does not have to move to the constructor: `add_request_handler`
979+
(see [below](#lowlevel-server-add_request_handler-is-now-public-and-takes-params_type))
980+
registers the same handler into the same registry at any point before `run()`,
981+
which preserves your module's statement order — `mcp-codemod` migrates decorator
982+
registrations this way for exactly that reason:
983+
984+
```python
985+
server = Server("my-server")
986+
server.add_request_handler("tools/call", CallToolRequestParams, handle_call_tool)
987+
```
988+
966989
### `RequestContext` type parameters simplified
967990

968991
The `mcp.shared.context` module has been removed. `RequestContext` is now split into `ClientRequestContext` (in `mcp.client.context`) and `ServerRequestContext` (in `mcp.server.context`).

scripts/codemod-batch-test/repos.json

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
"slug": "official-servers",
44
"url": "https://github.com/modelcontextprotocol/servers",
55
"sha": "7b1170d1da1e36bc9f553f51e76e64cbfd652b3e",
6-
"include": ["src/fetch", "src/git", "src/time"],
6+
"include": [
7+
"src/fetch",
8+
"src/git",
9+
"src/time"
10+
],
711
"note": "The official reference servers; lowlevel Server and FastMCP usage."
812
},
913
{
@@ -17,7 +21,9 @@
1721
"slug": "awslabs-aws-documentation",
1822
"url": "https://github.com/awslabs/mcp",
1923
"sha": "3a5294539de4de3a91d0ee72d5487bc8b8b1fcd7",
20-
"include": ["src/aws-documentation-mcp-server"],
24+
"include": [
25+
"src/aws-documentation-mcp-server"
26+
],
2127
"note": "One server from the awslabs monorepo; production FastMCP usage."
2228
},
2329
{
@@ -26,5 +32,68 @@
2632
"sha": "451d255a7305e6efef8a1a2b7374a21c512bba45",
2733
"include": [],
2834
"note": "Small community FastMCP server."
35+
},
36+
{
37+
"slug": "mysql-mcp-server",
38+
"url": "https://github.com/designcomputer/mysql_mcp_server",
39+
"sha": "e25be7fc4e9e79d7efc52eb69d776129429a837f",
40+
"include": [
41+
"src/mysql_mcp_server"
42+
],
43+
"note": "Seven lowlevel decorators on a module-level Server in one file; stdio + SSE wiring."
44+
},
45+
{
46+
"slug": "kaltura-mcp",
47+
"url": "https://github.com/zoharbabin/kaltura-mcp",
48+
"sha": "567f016e536692959e294c1ee94c9fc901576cd8",
49+
"include": [
50+
"src/kaltura_mcp"
51+
],
52+
"note": "Full seven-decorator lowlevel server with an explicit mcp>=1,<2 pin; handlers dispatch into other modules."
53+
},
54+
{
55+
"slug": "arxiv-mcp-server",
56+
"url": "https://github.com/blazickjp/arxiv-mcp-server",
57+
"sha": "d58901760d7ede4adb162eaba1725209a933f100",
58+
"include": [
59+
"src/arxiv_mcp_server"
60+
],
61+
"note": "Decorator-registered lowlevel server whose handlers live across tools/, prompts/, resources/ subpackages."
62+
},
63+
{
64+
"slug": "fastapi-mcp",
65+
"url": "https://github.com/tadata-org/fastapi_mcp",
66+
"sha": "e5cad13cabfc725bbcb047e526816d887d96da62",
67+
"include": [
68+
"fastapi_mcp"
69+
],
70+
"note": "Decorators on a method-local Server closing over self, with request_context introspection: the marker path."
71+
},
72+
{
73+
"slug": "langchain-mcp-adapters",
74+
"url": "https://github.com/langchain-ai/langchain-mcp-adapters",
75+
"sha": "6a10b83516e825b8ff73870e5595113acc1c8c6d",
76+
"include": [
77+
"langchain_mcp_adapters"
78+
],
79+
"note": "Client library exercising every v1 transport, session kwargs pass-through, and cursor pagination."
80+
},
81+
{
82+
"slug": "mcpadapt",
83+
"url": "https://github.com/grll/mcpadapt",
84+
"sha": "538cd85628b555ef4ad9392b7270b52274f444d4",
85+
"include": [
86+
"src/mcpadapt"
87+
],
88+
"note": "Client library on the old streamablehttp_client spelling with a positional timedelta session timeout."
89+
},
90+
{
91+
"slug": "chroma-mcp",
92+
"url": "https://github.com/chroma-core/chroma-mcp",
93+
"sha": "98ff67589bdcc31b730a5415ff9529433f949077",
94+
"include": [
95+
"src/chroma_mcp"
96+
],
97+
"note": "Org-backed FastMCP server frozen on mcp[cli]==1.6.0: the exact-pin dependency rewrite."
2998
}
3099
]

scripts/codemod-batch-test/run.py

Lines changed: 75 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,14 @@
11
"""Run the v1 -> v2 codemod against real pinned repositories and audit the result.
22
3-
For each repository in `repos.json` this script clones the pinned commit, runs
4-
the codemod over a copy, and type-checks both sides with pyright: the pristine
5-
clone against an environment holding the latest v1 SDK, the migrated copy
6-
against this workspace's v2 environment. Errors that appear only on the
7-
migrated side are the migration surface; each one is then correlated with the
8-
`# mcp-codemod:` markers the codemod inserted.
3+
Each pinned repo is migrated and pyright-checked on both sides (pristine against the
4+
latest v1 SDK, migrated against this workspace's v2). Every new error must sit on or
5+
near a `# mcp-codemod:` marker; an uncovered error is a silent miss and exits 1.
96
10-
The codemod's headline contract is that the markers are the complete list of
11-
remaining manual work, so every new error should sit on or next to a marker. A
12-
new error with no nearby marker is a silent miss -- the exit code is 1 when any
13-
exists, and each is printed for triage.
14-
15-
Usage, from the repository root:
16-
17-
uv run --frozen python scripts/codemod-batch-test/run.py [--repo SLUG] [--fresh]
7+
Usage: uv run --frozen python scripts/codemod-batch-test/run.py [--repo SLUG] [--fresh]
188
"""
199

2010
import argparse
11+
import ast
2112
import json
2213
import shutil
2314
import subprocess
@@ -32,28 +23,23 @@
3223

3324
HARNESS_DIR = Path(__file__).resolve().parent
3425
WORKSPACE_ROOT = HARNESS_DIR.parents[1]
35-
WORK_DIR = HARNESS_DIR / "work"
26+
# Dot-directory: pytest's default norecursedirs keeps cloned repos' test suites out of `./scripts/test`.
27+
WORK_DIR = HARNESS_DIR / ".work"
3628

37-
# The marker-to-error distance (in lines) still counted as "this error is
38-
# explained by that marker". Markers sit on the line above their site; a small
39-
# allowance covers multi-line statements.
29+
# Max line distance for an error to still count as explained by a marker.
4030
MARKER_RADIUS = 3
4131

42-
# Uncovered errors default to actionable. Only these pyright rules, in a file
43-
# the codemod did not touch and with no mcp symbol in the message, are written
44-
# off as v2's own typing getting stricter about the repo's code (mocks no
45-
# longer satisfying defaulted generics, narrower `| None` returns). Notably
46-
# `reportAttributeAccessIssue` is NOT here: a removed attribute the codemod
47-
# failed to flag looks exactly like that.
32+
# Rules written off as v2 strictness drift, but only in a file the codemod did not touch and with
33+
# no mcp symbol in the message. `reportAttributeAccessIssue` is absent: a missed removal looks like it.
4834
DRIFT_RULES = frozenset({"reportArgumentType", "reportOptionalSubscript", "reportOptionalMemberAccess"})
4935

50-
# Argument types that detonate at RUNTIME on v2 (`timedelta` where v2 takes float
51-
# seconds, `AnyUrl` where v2 takes `str`). A `reportArgumentType` error naming one
52-
# of these is a real break pyright happens to catch, never strictness drift.
36+
# A `reportArgumentType` error naming one of these is a real runtime break on v2, never strictness drift.
5337
DETONATOR_TYPES = ("timedelta", "AnyUrl")
5438

55-
# The v1 environment lives OUTSIDE the SDK checkout: inside it, uv resolves the
56-
# SDK workspace itself no matter the cwd, and the env would silently hold v2.
39+
# Rules that carry a break's downstream type propagation rather than its source.
40+
CASCADE_RULES = frozenset({"reportArgumentType", "reportAssignmentType", "reportCallIssue", "reportReturnType"})
41+
42+
# Outside the SDK checkout: inside it, uv resolves the SDK workspace itself and the env would hold v2.
5743
V1_ENV_DIR = Path.home() / ".cache" / "mcp-codemod-batch-test" / "v1env"
5844

5945
V1_ENV_PYPROJECT = """\
@@ -115,9 +101,7 @@ def _load_repos(only: str | None) -> list[Repo]:
115101
def _ensure_v1_environment() -> Path:
116102
"""Create (once) an environment holding the latest v1 SDK; return its python.
117103
118-
The returned interpreter is verified to really import a v1 `mcp.types` --
119-
a baseline accidentally type-checked against v2 reports no migration delta
120-
at all, so this fails loudly instead.
104+
Fails loudly unless it really holds v1: a v2 baseline would report no migration delta.
121105
"""
122106
env_dir = V1_ENV_DIR
123107
python = env_dir / ".venv" / "bin" / "python"
@@ -159,9 +143,9 @@ def _pyright_errors(repo: Repo, *, python: Path, side: Path) -> list[PyrightErro
159143
"""Type-check one side against the env of `python`, or None when pyright dies.
160144
161145
The config is written into the side's own root with relative includes, so
162-
that root is the project root and nothing outside it is ever scanned. The
163-
interpreter goes on the command line: `--pythonpath` beats the implicit
164-
`VIRTUAL_ENV` that `uv run` exports, which a config `venvPath` does not.
146+
nothing outside it is ever scanned.
147+
148+
`--pythonpath` beats the implicit `VIRTUAL_ENV` that `uv run` exports, which a config `venvPath` does not.
165149
"""
166150
config = {
167151
"include": list(repo.include) or ["."],
@@ -181,8 +165,7 @@ def _pyright_errors(repo: Repo, *, python: Path, side: Path) -> list[PyrightErro
181165
summary = output.get("summary")
182166
assert isinstance(summary, dict)
183167
if not summary.get("filesAnalyzed"):
184-
# A bad include path makes pyright "succeed" over nothing; a verdict
185-
# based on that would be a lie, so the repo fails instead.
168+
# A bad include path makes pyright "succeed" over nothing; fail the repo instead.
186169
print(f" pyright analyzed zero files in {side} -- check the include paths", file=sys.stderr)
187170
return None
188171
diagnostics = output.get("generalDiagnostics")
@@ -206,21 +189,57 @@ def _pyright_errors(repo: Repo, *, python: Path, side: Path) -> list[PyrightErro
206189
return errors
207190

208191

209-
def _collect_markers(roots: list[Path], side: Path) -> dict[str, list[int]]:
210-
"""Every `# mcp-codemod:` line in the migrated tree, by file."""
211-
markers: dict[str, list[int]] = {}
192+
def _statement_spans(source: str) -> list[tuple[int, int]]:
193+
"""The (lineno, end_lineno) of every statement in a parseable Python file.
194+
195+
A compound statement contributes only its HEADER lines (up to its first body
196+
statement): a marker above a `with` covers the multi-line call in its header,
197+
never the hundreds of lines inside a def or class body.
198+
"""
199+
try:
200+
tree = ast.parse(source)
201+
except SyntaxError:
202+
return []
203+
spans: list[tuple[int, int]] = []
204+
for node in ast.walk(tree):
205+
if not isinstance(node, ast.stmt):
206+
continue
207+
end = node.end_lineno or node.lineno
208+
body = getattr(node, "body", None)
209+
if isinstance(body, list) and body and isinstance(body[0], ast.stmt):
210+
end = min(end, body[0].lineno - 1)
211+
spans.append((node.lineno, end))
212+
return spans
213+
214+
215+
def _collect_markers(roots: list[Path], side: Path) -> dict[str, list[tuple[int, int]]]:
216+
"""Every `# mcp-codemod:` line in the migrated tree, by file, as covered spans.
217+
218+
A marker covers `MARKER_RADIUS` around itself plus any statement starting within that radius below it.
219+
"""
220+
markers: dict[str, list[tuple[int, int]]] = {}
212221
needle = f"# {MARKER}:"
213222
for root in roots:
214223
candidates = [path for path in root.rglob("*") if path.suffix == ".py" or path.name == "pyproject.toml"]
215224
candidates += list(root.rglob("requirements*.txt"))
216225
for path in candidates:
217226
try:
218-
lines = path.read_bytes().decode("utf-8").splitlines()
227+
source = path.read_bytes().decode("utf-8")
219228
except (OSError, UnicodeDecodeError):
220229
continue
230+
lines = source.splitlines()
221231
hits = [number for number, line in enumerate(lines, start=1) if needle in line]
222-
if hits:
223-
markers[str(path.relative_to(side))] = hits
232+
if not hits:
233+
continue
234+
spans = _statement_spans(source) if path.suffix == ".py" else []
235+
covered: list[tuple[int, int]] = []
236+
for hit in hits:
237+
end = hit + MARKER_RADIUS
238+
for start, stop in spans:
239+
if hit < start <= hit + MARKER_RADIUS:
240+
end = max(end, stop)
241+
covered.append((hit - MARKER_RADIUS, end))
242+
markers[str(path.relative_to(side))] = covered
224243
return markers
225244

226245

@@ -256,13 +275,18 @@ def _audit_repo(repo: Repo, *, v1_python: Path, fresh: bool) -> tuple[dict[str,
256275
markers = _collect_markers(roots, migrated)
257276
actionable: list[PyrightError] = []
258277
drift: list[PyrightError] = []
278+
cascade: list[PyrightError] = []
259279
for error in new_errors:
260-
nearby = markers.get(error.file, [])
261-
if any(abs(line - error.line) <= MARKER_RADIUS for line in nearby):
280+
spans = markers.get(error.file, [])
281+
if any(start <= error.line <= end for start, end in spans):
282+
continue
283+
# A break's source always errors without "Unknown" in its message, so
284+
# "Unknown" only appears in downstream propagation -- and in a marked file
285+
# the roots are the marked ones. Detonators stay actionable regardless.
286+
is_detonator = any(f'of type "{detonator}"' in error.message for detonator in DETONATOR_TYPES)
287+
if "Unknown" in error.message and spans and not is_detonator and error.rule in CASCADE_RULES:
288+
cascade.append(error)
262289
continue
263-
# Uncovered errors are actionable unless everything says v2 strictness
264-
# drift: an untouched file, no mcp symbol in the message, and a rule
265-
# from the drift list. A silent codemod miss fails any one of these.
266290
if (
267291
error.file not in rewritten_files
268292
and "mcp" not in error.message.lower()
@@ -273,10 +297,11 @@ def _audit_repo(repo: Repo, *, v1_python: Path, fresh: bool) -> tuple[dict[str,
273297
else:
274298
actionable.append(error)
275299

276-
covered = len(new_errors) - len(actionable) - len(drift)
300+
covered = len(new_errors) - len(actionable) - len(drift) - len(cascade)
277301
print(
278302
f" pyright: {len(baseline)} baseline errors, {len(new_errors)} new after migration "
279-
f"({resolved} resolved): {covered} covered by markers, {len(drift)} v2 strictness drift"
303+
f"({resolved} resolved): {covered} covered by markers, {len(cascade)} marked-break cascade, "
304+
f"{len(drift)} v2 strictness drift"
280305
)
281306
for error in actionable:
282307
print(f" UNCOVERED {error.file}:{error.line} [{error.rule}] {error.message.splitlines()[0]}")

src/mcp-codemod/README.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,21 @@ manual fix-up.
4040
change.
4141
- The `streamable_http_client(...) as (read, write, _)` three-tuple to the v2
4242
two-tuple.
43+
- Lowlevel `@server.list_tools()` / `@server.call_tool()` / ... decorator
44+
registrations, to `server.add_request_handler(...)` calls at the same source
45+
position, each wired through a generated adapter that reproduces the v1
46+
wrapper semantics your handler relied on: bare-list wrapping, the `call_tool`
47+
isError contract with jsonschema input/output validation, `read_resource`
48+
content conversion, and the completion None-mapping. Your handler bodies are
49+
not touched. A shape the adapter cannot serve honestly (a stacked decorator,
50+
a `self.`-attribute server, a non-v1 signature) is marked instead.
51+
- Positional arguments after the name on the lowlevel `Server(...)` constructor
52+
to keywords (v2 is keyword-only there but kept v1's names and order).
53+
- An inline `timedelta(...)` passed as a `ClientSession` timeout to
54+
`.total_seconds()` (v2 takes float seconds and would only fail on the first
55+
request), `cursor=` on the session's `list_*` methods to the v2
56+
`params=PaginatedRequestParams(...)` form, and a pydantic `AnyUrl(...)` /
57+
`FileUrl(...)` wrapper around a resource URI to the plain string v2 expects.
4358
- The `mcp` requirement in `pyproject.toml` and `requirements*.txt`, to
4459
`>=2,<3`, wherever the current constraint cannot accept any v2 release. Only
4560
the version specifier changes; the name, extras, environment marker, and
@@ -69,9 +84,11 @@ The codemod never guesses at these; it leaves them exactly as written and adds a
6984
`stateless_http=`, ...), which moved to `run()` or one of the app methods. The
7085
right destination depends on how you start the server, so the kwarg is left in
7186
place -- v2 then fails loudly -- rather than silently dropped.
72-
- Lowlevel `@server.call_tool()` decorators, which became `on_call_tool=`
73-
constructor arguments with a different handler signature. Rewriting the
74-
registration also means rewriting the handler body, which is yours to do.
87+
- Lowlevel decorator registrations the generated adapters cannot serve
88+
honestly: a second decorator stacked on the handler, a server reached through
89+
an attribute (`self.server`), a handler signature away from the v1 form, or a
90+
decorator argument the codemod cannot evaluate. The marker names the reason
91+
and the `add_request_handler(...)` destination.
7592
- Renames the codemod applied but cannot prove are right: a camelCase rename
7693
whose receiver could plausibly not be an mcp type gets a `# mcp-codemod: review:`
7794
marker so you look at it instead of trusting it.

0 commit comments

Comments
 (0)