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/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") diff --git a/tests/test_examples_importable.py b/tests/test_examples_importable.py new file mode 100644 index 000000000..14f3e915f --- /dev/null +++ b/tests/test_examples_importable.py @@ -0,0 +1,145 @@ +"""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 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 +""" + +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 ``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 {lockfile.parent for lockfile in EXAMPLES_DIR.rglob("uv.lock")} + + +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}" + )