mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 05:24:18 +02:00
Compare commits
3 commits
main
...
maint/exam
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d060e93eec |
||
|
|
824603517d |
||
|
|
3c484aa4a9 |
6 changed files with 162 additions and 17 deletions
|
|
@ -23,7 +23,7 @@ import base64
|
||||||
import io
|
import io
|
||||||
|
|
||||||
import qrcode # type: ignore[import-untyped]
|
import qrcode # type: ignore[import-untyped]
|
||||||
from mcp import types
|
from mcp_types import ImageContent
|
||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
from fastmcp.apps import AppConfig, ResourceCSP
|
from fastmcp.apps import AppConfig, ResourceCSP
|
||||||
|
|
@ -153,7 +153,7 @@ def generate_qr(
|
||||||
img.save(buffer, format="PNG")
|
img.save(buffer, format="PNG")
|
||||||
b64 = base64.b64encode(buffer.getvalue()).decode()
|
b64 = base64.b64encode(buffer.getvalue()).decode()
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
content=[types.ImageContent(type="image", data=b64, mimeType="image/png")]
|
content=[ImageContent(type="image", data=b64, mime_type="image/png")]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ from typing import Any
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import Client, FastMCP
|
||||||
from fastmcp.tools.tool import ToolResult
|
from fastmcp.tools import ToolResult
|
||||||
|
|
||||||
|
|
||||||
def with_serializer(serializer: Callable[[Any], str]):
|
def with_serializer(serializer: Callable[[Any], str]):
|
||||||
|
|
@ -55,18 +55,18 @@ def get_json_data() -> dict:
|
||||||
|
|
||||||
|
|
||||||
async def example_usage():
|
async def example_usage():
|
||||||
# YAML serialized tool
|
async with Client(server) as client:
|
||||||
yaml_result = await server._call_tool_mcp("get_example_data", {})
|
# YAML serialized tool
|
||||||
print("YAML Tool Result:")
|
yaml_result = await client.call_tool("get_example_data", {})
|
||||||
print(yaml_result)
|
print("YAML Tool Result:")
|
||||||
print()
|
print(yaml_result.content[0].text)
|
||||||
|
print()
|
||||||
|
|
||||||
# Default JSON serialized tool
|
# 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 Tool Result:")
|
||||||
print(json_result)
|
print(json_result.content[0].text)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(example_usage())
|
asyncio.run(example_usage())
|
||||||
server.run()
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ from rich import print
|
||||||
|
|
||||||
from fastmcp import Client, FastMCP
|
from fastmcp import Client, FastMCP
|
||||||
from fastmcp.server.providers import Provider
|
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"
|
DB_PATH = Path(__file__).parent / "tools.db"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ async def main():
|
||||||
print("=== Resource Templates ===")
|
print("=== Resource Templates ===")
|
||||||
templates = await client.list_resource_templates()
|
templates = await client.list_resource_templates()
|
||||||
for t in templates:
|
for t in templates:
|
||||||
print(f" {t.uriTemplate}")
|
print(f" {t.uri_template}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Read a skill's main file
|
# Read a skill's main file
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
from fastmcp.tools.tool import ToolResult
|
from fastmcp.tools import ToolResult
|
||||||
|
|
||||||
mcp = FastMCP("Echo Server")
|
mcp = FastMCP("Echo Server")
|
||||||
|
|
||||||
|
|
|
||||||
145
tests/test_examples_importable.py
Normal file
145
tests/test_examples_importable.py
Normal 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}"
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue