mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
* pin pydantic-monty to 0.0.8 * rename tool/prompt/resource base modules to avoid decorator name shadow * add sys.modules shims for old submodule import paths * preserve original module paths in deprecation warnings * clarify when sys.modules shims can be removed
692 lines
19 KiB
Python
692 lines
19 KiB
Python
"""Tests for FastMCPApp — the composable application provider.
|
|
|
|
Covers:
|
|
- @app.tool() decorator (global keys, visibility, calling patterns)
|
|
- @app.ui() decorator (model visibility, CSP auto-wiring)
|
|
- Global key registry and call_tool routing
|
|
- Callable resolver (_resolve_tool_ref)
|
|
- Composition with namespaced servers
|
|
- Provider interface delegation
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from prefab_ui.app import ResolvedTool
|
|
|
|
from fastmcp import Client, FastMCP
|
|
from fastmcp.server.app import (
|
|
_APP_TOOL_REGISTRY,
|
|
_FN_TO_GLOBAL_KEY,
|
|
FastMCPApp,
|
|
_make_global_key,
|
|
_resolve_tool_ref,
|
|
)
|
|
from fastmcp.tools.base import Tool
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures / helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
GLOBAL_KEY_PATTERN = re.compile(r"^.+-[0-9a-f]{8}$")
|
|
|
|
|
|
def _clear_registries() -> None:
|
|
"""Clear process-level registries between tests."""
|
|
_APP_TOOL_REGISTRY.clear()
|
|
_FN_TO_GLOBAL_KEY.clear()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Global key generation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGlobalKeyGeneration:
|
|
def test_make_global_key_format(self):
|
|
key = _make_global_key("save_contact")
|
|
assert GLOBAL_KEY_PATTERN.match(key)
|
|
assert key.startswith("save_contact-")
|
|
|
|
def test_make_global_key_uniqueness(self):
|
|
keys = {_make_global_key("my_tool") for _ in range(100)}
|
|
assert len(keys) == 100
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# @app.tool() decorator
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAppTool:
|
|
def setup_method(self) -> None:
|
|
_clear_registries()
|
|
|
|
def test_tool_bare_decorator(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.tool
|
|
def save(name: str) -> str:
|
|
return name
|
|
|
|
# Function is returned unchanged
|
|
assert save("alice") == "alice"
|
|
|
|
def test_tool_empty_parens(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.tool()
|
|
def save(name: str) -> str:
|
|
return name
|
|
|
|
assert save("alice") == "alice"
|
|
|
|
def test_tool_custom_name(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.tool("custom_save")
|
|
def save(name: str) -> str:
|
|
return name
|
|
|
|
assert save("alice") == "alice"
|
|
|
|
def test_tool_name_kwarg(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.tool(name="my_tool")
|
|
def save(name: str) -> str:
|
|
return name
|
|
|
|
assert save("alice") == "alice"
|
|
|
|
def test_tool_name_conflict_raises(self):
|
|
app = FastMCPApp("test")
|
|
|
|
with pytest.raises(TypeError):
|
|
|
|
@app.tool("x", name="y")
|
|
def save() -> str:
|
|
return ""
|
|
|
|
async def test_tool_registers_in_provider(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.tool()
|
|
def save(name: str) -> str:
|
|
return name
|
|
|
|
tools = await app._list_tools()
|
|
assert len(tools) == 1
|
|
assert tools[0].name == "save"
|
|
|
|
async def test_tool_custom_name_in_provider(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.tool("custom_save")
|
|
def save(name: str) -> str:
|
|
return name
|
|
|
|
tools = await app._list_tools()
|
|
assert len(tools) == 1
|
|
assert tools[0].name == "custom_save"
|
|
|
|
def test_tool_gets_global_key(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.tool()
|
|
def save(name: str) -> str:
|
|
return name
|
|
|
|
# Check the function is registered in the global key registry
|
|
assert id(save) in _FN_TO_GLOBAL_KEY
|
|
global_key = _FN_TO_GLOBAL_KEY[id(save)]
|
|
assert GLOBAL_KEY_PATTERN.match(global_key)
|
|
assert global_key.startswith("save-")
|
|
assert global_key in _APP_TOOL_REGISTRY
|
|
|
|
async def test_tool_global_key_in_meta(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.tool()
|
|
def save(name: str) -> str:
|
|
return name
|
|
|
|
tools = await app._list_tools()
|
|
tool = tools[0]
|
|
assert tool.meta is not None
|
|
assert "ui" in tool.meta
|
|
assert "globalKey" in tool.meta["ui"]
|
|
assert GLOBAL_KEY_PATTERN.match(tool.meta["ui"]["globalKey"])
|
|
|
|
async def test_tool_default_visibility_app_only(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.tool()
|
|
def save(name: str) -> str:
|
|
return name
|
|
|
|
tools = await app._list_tools()
|
|
meta = tools[0].meta
|
|
assert meta is not None
|
|
assert meta["ui"]["visibility"] == ["app"]
|
|
|
|
async def test_tool_model_visibility(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.tool(model=True)
|
|
def query(search: str) -> list:
|
|
return []
|
|
|
|
tools = await app._list_tools()
|
|
meta = tools[0].meta
|
|
assert meta is not None
|
|
assert meta["ui"]["visibility"] == ["app", "model"]
|
|
|
|
def test_tool_with_description(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.tool(description="Save a contact")
|
|
def save(name: str) -> str:
|
|
return name
|
|
|
|
def test_tool_with_auth(self):
|
|
app = FastMCPApp("test")
|
|
check = AsyncMock(return_value=True)
|
|
|
|
@app.tool(auth=check)
|
|
def save(name: str) -> str:
|
|
return name
|
|
|
|
def test_tool_with_timeout(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.tool(timeout=30.0)
|
|
def slow_save(name: str) -> str:
|
|
return name
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# @app.ui() decorator
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAppUI:
|
|
def setup_method(self) -> None:
|
|
_clear_registries()
|
|
|
|
def test_ui_bare_decorator(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.ui
|
|
def dashboard() -> str:
|
|
return "hi"
|
|
|
|
assert dashboard() == "hi"
|
|
|
|
def test_ui_empty_parens(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.ui()
|
|
def dashboard() -> str:
|
|
return "hi"
|
|
|
|
assert dashboard() == "hi"
|
|
|
|
def test_ui_custom_name(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.ui("my_dashboard")
|
|
def dashboard() -> str:
|
|
return "hi"
|
|
|
|
assert dashboard() == "hi"
|
|
|
|
async def test_ui_registers_in_provider(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.ui()
|
|
def dashboard() -> str:
|
|
return "dashboard"
|
|
|
|
tools = await app._list_tools()
|
|
assert len(tools) == 1
|
|
assert tools[0].name == "dashboard"
|
|
|
|
async def test_ui_visibility_model_only(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.ui()
|
|
def dashboard() -> str:
|
|
return "dashboard"
|
|
|
|
tools = await app._list_tools()
|
|
meta = tools[0].meta
|
|
assert meta is not None
|
|
assert meta["ui"]["visibility"] == ["model"]
|
|
|
|
def test_ui_no_global_key(self):
|
|
"""UI entry points should NOT get global keys."""
|
|
app = FastMCPApp("test")
|
|
|
|
@app.ui()
|
|
def dashboard() -> str:
|
|
return "dashboard"
|
|
|
|
assert id(dashboard) not in _FN_TO_GLOBAL_KEY
|
|
|
|
async def test_ui_has_resource_uri(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.ui()
|
|
def dashboard() -> str:
|
|
return "dashboard"
|
|
|
|
tools = await app._list_tools()
|
|
meta = tools[0].meta
|
|
assert meta is not None
|
|
assert meta["ui"]["resourceUri"] == "ui://prefab/renderer.html"
|
|
|
|
async def test_ui_has_csp(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.ui()
|
|
def dashboard() -> str:
|
|
return "dashboard"
|
|
|
|
tools = await app._list_tools()
|
|
meta = tools[0].meta
|
|
assert meta is not None
|
|
csp = meta["ui"].get("csp")
|
|
assert csp is not None
|
|
|
|
async def test_ui_with_title_and_description(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.ui(title="My Dashboard", description="Shows data")
|
|
def dashboard() -> str:
|
|
return "dashboard"
|
|
|
|
tools = await app._list_tools()
|
|
assert tools[0].title == "My Dashboard"
|
|
assert tools[0].description == "Shows data"
|
|
|
|
async def test_ui_with_tags(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.ui(tags={"dashboard", "main"})
|
|
def dashboard() -> str:
|
|
return "dashboard"
|
|
|
|
tools = await app._list_tools()
|
|
assert tools[0].tags == {"dashboard", "main"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Callable resolver
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolveToolRef:
|
|
def setup_method(self) -> None:
|
|
_clear_registries()
|
|
|
|
def test_resolve_global_key(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.tool()
|
|
def save(name: str) -> str:
|
|
return name
|
|
|
|
result = _resolve_tool_ref(save)
|
|
# str return → wrapped tool → ResolvedTool with unwrap_result
|
|
assert isinstance(result, ResolvedTool)
|
|
assert GLOBAL_KEY_PATTERN.match(result.name)
|
|
assert result.name.startswith("save-")
|
|
assert result.unwrap_result is True
|
|
|
|
def test_resolve_global_key_object_return(self):
|
|
"""Tools returning dicts don't need unwrapping."""
|
|
|
|
app = FastMCPApp("test")
|
|
|
|
@app.tool()
|
|
def save(name: str) -> dict:
|
|
return {"name": name}
|
|
|
|
result = _resolve_tool_ref(save)
|
|
assert isinstance(result, ResolvedTool)
|
|
assert GLOBAL_KEY_PATTERN.match(result.name)
|
|
assert result.name.startswith("save-")
|
|
assert result.unwrap_result is False
|
|
|
|
def test_resolve_fastmcp_metadata(self):
|
|
"""Functions with __fastmcp__ metadata but no global key."""
|
|
|
|
from fastmcp.tools.function_tool import ToolMeta
|
|
|
|
def my_tool():
|
|
pass
|
|
|
|
my_tool.__fastmcp__ = ToolMeta(name="custom_name") # type: ignore[attr-defined]
|
|
|
|
result = _resolve_tool_ref(my_tool)
|
|
assert isinstance(result, ResolvedTool)
|
|
assert result.name == "custom_name"
|
|
|
|
def test_resolve_bare_function(self):
|
|
def my_tool():
|
|
pass
|
|
|
|
result = _resolve_tool_ref(my_tool)
|
|
assert isinstance(result, ResolvedTool)
|
|
assert result.name == "my_tool"
|
|
|
|
def test_resolve_unresolvable_raises(self):
|
|
with pytest.raises(ValueError):
|
|
_resolve_tool_ref(42)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Provider interface
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestProviderInterface:
|
|
def setup_method(self) -> None:
|
|
_clear_registries()
|
|
|
|
async def test_list_tools_empty(self):
|
|
app = FastMCPApp("test")
|
|
assert await app._list_tools() == []
|
|
|
|
async def test_list_resources_empty(self):
|
|
app = FastMCPApp("test")
|
|
assert list(await app._list_resources()) == []
|
|
|
|
async def test_get_tool_by_name(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.tool()
|
|
def save(name: str) -> str:
|
|
return name
|
|
|
|
tool = await app._get_tool("save")
|
|
assert tool is not None
|
|
assert tool.name == "save"
|
|
|
|
async def test_get_tool_missing_returns_none(self):
|
|
app = FastMCPApp("test")
|
|
assert await app._get_tool("missing") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# call_tool with global key routing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCallToolGlobalKeyRouting:
|
|
def setup_method(self) -> None:
|
|
_clear_registries()
|
|
|
|
async def test_call_tool_by_global_key(self):
|
|
"""Server.call_tool can find a FastMCPApp tool by its global key."""
|
|
app = FastMCPApp("test")
|
|
|
|
@app.tool()
|
|
def save(name: str) -> str:
|
|
return f"saved {name}"
|
|
|
|
server = FastMCP("Platform")
|
|
server.add_provider(app)
|
|
|
|
global_key = _FN_TO_GLOBAL_KEY[id(save)]
|
|
|
|
result = await server.call_tool(global_key, {"name": "alice"})
|
|
assert result.content[0].text == "saved alice" # type: ignore[union-attr]
|
|
|
|
async def test_call_tool_by_name(self):
|
|
"""Regular name-based resolution still works."""
|
|
app = FastMCPApp("test")
|
|
|
|
@app.tool()
|
|
def save(name: str) -> str:
|
|
return f"saved {name}"
|
|
|
|
server = FastMCP("Platform")
|
|
server.add_provider(app)
|
|
|
|
result = await server.call_tool("save", {"name": "bob"})
|
|
assert result.content[0].text == "saved bob" # type: ignore[union-attr]
|
|
|
|
async def test_global_key_survives_namespace(self):
|
|
"""Global key works even when the app is mounted under a namespace."""
|
|
app = FastMCPApp("crm")
|
|
|
|
@app.tool()
|
|
def save_contact(name: str) -> str:
|
|
return f"saved {name}"
|
|
|
|
server = FastMCP("Platform")
|
|
server.add_provider(app, namespace="crm")
|
|
|
|
global_key = _FN_TO_GLOBAL_KEY[id(save_contact)]
|
|
|
|
# Global key should still work
|
|
result = await server.call_tool(global_key, {"name": "alice"})
|
|
assert result.content[0].text == "saved alice" # type: ignore[union-attr]
|
|
|
|
async def test_namespaced_name_also_works(self):
|
|
"""Namespaced tool name works through normal resolution."""
|
|
app = FastMCPApp("crm")
|
|
|
|
@app.tool(model=True)
|
|
def save_contact(name: str) -> str:
|
|
return f"saved {name}"
|
|
|
|
server = FastMCP("Platform")
|
|
server.add_provider(app, namespace="crm")
|
|
|
|
result = await server.call_tool("crm_save_contact", {"name": "bob"})
|
|
assert result.content[0].text == "saved bob" # type: ignore[union-attr]
|
|
|
|
async def test_global_key_auth_blocks_unauthorized(self):
|
|
"""Auth checks run even when resolving via global key."""
|
|
from fastmcp.exceptions import NotFoundError
|
|
from fastmcp.server.context import _current_transport
|
|
|
|
app = FastMCPApp("test")
|
|
deny_all = AsyncMock(return_value=False)
|
|
|
|
@app.tool(auth=deny_all)
|
|
def secret() -> str:
|
|
return "classified"
|
|
|
|
server = FastMCP("Platform")
|
|
server.add_provider(app)
|
|
|
|
global_key = _FN_TO_GLOBAL_KEY[id(secret)]
|
|
|
|
# Simulate non-stdio transport so auth is not skipped
|
|
token = _current_transport.set("streamable-http")
|
|
try:
|
|
with pytest.raises(NotFoundError):
|
|
await server.call_tool(global_key, {})
|
|
finally:
|
|
_current_transport.reset(token)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# End-to-end via Client
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEndToEnd:
|
|
def setup_method(self) -> None:
|
|
_clear_registries()
|
|
|
|
async def test_ui_tool_visible_to_client(self):
|
|
"""UI entry-point tools show up in list_tools via Client."""
|
|
app = FastMCPApp("test")
|
|
|
|
@app.ui()
|
|
def dashboard() -> str:
|
|
return "dashboard"
|
|
|
|
server = FastMCP("Platform")
|
|
server.add_provider(app)
|
|
|
|
async with Client(server) as client:
|
|
tools = await client.list_tools()
|
|
names = [t.name for t in tools]
|
|
assert "dashboard" in names
|
|
|
|
async def test_app_tool_model_true_visible(self):
|
|
"""App tools with model=True are visible via Client."""
|
|
app = FastMCPApp("test")
|
|
|
|
@app.tool(model=True)
|
|
def query(search: str) -> list:
|
|
return [search]
|
|
|
|
server = FastMCP("Platform")
|
|
server.add_provider(app)
|
|
|
|
async with Client(server) as client:
|
|
tools = await client.list_tools()
|
|
names = [t.name for t in tools]
|
|
assert "query" in names
|
|
|
|
async def test_call_tool_via_global_key_through_client(self):
|
|
"""Client can call a tool using its global key."""
|
|
app = FastMCPApp("test")
|
|
|
|
@app.tool()
|
|
def save(name: str) -> str:
|
|
return f"saved {name}"
|
|
|
|
server = FastMCP("Platform")
|
|
server.add_provider(app)
|
|
|
|
global_key = _FN_TO_GLOBAL_KEY[id(save)]
|
|
|
|
async with Client(server) as client:
|
|
result = await client.call_tool(global_key, {"name": "test"})
|
|
assert "saved test" in result.content[0].text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# .run() convenience
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRun:
|
|
def test_repr(self):
|
|
app = FastMCPApp("Dashboard")
|
|
assert repr(app) == "FastMCPApp('Dashboard')"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# add_tool programmatic
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAddTool:
|
|
def setup_method(self) -> None:
|
|
_clear_registries()
|
|
|
|
async def test_add_tool_from_function(self):
|
|
app = FastMCPApp("test")
|
|
|
|
def save(name: str) -> str:
|
|
return name
|
|
|
|
tool = app.add_tool(save)
|
|
assert tool.name == "save"
|
|
|
|
tools = await app._list_tools()
|
|
assert len(tools) == 1
|
|
|
|
async def test_add_tool_gets_global_key(self):
|
|
app = FastMCPApp("test")
|
|
|
|
def save(name: str) -> str:
|
|
return name
|
|
|
|
tool = app.add_tool(save)
|
|
assert tool.meta is not None
|
|
assert "globalKey" in tool.meta.get("ui", {})
|
|
|
|
async def test_add_tool_object(self):
|
|
app = FastMCPApp("test")
|
|
tool = Tool.from_function(lambda x: x, name="my_tool")
|
|
added = app.add_tool(tool)
|
|
assert added.name == "my_tool"
|
|
|
|
tools = await app._list_tools()
|
|
assert len(tools) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Composition
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestComposition:
|
|
def setup_method(self) -> None:
|
|
_clear_registries()
|
|
|
|
async def test_multiple_apps_on_one_server(self):
|
|
crm = FastMCPApp("CRM")
|
|
billing = FastMCPApp("Billing")
|
|
|
|
@crm.tool()
|
|
def save_contact(name: str) -> str:
|
|
return name
|
|
|
|
@billing.tool()
|
|
def create_invoice(amount: int) -> int:
|
|
return amount
|
|
|
|
server = FastMCP("Platform")
|
|
server.add_provider(crm, namespace="crm")
|
|
server.add_provider(billing, namespace="billing")
|
|
|
|
# Both tools reachable by global key
|
|
crm_key = _FN_TO_GLOBAL_KEY[id(save_contact)]
|
|
billing_key = _FN_TO_GLOBAL_KEY[id(create_invoice)]
|
|
|
|
r1 = await server.call_tool(crm_key, {"name": "alice"})
|
|
r2 = await server.call_tool(billing_key, {"amount": 100})
|
|
|
|
assert r1.content[0].text == "alice" # type: ignore[union-attr]
|
|
assert r2.content[0].text == "100" # type: ignore[union-attr]
|
|
|
|
async def test_ui_and_tool_on_same_app(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.ui()
|
|
def dashboard() -> str:
|
|
return "ui"
|
|
|
|
@app.tool()
|
|
def save(name: str) -> str:
|
|
return name
|
|
|
|
tools = await app._list_tools()
|
|
assert len(tools) == 2
|
|
names = {t.name for t in tools}
|
|
assert names == {"dashboard", "save"}
|
|
|
|
async def test_ui_registers_prefab_renderer_resource(self):
|
|
app = FastMCPApp("test")
|
|
|
|
@app.ui()
|
|
def dashboard() -> str:
|
|
return "ui"
|
|
|
|
resources = await app._list_resources()
|
|
uris = [str(r.uri) for r in resources]
|
|
assert any("ui://prefab/renderer.html" in uri for uri in uris)
|