fastmcp/tests/test_fastmcp_app.py
Jeremiah Lowin 3a9717e6be
Publish docs for v3.2.0 (#3713)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com>
Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com>
Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com>
Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Co-authored-by: nightcityblade <nightcityblade@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
Co-authored-by: Sumanshu Nankana <sumanshunankana@gmail.com>
Co-authored-by: Eric Robinson <ericrobinson@indeed.com>
Co-authored-by: Martim Santos <martimfasantos@gmail.com>
Co-authored-by: d 🔹 <liusway405@gmail.com>
Co-authored-by: Matthieu B <66959271+mtthidoteu@users.noreply.github.com>
Co-authored-by: Sascha Buehrle <47737812+saschabuehrle@users.noreply.github.com>
Co-authored-by: Hakancan <142545736+hkc5@users.noreply.github.com>
Co-authored-by: nightcityblade <jackchen@haloailabs.com>
Co-authored-by: Matt Hallowell <17804673+mhallo@users.noreply.github.com>
Co-authored-by: nate nowack <thrast36@gmail.com>
Co-authored-by: Bill Easton <williamseaston@gmail.com>
Co-authored-by: Marcus Shu <46469249+shulkx@users.noreply.github.com>
Co-authored-by: Rushabh Doshi <radoshi@gmail.com>
Co-authored-by: AIKAWA Shigechika <shige@aikawa.jp>
Co-authored-by: Jeremy Simon <simonjer805@gmail.com>
Co-authored-by: Miguel Miranda Dias <7780875+pandego@users.noreply.github.com>
Co-authored-by: Anthony James Padavano <padavano.anthony@gmail.com>
Co-authored-by: Mostafa Kamal <hiremostafa@gmail.com>
Fix auto-close MRE script posting comment without closing (#3386)
Fix WorkOS token scope verification bypass 🤖 Generated with Codex (#3407)
Fix initialize McpError fallthrough 🤖 Generated with Codex (#3413)
Fix transform arg collisions with passthrough params (#3431)
Fix get_* returning None when latest version is disabled (#3439)
Fix get_* returning None when latest version is disabled (#3421)
Fix server lifespan overlap teardown (#3415)
Fix $ref output schema object detection regression (#3420)
resolved annotations (#3429)
Fix async partial callables rejected by iscoroutinefunction (#3438)
Fix async partial callables rejected by iscoroutinefunction (#3423)
fix: add version to components (#3458)
fix: use intent-based flag for OIDC scope patch in load_access_token (#3465)
Fixes #3461
fix: normalize Google scope shorthands and surface valid_scopes (#3477)
fix: resolve ty 0.0.23 type-checking errors and bump pin (#3481)
fix: shield lifespan teardown from cancellation (#3480)
fix: forward custom_route endpoints from mounted servers (#3462)
fix updates _get_additional_http_routes() to traverse providers,
Fixes #3457
fix: remove hardcoded version from CLI help text (#3456)
fix: monty 0.0.8 compatibility, drop external_functions from constructor (#3468)
fix: task test teardown hanging 5s per test (#3499)
Closes #3498
fix: validate workspace path is a directory before cursor install (#3440)
Fixes #3426
fix: handle re.error from malformed URI templates in build_regex (#3501)
fix: reject empty/OIDC-only required_scopes in AzureProvider (#3503)
fix: restrict $ref resolution to local refs only (SSRF/LFI) (#3502)
fix warnings and timeouts (#3504)
close upgrade check issue when build passes (#3505)
Closes #3484
fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) (#3507)
fix: prevent path traversal in skill download (#3493)
fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy (#3492)
fix: remove unrelated transform and http.py changes from PR scope
fix: remove forced follow_redirects from httpx_client_factory calls (#3496)
fix: stop passing follow_redirects to httpx_client_factory
fix: restore follow_redirects=True for custom httpx client factories
Closes #3509
fix: CSRF double-submit cookie check in consent flow (#3519)
fix: validate server names in install commands (#3522)
fix: use raw strings for regex in pytest.raises match (#3523)
fix: reject refresh tokens used as Bearer access tokens (#3524)
fix: route ResourcesAsTools/PromptsAsTools through server middleware (#3495)
fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators (#3540)
fix: filter warnings by message in KEY_PREFIX test (#3549)
fix: suppress output schema for ToolResult subclass annotations (#3548)
fix: increase sleep duration in proxy cache tests (#3567)
fix: store absolute token expiry to prevent stale expires_in on reload (#3572)
fix: preserve tool properties named 'title' during schema compression (#3582)
Fix loopback redirect URI port matching per RFC 8252 §7.3 (#3589)
Fix app tool routing: visibility check and middleware propagation (#3591)
Fix query parameter serialization to respect OpenAPI explode/style settings (#3595)
Fix dev apps form: union types, textarea support, JSON parsing (#3597)
fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo (#3603)
fix: resolve EntraOBOToken dependency injection through MultiAuth (#3609)
fix(docs): correct misleading stateless_http header (#3622)
fix: filesystem provider import machinery (#3626)
Closes #3625 (issues 2, 3, 6)
fix: recover StdioTransport after subprocess exits (#3630)
fix(server): preserve mounted tool task metadata (#3632)
fix: scope deprecation warning filter to FastMCPDeprecationWarning (#3649)
fix imports, add PrefabAppConfig (#3650)
fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks (#3651)
Fix blocking docs issues: chart imports, Select API, Rx consistency (#3652)
closed by default (#3657)
Fix prompt caching middleware missing wrap/unwrap round-trip (#3666)
fix: serialize object query params per OpenAPI style/explode rules (#3662)
Fixes #2857
fix: HTTP request headers not accessible in background task workers (#3631)
fix: restore HTTP headers in worker execution path for background tasks (#3681)
fix: strip discriminator after dereferencing schemas (#3682)
fix: remove stale ty:ignore directives for ty 0.0.26 (#3684)
Fix docs gaps in app provider pages (#3690)
fix: dev apps log panel UX improvements (#3698)
fix dev server empty string args (#3700)
2026-03-30 16:48:30 -04:00

912 lines
28 KiB
Python

"""Tests for FastMCPApp — the composable application provider.
Covers:
- @app.tool() decorator (visibility, calling patterns)
- @app.ui() decorator (model visibility, CSP auto-wiring)
- get_app_tool routing through provider chain
- Callable resolver (_resolve_tool_ref)
- Composition with namespaced servers
- Provider interface delegation
"""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from prefab_ui.app import ResolvedTool
from prefab_ui.components import Text
from fastmcp import Client, FastMCP
from fastmcp.apps.app import (
FastMCPApp,
_make_resolver,
)
from fastmcp.tools.base import Tool
# ---------------------------------------------------------------------------
# @app.tool() decorator
# ---------------------------------------------------------------------------
class TestFastMCPAppInit:
def test_app_name_with_triple_underscore_rejected(self):
with pytest.raises(ValueError, match="must not contain '___'"):
FastMCPApp("my___app")
def test_app_name_with_single_or_double_underscore_ok(self):
FastMCPApp("my_app")
FastMCPApp("my__app")
class TestAppTool:
def test_tool_bare_decorator(self):
app = FastMCPApp("test")
@app.tool
def save(name: str) -> str:
return name
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"
async def test_tool_has_app_name_in_meta(self):
app = FastMCPApp("contacts")
@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["fastmcp"]["app"] == "contacts"
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 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"]
async def test_ui_has_app_name_in_meta(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["fastmcp"]["app"] == "test"
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 test_resolve_string_no_app_name(self):
"""Without an app name, strings pass through unprefixed."""
result = _make_resolver()("save_contact")
assert isinstance(result, ResolvedTool)
assert result.name == "save_contact"
def test_resolve_string_with_app_name(self):
"""With an app name, strings get the ___-prefix."""
result = _make_resolver("Files")("store_files")
assert isinstance(result, ResolvedTool)
assert result.name == "Files___store_files"
def test_resolve_string_already_prefixed(self):
"""Strings that already contain ___ are not double-prefixed."""
result = _make_resolver("Files")("Other___store_files")
assert isinstance(result, ResolvedTool)
assert result.name == "Other___store_files"
def test_resolve_callable_no_app_name(self):
def my_tool():
pass
result = _make_resolver()(my_tool)
assert isinstance(result, ResolvedTool)
assert result.name == "my_tool"
def test_resolve_callable_with_app_name(self):
"""Callables also get the ___-prefix when an app name is set."""
def store_files():
pass
result = _make_resolver("Files")(store_files)
assert isinstance(result, ResolvedTool)
assert result.name == "Files___store_files"
def test_resolve_fastmcp_metadata(self):
from fastmcp.tools.function_tool import ToolMeta
def my_tool():
pass
my_tool.__fastmcp__ = ToolMeta(name="custom_name") # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
result = _make_resolver()(my_tool)
assert isinstance(result, ResolvedTool)
assert result.name == "custom_name"
def test_resolve_fastmcp_metadata_with_app_name(self):
from fastmcp.tools.function_tool import ToolMeta
def my_tool():
pass
my_tool.__fastmcp__ = ToolMeta(name="custom_name") # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
result = _make_resolver("MyApp")(my_tool)
assert isinstance(result, ResolvedTool)
assert result.name == "MyApp___custom_name"
def test_resolve_unresolvable_raises(self):
with pytest.raises(ValueError):
_make_resolver()(42)
# ---------------------------------------------------------------------------
# get_app_tool — provider chain routing
# ---------------------------------------------------------------------------
class TestGetAppTool:
async def test_direct_lookup(self):
"""FastMCPApp.get_app_tool finds tools by app name + tool name."""
app = FastMCPApp("contacts")
@app.tool()
def save(name: str) -> str:
return name
tool = await app.get_app_tool("contacts", "save")
assert tool is not None
assert tool.name == "save"
async def test_wrong_app_returns_none(self):
app = FastMCPApp("contacts")
@app.tool()
def save(name: str) -> str:
return name
assert await app.get_app_tool("billing", "save") is None
async def test_wrong_tool_returns_none(self):
app = FastMCPApp("contacts")
@app.tool()
def save(name: str) -> str:
return name
assert await app.get_app_tool("contacts", "missing") is None
async def test_two_apps_no_collision(self):
"""Two apps with the same tool name are disambiguated."""
app1 = FastMCPApp("contacts")
app2 = FastMCPApp("billing")
@app1.tool()
def save(name: str) -> str:
return f"contact: {name}"
@app2.tool("save")
def save_billing(amount: int) -> str:
return f"invoice: {amount}"
server = FastMCP("Platform")
server.add_provider(app1)
server.add_provider(app2)
t1 = await server.get_app_tool("contacts", "save")
t2 = await server.get_app_tool("billing", "save")
assert t1 is not None
assert t2 is not None
assert t1 is not t2
async def test_survives_namespace_transform(self):
"""get_app_tool bypasses namespace transforms."""
app = FastMCPApp("crm")
@app.tool()
def save_contact(name: str) -> str:
return name
server = FastMCP("Platform")
server.add_provider(app, namespace="crm")
# Normal get_tool with untransformed name fails
tool = await server.get_tool("save_contact")
assert tool is None
# get_app_tool bypasses transforms
tool = await server.get_app_tool("crm", "save_contact")
assert tool is not None
assert tool.name == "save_contact"
async def test_ui_tool_not_findable_via_get_app_tool(self):
"""@app.ui() tools have model visibility and should NOT be
returned by get_app_tool (only app-visible tools are)."""
app = FastMCPApp("dashboard")
@app.ui()
def show() -> str:
return "ui"
tool = await app.get_app_tool("dashboard", "show")
assert tool is None
async def test_model_visible_tool_findable(self):
"""@app.tool(model=True) has app visibility and IS findable."""
app = FastMCPApp("test")
@app.tool(model=True)
def query(q: str) -> str:
return q
tool = await app.get_app_tool("test", "query")
assert tool is not None
# ---------------------------------------------------------------------------
# Provider interface
# ---------------------------------------------------------------------------
class TestProviderInterface:
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 app_name routing
# ---------------------------------------------------------------------------
class TestCallToolAppRouting:
async def test_call_tool_with_app_name(self):
"""Server.call_tool routes via get_app_tool when app_name is set."""
app = FastMCPApp("contacts")
@app.tool()
def save(name: str) -> str:
return f"saved {name}"
server = FastMCP("Platform")
server.add_provider(app)
result = await server.call_tool("contacts___save", {"name": "alice"})
assert result.content[0].text == "saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
async def test_call_tool_without_app_name_model_visible(self):
"""Regular name-based resolution works for model-visible tools."""
app = FastMCPApp("test")
@app.tool(model=True)
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] # ty:ignore[unresolved-attribute]
async def test_app_name_survives_namespace(self):
"""app_name routing bypasses namespace transforms."""
app = FastMCPApp("crm")
@app.tool()
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": "alice"})
assert result.content[0].text == "saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
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] # ty:ignore[unresolved-attribute]
async def test_app_name_auth_blocks_unauthorized(self):
"""Auth checks run even when routing via app_name."""
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)
token = _current_transport.set("streamable-http")
try:
with pytest.raises(NotFoundError):
await server.call_tool("test___secret", {})
finally:
_current_transport.reset(token)
async def test_two_apps_same_tool_name_routed_correctly(self):
"""Two apps with same tool name disambiguated by app_name."""
contacts = FastMCPApp("contacts")
billing = FastMCPApp("billing")
@contacts.tool()
def save(name: str) -> str:
return f"contact: {name}"
@billing.tool("save")
def save_billing(amount: str) -> str:
return f"invoice: {amount}"
server = FastMCP("Platform")
server.add_provider(contacts)
server.add_provider(billing)
r1 = await server.call_tool("contacts___save", {"name": "alice"})
r2 = await server.call_tool("billing___save", {"amount": "100"})
assert r1.content[0].text == "contact: alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
assert r2.content[0].text == "invoice: 100" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
async def test_deeply_nested_app(self):
"""App tool is found even through multiple levels of nesting."""
app = FastMCPApp("deep")
@app.tool()
def hidden(x: str) -> str:
return x
inner = FastMCP("Inner")
inner.add_provider(app, namespace="app")
outer = FastMCP("Outer")
outer.mount(inner, namespace="inner")
# Normal resolution: would need "inner_app_hidden"
# App routing: bypasses all transforms
result = await outer.call_tool("deep___hidden", {"x": "found"})
assert result.content[0].text == "found" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
# ---------------------------------------------------------------------------
# App-only tool filtering from server list_tools / get_tool
# ---------------------------------------------------------------------------
class TestAppOnlyToolFiltering:
async def test_app_only_tool_hidden_from_list_tools(self):
"""@app.tool() (visibility=["app"]) should not appear in server.list_tools()."""
app = FastMCPApp("crm")
@app.tool()
def save_contact(name: str) -> str:
return name
server = FastMCP("Platform")
server.add_provider(app)
tools = await server.list_tools()
names = [t.name for t in tools]
assert "save_contact" not in names
async def test_model_visible_tool_in_list_tools(self):
"""@app.tool(model=True) (visibility=["app","model"]) appears in list_tools."""
app = FastMCPApp("crm")
@app.tool(model=True)
def query(search: str) -> list[str]:
return [search]
server = FastMCP("Platform")
server.add_provider(app)
tools = await server.list_tools()
names = [t.name for t in tools]
assert "query" in names
async def test_ui_tool_in_list_tools(self):
"""@app.ui() (visibility=["model"]) appears in list_tools."""
app = FastMCPApp("dashboard")
@app.ui()
def show_dashboard() -> str:
return "dashboard"
server = FastMCP("Platform")
server.add_provider(app)
tools = await server.list_tools()
names = [t.name for t in tools]
assert "show_dashboard" in names
async def test_app_only_tool_still_callable_via_app_name(self):
"""Even though filtered from list_tools, app-only tools are callable via call_tool with app_name."""
app = FastMCPApp("contacts")
@app.tool()
def save(name: str) -> str:
return f"saved {name}"
server = FastMCP("Platform")
server.add_provider(app)
# Verify it's hidden from list_tools
tools = await server.list_tools()
names = [t.name for t in tools]
assert "save" not in names
# But still callable via app_name routing
result = await server.call_tool("contacts___save", {"name": "alice"})
assert result.content[0].text == "saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
async def test_app_only_tool_hidden_from_get_tool(self):
"""server.get_tool() returns None for app-only tools."""
app = FastMCPApp("crm")
@app.tool()
def save_contact(name: str) -> str:
return name
server = FastMCP("Platform")
server.add_provider(app)
tool = await server.get_tool("save_contact")
assert tool is None
async def test_app_only_tool_hidden_with_namespace(self):
"""App-only tools hidden even when accessed through a namespace."""
app = FastMCPApp("crm")
@app.tool()
def save(name: str) -> str:
return name
server = FastMCP("Platform")
server.add_provider(app, namespace="crm")
tools = await server.list_tools()
names = [t.name for t in tools]
assert "crm_save" not in names
# ---------------------------------------------------------------------------
# End-to-end via Client
# ---------------------------------------------------------------------------
class TestEndToEnd:
async def test_ui_tool_visible_to_client(self):
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 = 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
# ---------------------------------------------------------------------------
# .run() convenience
# ---------------------------------------------------------------------------
class TestRun:
def test_repr(self):
app = FastMCPApp("Dashboard")
assert repr(app) == "FastMCPApp('Dashboard')"
# ---------------------------------------------------------------------------
# add_tool programmatic
# ---------------------------------------------------------------------------
class TestAddTool:
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_tagged_with_app_name(self):
app = FastMCPApp("myapp")
def save(name: str) -> str:
return name
tool = app.add_tool(save)
assert tool.meta is not None
assert tool.meta["fastmcp"]["app"] == "myapp"
async def test_add_tool_findable_via_get_app_tool(self):
app = FastMCPApp("myapp")
def save(name: str) -> str:
return name
app.add_tool(save)
tool = await app.get_app_tool("myapp", "save")
assert tool is not None
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:
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")
r1 = await server.call_tool("CRM___save_contact", {"name": "alice"})
r2 = await server.call_tool("Billing___create_invoice", {"amount": 100})
assert r1.content[0].text == "alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
assert r2.content[0].text == "100" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
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)
# ---------------------------------------------------------------------------
# Integration: full end-to-end with client, namespacing, and structured content
# ---------------------------------------------------------------------------
class TestAppIntegration:
async def test_full_app_lifecycle_through_client(self):
"""End-to-end: mount an app on a namespaced server, call UI tool
through a client (verifying structured_content is returned), then
call the backend tool via the ___-prefixed name."""
app = FastMCPApp("contacts")
@app.ui()
def contact_form() -> Text:
return Text(content="Enter contact details")
@app.tool()
def save_contact(name: str, email: str) -> dict[str, str]:
return {"name": name, "email": email}
server = FastMCP("Platform")
server.add_provider(app, namespace="crm")
# The @app.ui() tool should be visible (namespaced) to the client.
# The @app.tool() backend tool should NOT appear.
async with Client(server) as client:
tools = await client.list_tools()
tool_names = [t.name for t in tools]
assert "crm_contact_form" in tool_names
assert "crm_save_contact" not in tool_names
# Call the UI tool through the client and check structured_content
result = await client.call_tool_mcp("crm_contact_form", {})
sc = result.structuredContent
assert sc is not None
# Call the backend tool via prefixed name
# (bypasses namespace transforms and visibility filtering)
backend_result = await server.call_tool(
"contacts___save_contact",
{"name": "Alice", "email": "alice@example.com"},
)
result_text = backend_result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
assert "Alice" in result_text
assert "alice@example.com" in result_text