mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Split the SDK upgrade guides by SDK version (#4684)
This commit is contained in:
parent
d6b9daecb1
commit
0175bc9235
15 changed files with 2170 additions and 322 deletions
250
tests/docs/test_upgrade_guide_api_claims.py
Normal file
250
tests/docs/test_upgrade_guide_api_claims.py
Normal 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}"
|
||||
)
|
||||
239
tests/docs/test_upgrade_guide_equivalence.py
Normal file
239
tests/docs/test_upgrade_guide_equivalence.py
Normal 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!"
|
||||
101
tests/docs/test_upgrade_guide_examples.py
Normal file
101
tests/docs/test_upgrade_guide_examples.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue