From 3c484aa4a9a189350a11cb7bee4759436adb39fb Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:40:47 -0400 Subject: [PATCH 1/3] Fix stale SDK idioms in examples and scripts --- examples/custom_tool_serializer_decorator.py | 24 ++++++++++---------- examples/providers/sqlite/server.py | 2 +- examples/skills/client.py | 2 +- examples/tool_result_echo.py | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/examples/custom_tool_serializer_decorator.py b/examples/custom_tool_serializer_decorator.py index 7075b7238..bdf304dec 100644 --- a/examples/custom_tool_serializer_decorator.py +++ b/examples/custom_tool_serializer_decorator.py @@ -12,8 +12,8 @@ from typing import Any import yaml -from fastmcp import FastMCP -from fastmcp.tools.tool import ToolResult +from fastmcp import Client, FastMCP +from fastmcp.tools import ToolResult def with_serializer(serializer: Callable[[Any], str]): @@ -55,18 +55,18 @@ def get_json_data() -> dict: async def example_usage(): - # YAML serialized tool - yaml_result = await server._call_tool_mcp("get_example_data", {}) - print("YAML Tool Result:") - print(yaml_result) - print() + async with Client(server) as client: + # YAML serialized tool + yaml_result = await client.call_tool("get_example_data", {}) + print("YAML Tool Result:") + print(yaml_result.content[0].text) + print() - # Default JSON serialized tool - json_result = await server._call_tool_mcp("get_json_data", {}) - print("JSON Tool Result:") - print(json_result) + # Default JSON serialized tool + json_result = await client.call_tool("get_json_data", {}) + print("JSON Tool Result:") + print(json_result.content[0].text) if __name__ == "__main__": asyncio.run(example_usage()) - server.run() diff --git a/examples/providers/sqlite/server.py b/examples/providers/sqlite/server.py index dd9f19ff6..694a2906e 100644 --- a/examples/providers/sqlite/server.py +++ b/examples/providers/sqlite/server.py @@ -23,7 +23,7 @@ from rich import print from fastmcp import Client, FastMCP from fastmcp.server.providers import Provider -from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.tools import Tool, ToolResult DB_PATH = Path(__file__).parent / "tools.db" diff --git a/examples/skills/client.py b/examples/skills/client.py index a376fe235..d5005ac23 100644 --- a/examples/skills/client.py +++ b/examples/skills/client.py @@ -41,7 +41,7 @@ async def main(): print("=== Resource Templates ===") templates = await client.list_resource_templates() for t in templates: - print(f" {t.uriTemplate}") + print(f" {t.uri_template}") print() # Read a skill's main file diff --git a/examples/tool_result_echo.py b/examples/tool_result_echo.py index bd1185f29..54ed151de 100644 --- a/examples/tool_result_echo.py +++ b/examples/tool_result_echo.py @@ -10,7 +10,7 @@ import time from dataclasses import dataclass from fastmcp import FastMCP -from fastmcp.tools.tool import ToolResult +from fastmcp.tools import ToolResult mcp = FastMCP("Echo Server") From 824603517d4f065be80ae5500bfbdadd6a29bd18 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:46:41 -0400 Subject: [PATCH 2/3] Add import-resolution gate for examples --- tests/test_examples_importable.py | 138 ++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 tests/test_examples_importable.py diff --git a/tests/test_examples_importable.py b/tests/test_examples_importable.py new file mode 100644 index 000000000..02148ffec --- /dev/null +++ b/tests/test_examples_importable.py @@ -0,0 +1,138 @@ +"""Import-resolution gate for the ``examples/`` directory. + +The example scripts sit outside the ty and pytest import gates, so stale +SDK idioms (renamed modules, moved symbols, v1 import paths) shipped there +repeatedly without CI noticing. This test closes that gap: it discovers every +``.py`` under ``examples/`` and asserts, WITHOUT executing any of them, that + +1. the file parses as valid Python (AST), and +2. every top-level ``fastmcp`` / ``mcp`` / ``mcp_types`` import resolves + against the installed package — both the module path and the imported + names. This catches the recurring ``from mcp.types import X`` regression + (the module is now ``mcp_types``) as well as renamed or moved symbols. + +Execution is deliberately avoided: examples spin up servers, hit external +services, and pull heavy optional dependencies. Static resolution catches the +class of breakage we actually keep reintroducing (renamed imports) cheaply. + +Standalone example sub-projects that pin their own ``fastmcp``/``mcp`` in a +local ``pyproject.toml`` (e.g. ``examples/testing_demo`` targets v1 on purpose) +are excluded — their imports are validated against a different package than the +one installed here. + +Run: + uv run pytest tests/test_examples_importable.py -v -s +""" + +from __future__ import annotations + +import ast +import importlib +from pathlib import Path + +EXAMPLES_DIR = Path(__file__).parent.parent / "examples" + +# Snapshot baseline — ratchet DOWN as examples are fixed, never up. +MAX_IMPORT_FAILURES = 0 + +_CHECKED_ROOTS = ("fastmcp", "mcp", "mcp_types") + + +def _standalone_dirs() -> set[Path]: + """Directories that are self-contained example sub-projects. + + A ``pyproject.toml`` under ``examples/`` marks a project boundary: the + directory ships its own dependency pins (``examples/testing_demo`` targets + fastmcp v1 on purpose, ``examples/smart_home`` pins fastmcp from git), so + its imports must not be validated against the package installed for the + main test suite. Everything below such a directory is excluded. + """ + return {pyproject.parent for pyproject in EXAMPLES_DIR.rglob("pyproject.toml")} + + +def _find_example_files() -> list[Path]: + standalone = _standalone_dirs() + + def is_standalone(path: Path) -> bool: + return any(root in path.parents for root in standalone) + + return sorted(p for p in EXAMPLES_DIR.rglob("*.py") if not is_standalone(p)) + + +def _check_imports(path: Path) -> list[str]: + """Return descriptions of unresolved fastmcp/mcp_types imports.""" + try: + tree = ast.parse(path.read_text("utf-8")) + except SyntaxError as e: + rel = path.relative_to(EXAMPLES_DIR.parent) + return [f"{rel}:{e.lineno}: syntax error: {e.msg}"] + + errors: list[str] = [] + rel = path.relative_to(EXAMPLES_DIR.parent) + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name.split(".")[0] in _CHECKED_ROOTS: + if _import_module(alias.name) is None: + errors.append( + f"{rel}:{node.lineno}: cannot import '{alias.name}'" + ) + elif isinstance(node, ast.ImportFrom): + if node.level: # relative import — not a fastmcp/mcp_types path + continue + if node.module and node.module.split(".")[0] in _CHECKED_ROOTS: + mod = _import_module(node.module) + if mod is None: + errors.append( + f"{rel}:{node.lineno}: cannot import module '{node.module}'" + ) + continue + for alias in node.names: + name = alias.name + if name == "*": + continue + # ``from pkg import sub`` where ``sub`` is itself a + # submodule resolves even though ``pkg`` has no such + # attribute until the submodule is imported. + if hasattr(mod, name) or _import_module(f"{node.module}.{name}"): + continue + errors.append( + f"{rel}:{node.lineno}: '{node.module}' has no '{name}'" + ) + return errors + + +def _import_module(name: str): + """Import ``name``, returning the module or None if it cannot be imported.""" + try: + return importlib.import_module(name) + except ImportError: + return None + + +def test_examples_imports_resolve(): + """Example scripts must not regress in fastmcp/mcp_types import resolution. + + Parses every ``.py`` under ``examples/`` (excluding standalone sub-projects + that pin their own fastmcp/mcp) and verifies its top-level fastmcp and + mcp_types imports resolve against the installed package. Nothing is + executed. + """ + files = _find_example_files() + assert files, "no example files discovered — check EXAMPLES_DIR" + + import_failures: list[str] = [] + for path in files: + import_failures.extend(_check_imports(path)) + + print(f"\nExample files checked: {len(files)}") + print(f"Import failures: {len(import_failures)}") + if import_failures: + print("\nUnresolved imports:") + for failure in import_failures: + print(f" {failure}") + + assert len(import_failures) <= MAX_IMPORT_FAILURES, ( + f"Import failures regressed: {len(import_failures)} > {MAX_IMPORT_FAILURES}" + ) From d060e93eec11e8615c4a83a9f192ae9369aa63f3 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:06:22 -0400 Subject: [PATCH 3/3] Narrow examples-gate exclusion to uv.lock, fix qr_server stale import A bare pyproject.toml in an examples/ subdir isn't evidence of an incompatible dependency graph -- most just declare extras (qrcode, phue2, atproto). Only a directory with its own uv.lock is genuinely independently resolved. Narrowing the exclusion brought 20 more files into scope and caught exactly the bug class the gate exists for: qr_server.py's `from mcp import types` / `mimeType=` are stale v1 idioms that would raise on the current SDK. --- examples/apps/qr_server/qr_server.py | 4 ++-- tests/test_examples_importable.py | 27 +++++++++++++++++---------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/examples/apps/qr_server/qr_server.py b/examples/apps/qr_server/qr_server.py index 04639fdb9..28ea8d4d1 100644 --- a/examples/apps/qr_server/qr_server.py +++ b/examples/apps/qr_server/qr_server.py @@ -23,7 +23,7 @@ import base64 import io import qrcode # type: ignore[import-untyped] -from mcp import types +from mcp_types import ImageContent from fastmcp import FastMCP from fastmcp.apps import AppConfig, ResourceCSP @@ -153,7 +153,7 @@ def generate_qr( img.save(buffer, format="PNG") b64 = base64.b64encode(buffer.getvalue()).decode() return ToolResult( - content=[types.ImageContent(type="image", data=b64, mimeType="image/png")] + content=[ImageContent(type="image", data=b64, mime_type="image/png")] ) diff --git a/tests/test_examples_importable.py b/tests/test_examples_importable.py index 02148ffec..14f3e915f 100644 --- a/tests/test_examples_importable.py +++ b/tests/test_examples_importable.py @@ -15,10 +15,13 @@ Execution is deliberately avoided: examples spin up servers, hit external services, and pull heavy optional dependencies. Static resolution catches the class of breakage we actually keep reintroducing (renamed imports) cheaply. -Standalone example sub-projects that pin their own ``fastmcp``/``mcp`` in a -local ``pyproject.toml`` (e.g. ``examples/testing_demo`` targets v1 on purpose) -are excluded — their imports are validated against a different package than the -one installed here. +Standalone example sub-projects with their own ``uv.lock`` (e.g. +``examples/testing_demo`` targets v1 on purpose) are excluded — their +dependency graph is independently resolved, so their imports are validated +against a different package than the one installed here. A bare +``pyproject.toml`` without its own lock (e.g. ``examples/apps/qr_server``) +still resolves against this tree's install and is checked like any other +example — it is not, on its own, evidence of an incompatible dependency. Run: uv run pytest tests/test_examples_importable.py -v -s @@ -41,13 +44,17 @@ _CHECKED_ROOTS = ("fastmcp", "mcp", "mcp_types") def _standalone_dirs() -> set[Path]: """Directories that are self-contained example sub-projects. - A ``pyproject.toml`` under ``examples/`` marks a project boundary: the - directory ships its own dependency pins (``examples/testing_demo`` targets - fastmcp v1 on purpose, ``examples/smart_home`` pins fastmcp from git), so - its imports must not be validated against the package installed for the - main test suite. Everything below such a directory is excluded. + A ``uv.lock`` under ``examples/`` marks a genuinely independent dependency + graph (``examples/testing_demo`` locks and targets fastmcp v1 on purpose), + so its imports must not be validated against the package installed for + the main test suite. A ``pyproject.toml`` alone is not sufficient — most + example sub-projects (``examples/apps/qr_server``, ``examples/smart_home``, + ``examples/atproto_mcp``) have one purely to declare extra dependencies + (qrcode, phue2, atproto) but share this tree's fastmcp/mcp install, so + their fastmcp/mcp imports are still checked. Everything below a directory + with its own lock is excluded. """ - return {pyproject.parent for pyproject in EXAMPLES_DIR.rglob("pyproject.toml")} + return {lockfile.parent for lockfile in EXAMPLES_DIR.rglob("uv.lock")} def _find_example_files() -> list[Path]: