Compare commits

...

3 commits

Author SHA1 Message Date
Jeremiah Lowin
d060e93eec
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.
2026-07-08 09:06:22 -04:00
Jeremiah Lowin
824603517d
Add import-resolution gate for examples 2026-07-07 16:46:41 -04:00
Jeremiah Lowin
3c484aa4a9
Fix stale SDK idioms in examples and scripts 2026-07-07 16:40:47 -04:00
6 changed files with 162 additions and 17 deletions

View file

@ -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")]
)

View file

@ -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():
async with Client(server) as client:
# YAML serialized tool
yaml_result = await server._call_tool_mcp("get_example_data", {})
yaml_result = await client.call_tool("get_example_data", {})
print("YAML Tool Result:")
print(yaml_result)
print(yaml_result.content[0].text)
print()
# Default JSON serialized tool
json_result = await server._call_tool_mcp("get_json_data", {})
json_result = await client.call_tool("get_json_data", {})
print("JSON Tool Result:")
print(json_result)
print(json_result.content[0].text)
if __name__ == "__main__":
asyncio.run(example_usage())
server.run()

View file

@ -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"

View file

@ -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

View file

@ -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")

View file

@ -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}"
)