Split the SDK upgrade guides by SDK version (#4684)

This commit is contained in:
Jeremiah Lowin 2026-07-28 10:47:39 -04:00 committed by GitHub
commit 0175bc9235
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2170 additions and 322 deletions

View file

@ -0,0 +1,250 @@
"""Check the API claims the upgrade guides make against the real APIs.
The other two doc tests cover code blocks: one executes them, one compares the
before/after pair. Neither looks at *prose*, and prose is where a migration
guide does most of its work mapping tables, prompt checklists, and sentences
naming an attribute to use. Those claims went wrong repeatedly and in the same
way: an API was named without anyone checking it resolved.
So this file checks the claims mechanically:
- every ``ctx.<name>`` the guides tell a reader to *use* exists on the class
they'd be using it on, and every one they name as removed really is gone
- every ``MCPServer`` constructor parameter appears somewhere in the SDK v2
guide, so a newly added SDK argument can't quietly go unmapped
- the ``request_context`` attributes the guides route people to are real
Run:
uv run pytest tests/docs/test_upgrade_guide_api_claims.py -v
"""
from __future__ import annotations
import inspect
import re
import warnings
from pathlib import Path
from typing import Any
import pytest
UPGRADE_DIR = Path("docs/getting-started/upgrading")
def _guide(name: str) -> str:
return (UPGRADE_DIR / name).read_text("utf-8")
with warnings.catch_warnings():
warnings.simplefilter("ignore")
from mcp.server.mcpserver import MCPServer
from fastmcp import Context as FastMCPContext
# Context attributes the guides may mention without them existing on FastMCP's
# Context, because the guide's whole point is that they are gone or moved. Each
# is asserted to genuinely be absent, so a name that later gains an
# implementation stops being listed as missing.
DOCUMENTED_AS_ABSENT = {
"sample",
"sample_step",
"list_roots",
"mcp_server",
"headers",
"protocol_version",
"client_capabilities",
"elicit_url",
"close_standalone_sse_stream",
"notify_tools_changed",
"notify_resources_changed",
"notify_prompts_changed",
"notify_resource_updated",
"params",
"meta",
}
def test_absent_context_attributes_are_really_absent():
"""Names the guides describe as gone must not exist on FastMCP's Context.
If one of these gains an implementation, the guides are now telling people
to work around something that works, and this test says so.
"""
resurrected = [
n for n in sorted(DOCUMENTED_AS_ABSENT) if hasattr(FastMCPContext, n)
]
assert not resurrected, (
f"guides describe these as absent from fastmcp.Context, but they exist: {resurrected}"
)
@pytest.mark.parametrize(
"guide",
sorted(p.name for p in UPGRADE_DIR.glob("*.mdx")),
)
def test_ctx_attributes_named_in_guides_exist(guide: str):
"""Every ``ctx.<name>`` in a guide either exists or is documented as absent."""
referenced = set(re.findall(r"`ctx\.([a-z_]+)", _guide(guide)))
unknown = {
name
for name in referenced
if not hasattr(FastMCPContext, name) and name not in DOCUMENTED_AS_ABSENT
}
assert not unknown, (
f"{guide} names ctx.{{{', '.join(sorted(unknown))}}}, which do not exist on "
f"fastmcp.Context and are not in DOCUMENTED_AS_ABSENT"
)
def test_request_context_attributes_the_guides_route_to_exist():
"""The guides send people to ``ctx.request_context`` for several attributes.
``FastMCPRequestContext`` resolves its attributes dynamically, so this is
checked against a live request rather than the class.
"""
import asyncio
from fastmcp import Client, FastMCP
mcp = FastMCP("probe")
@mcp.tool
async def probe(ctx: FastMCPContext) -> list[str]:
rc = ctx.request_context
return [n for n in ("request_id", "meta", "protocol_version") if hasattr(rc, n)]
async def run() -> list[str]:
async with Client(mcp) as client:
return (await client.call_tool("probe", {})).data
with warnings.catch_warnings():
warnings.simplefilter("ignore")
present = asyncio.run(run())
assert set(present) == {"request_id", "meta", "protocol_version"}
# Context methods the SDK v2 guide says are *genuinely* unchanged. Existence is
# not enough for that claim — a method present on both classes with a different
# signature is worse than a missing one, because the import swap compiles and
# fails at runtime. So these are compared signature-for-signature.
CLAIMED_SIGNATURE_COMPATIBLE = ["report_progress"]
# Present on both, but with signatures that differ. The guide must describe each
# migration rather than list it as carrying over; this pins the difference so a
# future SDK or FastMCP release that converges them shows up as a failure.
KNOWN_SIGNATURE_DIFFERENCES = ["log", "info", "debug", "warning", "error", "elicit"]
@pytest.mark.parametrize("method", CLAIMED_SIGNATURE_COMPATIBLE)
def test_methods_claimed_unchanged_have_identical_signatures(method: str):
from mcp.server.mcpserver import Context as SDKContext
sdk = inspect.signature(getattr(SDKContext, method))
fastmcp = inspect.signature(getattr(FastMCPContext, method))
assert str(sdk) == str(fastmcp), (
f"the SDK v2 guide lists ctx.{method} as carrying over unchanged, but "
f"the signatures differ:\n SDK : {sdk}\n FastMCP: {fastmcp}"
)
@pytest.mark.parametrize("method", KNOWN_SIGNATURE_DIFFERENCES)
def test_methods_with_known_signature_differences_still_differ(method: str):
from mcp.server.mcpserver import Context as SDKContext
sdk = inspect.signature(getattr(SDKContext, method))
fastmcp = inspect.signature(getattr(FastMCPContext, method))
assert str(sdk) != str(fastmcp), (
f"ctx.{method} signatures now match; the guide's migration note for it "
f"is stale and should be moved to the unchanged list"
)
# SDK v1's `mcp.server.fastmcp.FastMCP.__init__` parameters. Hardcoded because
# v1 cannot be installed alongside v4 to introspect — read from the published
# mcp 1.20.0 wheel. Anything here that FastMCP 4 does not accept must appear in
# the v1 guide, since a reader following "it's one import change" hits it.
SDK_V1_CONSTRUCTOR_PARAMS = [
"name", "instructions", "website_url", "icons", "auth_server_provider",
"token_verifier", "event_store", "tools", "debug", "log_level", "host",
"port", "mount_path", "sse_path", "message_path", "streamable_http_path",
"json_response", "stateless_http", "warn_on_duplicate_resources",
"warn_on_duplicate_tools", "warn_on_duplicate_prompts", "dependencies",
"lifespan", "auth", "transport_security", "transport",
] # fmt: skip
def test_sdk_v1_constructor_params_fastmcp_rejects_are_documented():
"""Every v1 keyword FastMCP 4 refuses must be named in the v1 guide.
The guide's headline is that upgrading is a single import change. That is
only honest if the constructor arguments it *doesn't* accept are spelled
out, so nobody follows the headline into a ``TypeError``.
"""
from fastmcp import FastMCP
guide = _guide("from-mcp-sdk-v1.mdx")
probe: dict[str, Any] = {
"name": "s",
"icons": None,
"tools": None,
"lifespan": None,
}
undocumented = []
for param in SDK_V1_CONSTRUCTOR_PARAMS:
if param == "name":
continue
kwargs: dict[str, Any] = {param: probe.get(param)}
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
FastMCP("s", **kwargs)
continue # accepted, nothing to document
except TypeError:
pass
except Exception:
continue # accepted the keyword, rejected the probe value
shorthand = param.replace("warn_on_duplicate", "")
if re.search(rf"`{re.escape(param)}[=`]", guide):
continue
if param.startswith("warn_on_duplicate") and re.search(
rf"`{re.escape(shorthand)}[=`]", guide
):
continue
undocumented.append(param)
assert not undocumented, (
"SDK v1 FastMCP() parameters that FastMCP 4 rejects but from-mcp-sdk-v1.mdx "
f"never mentions: {undocumented}"
)
def test_every_mcpserver_constructor_param_is_mapped():
"""The SDK v2 guide claims an exhaustive constructor mapping — hold it to that.
A parameter added to ``MCPServer`` upstream should fail here rather than
reach a reader as an unmapped keyword that raises ``TypeError`` on FastMCP.
"""
guide = _guide("from-mcp-sdk-v2.mdx")
params = [
p for p in inspect.signature(MCPServer.__init__).parameters if p != "self"
]
unmapped = []
for param in params:
# `warn_on_duplicate_resources` is covered by the table's shorthand
# "warn_on_duplicate_tools, _resources, _prompts".
shorthand = param.replace("warn_on_duplicate", "")
if re.search(rf"`{re.escape(param)}[=`]", guide):
continue
if param.startswith("warn_on_duplicate") and re.search(
rf"`{re.escape(shorthand)}`", guide
):
continue
unmapped.append(param)
assert not unmapped, (
f"MCPServer constructor parameters not mentioned in from-mcp-sdk-v2.mdx: {unmapped}"
)

View file

@ -0,0 +1,239 @@
"""Prove the SDK v2 upgrade guides produce an equivalent server.
`test_upgrade_guide_examples.py` proves every example runs. That is necessary
but not sufficient: a migration guide is only correct if the "after" code
exposes the same MCP surface as the "before" code it replaces. A guide whose
halves both run but disagree on a tool's schema teaches a silent regression.
So for each MCP SDK v2 guide, the complete before-and-after server pair is
lifted out of the page, both halves are built, and their advertised tools,
resources, templates, and prompts are compared. The SDK v1 guides are not
covered here v1 is not installable alongside v4, so their "before" code
cannot be built to compare against.
Run:
uv run pytest tests/docs/test_upgrade_guide_equivalence.py -v
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from uuid import uuid4
import pytest
from pytest_examples.find_examples import _extract_code_chunks
from fastmcp import Client, FastMCP
UPGRADE_DIR = Path("docs/getting-started/upgrading")
def _block_containing(page: str, needle: str) -> dict[str, Any]:
"""Execute the one code block on `page` that contains `needle`."""
path = UPGRADE_DIR / page
matches = [
ex
for ex in _extract_code_chunks(path, path.read_text("utf-8"), uuid4())
if needle in ex.source
]
assert len(matches) == 1, (
f"expected exactly one block in {page} containing {needle!r}, "
f"found {len(matches)}"
)
namespace: dict[str, Any] = {"__name__": "fastmcp_docs_example"}
exec(compile(matches[0].source, str(path), "exec"), namespace)
return namespace
def _strip_titles(node: Any) -> Any:
"""Recursively drop every "title" key, the one difference that's genuinely cosmetic.
A hand-written SDK schema has no title anywhere; FastMCP derives one at every
level from the function/model it built the schema from. Everything else in the
tree constraints, "additionalProperties", nested "anyOf"/"const", enum values
is retained, because those describe what a client is allowed to send and a
silent difference there is exactly the kind of regression this test exists to
catch.
"""
if isinstance(node, dict):
return {k: _strip_titles(v) for k, v in node.items() if k != "title"}
if isinstance(node, list):
return [_strip_titles(v) for v in node]
return node
def _normalize(schema: dict[str, Any] | None) -> dict[str, Any]:
"""Compare schemas by their full structure, modulo generated titles.
"required" is sorted because the SDK and FastMCP may build it in a different
parameter order for the same signature an ordering difference, not a
contract difference.
"""
if not schema:
return {}
stripped = _strip_titles(schema)
if "required" in stripped:
stripped["required"] = sorted(stripped["required"])
return stripped
def _split_declared_strictness(
before: dict[str, dict[str, Any]], after: dict[str, dict[str, Any]]
) -> dict[str, dict[str, Any]]:
"""Pop `"additionalProperties": false` from every migrated schema, asserting it's there.
FastMCP's generated tool schemas declare `"additionalProperties": false`; a
schema built by either SDK server API does not declare it. This is a real
contract change, not a cosmetic one both SDK APIs *accept* an unexpected
argument at call time, and FastMCP rejects it (pinned by
`test_fastmcp_tightens_the_argument_contract` in each class below). It is
popped here only so the rest of the schema can be compared field for field,
and popping is an assertion rather than a silent discard: if FastMCP ever
stops declaring it, or the SDK starts, this fails.
"""
stripped: dict[str, dict[str, Any]] = {}
for name, schema in after.items():
schema = dict(schema)
assert schema.pop("additionalProperties", None) is False, (
f"expected FastMCP to declare additionalProperties: false for {name!r}"
)
assert "additionalProperties" not in before.get(name, {}), (
f"expected the SDK schema for {name!r} not to declare additionalProperties"
)
stripped[name] = schema
return stripped
async def _fastmcp_surface(mcp: FastMCP) -> dict[str, Any]:
async with Client(mcp) as client:
tools = await client.list_tools()
resources = await client.list_resources()
templates = await client.list_resource_templates()
prompts = await client.list_prompts()
return {
"tools": {t.name: _normalize(t.input_schema) for t in tools},
"resources": {str(r.uri) for r in resources},
"templates": {t.uri_template for t in templates},
"prompts": {p.name: sorted(a.name for a in p.arguments or []) for p in prompts},
}
class TestMCPServerGuide:
"""docs/.../from-mcp-sdk-v2.mdx — the high-level MCPServer migration."""
@pytest.fixture(scope="class")
def pair(self) -> tuple[Any, FastMCP]:
before = _block_containing("from-mcp-sdk-v2.mdx", 'MCPServer("demo")')
after = _block_containing("from-mcp-sdk-v2.mdx", 'FastMCP("demo")')
return before["server"], after["mcp"]
async def test_same_surface(self, pair):
server, mcp = pair
before = {
"tools": {
t.name: _normalize(t.input_schema) for t in await server.list_tools()
},
"resources": {str(r.uri) for r in await server.list_resources()},
"templates": {
t.uri_template for t in await server.list_resource_templates()
},
"prompts": {
p.name: sorted(a.name for a in p.arguments or [])
for p in await server.list_prompts()
},
}
after = await _fastmcp_surface(mcp)
after["tools"] = _split_declared_strictness(before["tools"], after["tools"])
assert before == after
async def test_fastmcp_tightens_the_argument_contract(self, pair):
"""FastMCP rejects an unexpected argument where MCPServer accepts it.
This is the behavior behind the `additionalProperties` schema difference,
and it is a real change for any caller that was passing extra keys.
"""
server, mcp = pair
tolerated = await server.call_tool(
"greet", {"name": "World", "extra": "surprise"}
)
assert tolerated.is_error is False
assert tolerated.content[0].text == "Hello, World!"
async with Client(mcp) as client:
with pytest.raises(Exception):
await client.call_tool("greet", {"name": "World", "extra": "surprise"})
async def test_migrated_tools_still_work(self, pair):
_, mcp = pair
async with Client(mcp) as client:
greeting = await client.call_tool("greet", {"name": "World"})
processed = await client.call_tool("process", {"items": ["a", "b"]})
assert greeting.data == "Hello, World!"
assert processed.data == "Processed 2 items"
class TestLowLevelGuide:
"""docs/.../from-low-level-sdk-v2.mdx — the low-level Server migration."""
@pytest.fixture(scope="class")
def pair(self) -> tuple[dict[str, Any], FastMCP]:
before = _block_containing("from-low-level-sdk-v2.mdx", ' "demo",')
after = _block_containing("from-low-level-sdk-v2.mdx", 'FastMCP("demo")')
return before, after["mcp"]
async def test_same_surface(self, pair):
handlers, mcp = pair
tools = await handlers["list_tools"](None, None)
resources = await handlers["list_resources"](None, None)
prompts = await handlers["list_prompts"](None, None)
before = {
"tools": {t.name: _normalize(t.input_schema) for t in tools.tools},
"resources": {str(r.uri) for r in resources.resources},
"templates": set(),
"prompts": {
p.name: sorted(a.name for a in p.arguments or [])
for p in prompts.prompts
},
}
after = await _fastmcp_surface(mcp)
after["tools"] = _split_declared_strictness(before["tools"], after["tools"])
assert before == after
async def test_fastmcp_tightens_the_argument_contract(self, pair):
"""FastMCP rejects an unexpected argument where the handler ignored it.
A low-level handler reads `params.arguments` as a plain dict and never
looks at keys it doesn't need, so extras pass through silently. The
migrated tool rejects them. Pinned rather than normalized away, because
it is a real change for any caller that was passing extra keys.
"""
handlers, mcp = pair
params = type(
"Params", (), {"name": "greet", "arguments": {"name": "World", "extra": 1}}
)()
tolerated = await handlers["call_tool"](None, params)
assert tolerated.content[0].text == "Hello, World!"
async with Client(mcp) as client:
with pytest.raises(Exception):
await client.call_tool("greet", {"name": "World", "extra": 1})
async def test_handlers_and_tools_agree(self, pair):
"""The rewritten tool returns what the hand-written handler returned."""
handlers, mcp = pair
params = type("Params", (), {"name": "greet", "arguments": {"name": "World"}})()
handler_result = await handlers["call_tool"](None, params)
async with Client(mcp) as client:
tool_result = await client.call_tool("greet", {"name": "World"})
assert handler_result.content[0].text == "Hello, World!"
assert tool_result.data == "Hello, World!"

View file

@ -0,0 +1,101 @@
"""Execute the Python examples in the upgrade guides.
`test_doc_examples.py` covers every page in `docs/`, but only checks that
examples parse and that their ``fastmcp.*`` imports resolve. The upgrade guides
carry a stronger obligation: someone lands on one mid-migration, copies a block,
and runs it. So these examples are actually executed, and their non-FastMCP
imports (`mcp`, `mcp_types`) are exercised along with everything else.
Both halves of a `<CodeGroup>` are executed where they can be. The "after" code
is FastMCP 4, which this repo is. The "before" code is only runnable when it
targets the MCP SDK **v2** the version installed here which covers the two
SDK v2 guides. Blocks written against SDK v1 (whose `mcp.types` and
`mcp.server.fastmcp` no longer exist) and fragments that pair a "# Before" and
"# After" in one block are tagged ``test="skip"`` in the source and skipped here;
the count of those is pinned so a new one can't appear unnoticed.
Run:
uv run pytest tests/docs/test_upgrade_guide_examples.py -v
"""
from __future__ import annotations
import warnings
from pathlib import Path
from uuid import uuid4
import pytest
from pytest_examples import CodeExample
from pytest_examples.find_examples import _extract_code_chunks
import fastmcp
UPGRADE_DIR = Path("docs/getting-started/upgrading")
# Blocks deliberately not executable: SDK v1 API that is no longer installable,
# and before/after fragments that are not standalone programs. Pinned so that
# adding a skip is a visible decision rather than a silent one.
EXPECTED_SKIPS = 35
def _examples() -> list[CodeExample]:
examples: list[CodeExample] = []
for mdx_file in sorted(UPGRADE_DIR.rglob("*.mdx")):
code = mdx_file.read_text("utf-8")
examples.extend(_extract_code_chunks(mdx_file, code, uuid4()))
return examples
ALL = _examples()
RUNNABLE = [ex for ex in ALL if ex.prefix_settings().get("test") != "skip"]
SKIPPED = [ex for ex in ALL if ex.prefix_settings().get("test") == "skip"]
def _example_id(example: CodeExample) -> str:
return f"{Path(example.path).name}:{example.start_line}"
def test_guides_have_examples():
"""Guard against the extractor silently matching nothing."""
assert len(RUNNABLE) >= 20, f"only found {len(RUNNABLE)} runnable examples"
def test_skip_count_is_pinned():
"""A newly unrunnable example should be a deliberate choice."""
listing = "\n".join(f" {_example_id(ex)}" for ex in SKIPPED)
assert len(SKIPPED) == EXPECTED_SKIPS, (
f"expected {EXPECTED_SKIPS} skipped examples, found {len(SKIPPED)}:\n{listing}"
)
@pytest.fixture(autouse=True)
def restore_global_settings():
"""Undo any global setting an example changes.
Some examples exist precisely to show a global toggle the upgrade guide
demonstrates turning the camelCase bridge off with
``fastmcp.settings.mcp_camelcase_compat = False``. Executing that here
would otherwise leave the bridge off for every test that runs afterwards in
the same process, which silently breaks unrelated suites.
"""
before = fastmcp.settings.model_dump()
yield
for field, value in before.items():
if getattr(fastmcp.settings, field, value) != value:
setattr(fastmcp.settings, field, value)
@pytest.mark.parametrize("example", RUNNABLE, ids=[_example_id(e) for e in RUNNABLE])
def test_example_executes(example: CodeExample):
"""Every non-skipped example runs top to bottom without raising.
Examples are executed under a module name other than ``__main__`` so an
``if __name__ == "__main__": mcp.run()`` footer defines the server without
starting it.
"""
namespace: dict[str, object] = {"__name__": "fastmcp_docs_example"}
with warnings.catch_warnings():
# Guides intentionally demonstrate deprecated surfaces (the camelCase
# bridge, SDK logging) whose warnings are the point being made.
warnings.simplefilter("ignore")
exec(compile(example.source, str(example.path), "exec"), namespace)