diff --git a/src/fastmcp/server/app.py b/src/fastmcp/server/app.py index 97ff4fcc2..730aeb031 100644 --- a/src/fastmcp/server/app.py +++ b/src/fastmcp/server/app.py @@ -1,11 +1,11 @@ """FastMCPApp — a Provider that represents a composable MCP application. FastMCPApp binds entry-point tools (model calls these) together with backend -tools (the UI calls these via CallTool). Backend tools are registered in a -process-level registry keyed by ``(app_name, tool_name)`` so that -``CallTool("save_contact")`` reaches the right tool regardless of namespace -transforms — the server looks up the tool directly when the request carries -``_meta.fastmcp.app``. +tools (the UI calls these via CallTool). Backend tools are tagged with +``meta["fastmcp"]["app"]`` so they can be found through the provider chain +even when transforms (namespace, visibility, etc.) have renamed or hidden +them — the server sets a context var that tells ``Provider.get_tool`` to +fall back to a direct lookup for app-visible tools. Usage:: @@ -44,20 +44,6 @@ logger = get_logger(__name__) F = TypeVar("F", bound=Callable[..., Any]) -# --------------------------------------------------------------------------- -# Process-level registry -# --------------------------------------------------------------------------- -# (app_name, tool_name) → Tool object. Populated by @app.tool() at -# decoration time. The server checks this registry (via get_app_tool) -# when a call_tool request carries _meta.fastmcp.app, bypassing the -# normal transform chain entirely. -_APP_TOOLS: dict[tuple[str, str], Tool] = {} - - -def get_app_tool(app_name: str, tool_name: str) -> Tool | None: - """Look up an app tool by (app_name, tool_name), or return None.""" - return _APP_TOOLS.get((app_name, tool_name)) - # --------------------------------------------------------------------------- # CallTool resolver @@ -138,9 +124,8 @@ class FastMCPApp(Provider): Binds together entry-point tools (``@app.ui``), backend tools (``@app.tool``), and the Prefab renderer resource. Backend tools - are registered in a process-level registry keyed by - ``(app_name, tool_name)`` so the server can route app-originated - calls directly, bypassing transforms. + are tagged with ``meta["fastmcp"]["app"]`` so ``Provider.get_tool`` + can find them by original name even when transforms have been applied. """ def __init__(self, name: str) -> None: @@ -217,7 +202,10 @@ class FastMCPApp(Provider): from fastmcp.server.apps import AppConfig, app_config_to_meta_dict app_config = AppConfig(visibility=visibility) - meta: dict[str, Any] = {"ui": app_config_to_meta_dict(app_config)} + meta: dict[str, Any] = { + "ui": app_config_to_meta_dict(app_config), + "fastmcp": {"app": self.name}, + } tool_obj = Tool.from_function( fn, @@ -228,7 +216,6 @@ class FastMCPApp(Provider): auth=auth, ) self._local._add_component(tool_obj) - _APP_TOOLS[(self.name, resolved_name)] = tool_obj return fn return _dispatch_decorator(name_or_fn, name, _register, "tool") @@ -283,9 +270,8 @@ class FastMCPApp(Provider): """Register a UI entry-point tool that the model calls. Entry-point tools default to ``visibility=["model"]`` and auto-wire - the Prefab renderer resource and CSP. They do NOT get registered in - the app tool registry — the model resolves them through the normal - transform chain. + the Prefab renderer resource and CSP. They are tagged with the app + name so structured content includes ``_meta.fastmcp.app``. Supports multiple calling patterns:: @@ -363,13 +349,17 @@ class FastMCPApp(Provider): ) -> Tool: """Add a tool to this app programmatically. - The tool is registered as a backend tool in the app's registry. + The tool is tagged with this app's name for routing. """ if not isinstance(tool, Tool): tool = Tool._ensure_tool(tool) + # Tag with app name for routing + meta = dict(tool.meta) if tool.meta else {} + meta.setdefault("fastmcp", {})["app"] = self.name + tool.meta = meta + self._local._add_component(tool) - _APP_TOOLS[(self.name, tool.name)] = tool return tool # ------------------------------------------------------------------ diff --git a/src/fastmcp/server/providers/aggregate.py b/src/fastmcp/server/providers/aggregate.py index 0b212315e..c57f9aa9c 100644 --- a/src/fastmcp/server/providers/aggregate.py +++ b/src/fastmcp/server/providers/aggregate.py @@ -168,6 +168,19 @@ class AggregateProvider(Provider): ) return self._get_highest_version_result(results, f"get_tool({name!r})") # type: ignore[return-value] + async def get_app_tool(self, app_name: str, tool_name: str) -> Tool | None: + """Query all child providers for an app tool.""" + results = await gather( + *[p.get_app_tool(app_name, tool_name) for p in self.providers], + return_exceptions=True, + ) + for r in results: + if isinstance(r, BaseException): + continue + if r is not None: + return r + return None + # ------------------------------------------------------------------------- # Resources # ------------------------------------------------------------------------- diff --git a/src/fastmcp/server/providers/base.py b/src/fastmcp/server/providers/base.py index 49d278f6d..107eb3198 100644 --- a/src/fastmcp/server/providers/base.py +++ b/src/fastmcp/server/providers/base.py @@ -158,11 +158,6 @@ class Provider: (FastMCP) performs enabled filtering after all transforms complete, allowing session-level transforms to override provider-level disables. - If the transform chain returns None, a fallback checks for - app-visible tools by their original (untransformed) name. This - lets ``CallTool("save_contact")`` reach backend tools even when - namespace or other transforms have been applied to the provider. - Args: name: The transformed tool name to look up. version: Optional version filter. If None, returns highest version. @@ -180,6 +175,29 @@ class Provider: return await chain(name, version=version) + async def get_app_tool(self, app_name: str, tool_name: str) -> Tool | None: + """Look up an app-visible tool by original name, bypassing transforms. + + This is the routing path for tool calls from app UIs (identified by + ``_meta.fastmcp.app`` on the request). It skips the transform chain + entirely — the tool is found by its registered name and matched + against the app identity in its metadata. + + The default implementation checks this provider's own storage via + ``_get_tool``. Aggregate and wrapped providers override to + delegate to children. + + Returns: + The tool if found and tagged with the given app name, else None. + """ + tool = await self._get_tool(tool_name) + if tool is not None: + meta = tool.meta or {} + fastmcp_meta = meta.get("fastmcp") + if isinstance(fastmcp_meta, dict) and fastmcp_meta.get("app") == app_name: + return tool + return None + async def list_resources(self) -> Sequence[Resource]: """List resources with all transforms applied. diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py index cf536bb3b..789a7c190 100644 --- a/src/fastmcp/server/providers/fastmcp_provider.py +++ b/src/fastmcp/server/providers/fastmcp_provider.py @@ -562,6 +562,10 @@ class FastMCPProvider(Provider): return None return FastMCPProviderTool.wrap(self.server, raw_tool) + async def get_app_tool(self, app_name: str, tool_name: str) -> Tool | None: + """Delegate to nested server's get_app_tool, bypassing transforms.""" + return await self.server.get_app_tool(app_name, tool_name) + # ------------------------------------------------------------------------- # Resource methods # ------------------------------------------------------------------------- diff --git a/src/fastmcp/server/providers/wrapped_provider.py b/src/fastmcp/server/providers/wrapped_provider.py index 17ae018d9..3ce097fff 100644 --- a/src/fastmcp/server/providers/wrapped_provider.py +++ b/src/fastmcp/server/providers/wrapped_provider.py @@ -63,6 +63,10 @@ class _WrappedProvider(Provider): """Delegate to inner's get_tool (includes inner's transforms).""" return await self._inner.get_tool(name, version) + async def get_app_tool(self, app_name: str, tool_name: str) -> Tool | None: + """Delegate to inner, bypassing this wrapper's transforms.""" + return await self._inner.get_app_tool(app_name, tool_name) + async def _list_resources(self) -> Sequence[Resource]: """Delegate to inner's list_resources (includes inner's transforms).""" return await self._inner.list_resources() diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 8c997c719..306242537 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1100,14 +1100,13 @@ class FastMCP( with server_span( f"tools/call {name}", "tools/call", self.name, "tool", name ) as span: - # If the call came from an app UI (_meta.fastmcp.app is set), - # look up the tool directly in the app registry, bypassing - # transforms. Otherwise use normal provider resolution. + # If the call came from an app UI (_meta.fastmcp.app), + # look up the tool via get_app_tool which walks the + # provider tree bypassing transforms. Otherwise use + # normal provider resolution. tool: Tool | None = None if app_name is not None: - from fastmcp.server.app import get_app_tool - - tool = get_app_tool(app_name, name) + tool = await self.get_app_tool(app_name, name) if tool is not None: # Auth still applies to app tools skip_auth, token = _get_auth_context() diff --git a/tests/test_fastmcp_app.py b/tests/test_fastmcp_app.py index 1e0b65cb5..e5ea97565 100644 --- a/tests/test_fastmcp_app.py +++ b/tests/test_fastmcp_app.py @@ -3,7 +3,7 @@ Covers: - @app.tool() decorator (visibility, calling patterns) - @app.ui() decorator (model visibility, CSP auto-wiring) -- App tool registry and call_tool routing +- get_app_tool routing through provider chain - Callable resolver (_resolve_tool_ref) - Composition with namespaced servers - Provider interface delegation @@ -18,32 +18,17 @@ from prefab_ui.app import ResolvedTool from fastmcp import Client, FastMCP from fastmcp.server.app import ( - _APP_TOOLS, FastMCPApp, _resolve_tool_ref, - get_app_tool, ) from fastmcp.tools.base import Tool -# --------------------------------------------------------------------------- -# Fixtures / helpers -# --------------------------------------------------------------------------- - - -def _clear_registries() -> None: - """Clear process-level registries between tests.""" - _APP_TOOLS.clear() - - # --------------------------------------------------------------------------- # @app.tool() decorator # --------------------------------------------------------------------------- class TestAppTool: - def setup_method(self) -> None: - _clear_registries() - def test_tool_bare_decorator(self): app = FastMCPApp("test") @@ -111,24 +96,17 @@ class TestAppTool: assert len(tools) == 1 assert tools[0].name == "custom_save" - def test_tool_registered_in_app_tools(self): - app = FastMCPApp("test") + async def test_tool_has_app_name_in_meta(self): + app = FastMCPApp("contacts") @app.tool() def save(name: str) -> str: return name - assert ("test", "save") in _APP_TOOLS - assert _APP_TOOLS[("test", "save")].name == "save" - - def test_tool_custom_name_registered_correctly(self): - app = FastMCPApp("myapp") - - @app.tool("custom_save") - def save(name: str) -> str: - return name - - assert ("myapp", "custom_save") in _APP_TOOLS + 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") @@ -154,19 +132,6 @@ class TestAppTool: assert meta is not None assert meta["ui"]["visibility"] == ["app", "model"] - async def test_tool_no_global_key_in_meta(self): - """Tools should NOT have globalKey in meta (removed in refactor).""" - 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 "globalKey" not in meta.get("ui", {}) - def test_tool_with_description(self): app = FastMCPApp("test") @@ -196,9 +161,6 @@ class TestAppTool: class TestAppUI: - def setup_method(self) -> None: - _clear_registries() - def test_ui_bare_decorator(self): app = FastMCPApp("test") @@ -249,15 +211,17 @@ class TestAppUI: assert meta is not None assert meta["ui"]["visibility"] == ["model"] - def test_ui_not_in_app_tools(self): - """UI entry points should NOT be in the app tool registry.""" + async def test_ui_has_app_name_in_meta(self): app = FastMCPApp("test") @app.ui() def dashboard() -> str: return "dashboard" - assert ("test", "dashboard") not in _APP_TOOLS + 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") @@ -312,9 +276,6 @@ class TestAppUI: class TestResolveToolRef: - def setup_method(self) -> None: - _clear_registries() - def test_resolve_string_passes_through(self): """Strings pass through as-is — server resolves at call time.""" result = _resolve_tool_ref("save_contact") @@ -347,44 +308,43 @@ class TestResolveToolRef: # --------------------------------------------------------------------------- -# get_app_tool registry +# get_app_tool — provider chain routing # --------------------------------------------------------------------------- class TestGetAppTool: - def setup_method(self) -> None: - _clear_registries() - - def test_lookup_by_app_and_tool_name(self): + 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 = get_app_tool("contacts", "save") + tool = await app.get_app_tool("contacts", "save") assert tool is not None assert tool.name == "save" - def test_lookup_wrong_app_returns_none(self): + async def test_wrong_app_returns_none(self): app = FastMCPApp("contacts") @app.tool() def save(name: str) -> str: return name - assert get_app_tool("billing", "save") is None + assert await app.get_app_tool("billing", "save") is None - def test_lookup_wrong_tool_returns_none(self): + async def test_wrong_tool_returns_none(self): app = FastMCPApp("contacts") @app.tool() def save(name: str) -> str: return name - assert get_app_tool("contacts", "missing") is None + assert await app.get_app_tool("contacts", "missing") is None - def test_two_apps_same_tool_name_no_collision(self): + async def test_two_apps_no_collision(self): + """Two apps with the same tool name are disambiguated.""" app1 = FastMCPApp("contacts") app2 = FastMCPApp("billing") @@ -396,12 +356,47 @@ class TestGetAppTool: def save_billing(amount: int) -> str: return f"invoice: {amount}" - t1 = get_app_tool("contacts", "save") - t2 = get_app_tool("billing", "save") + 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_findable_by_app_name(self): + """@app.ui() tools are also tagged with app name.""" + app = FastMCPApp("dashboard") + + @app.ui() + def show() -> str: + return "ui" + + tool = await app.get_app_tool("dashboard", "show") + assert tool is not None + # --------------------------------------------------------------------------- # Provider interface @@ -409,9 +404,6 @@ class TestGetAppTool: class TestProviderInterface: - def setup_method(self) -> None: - _clear_registries() - async def test_list_tools_empty(self): app = FastMCPApp("test") assert await app._list_tools() == [] @@ -442,11 +434,8 @@ class TestProviderInterface: class TestCallToolAppRouting: - def setup_method(self) -> None: - _clear_registries() - async def test_call_tool_with_app_name(self): - """Server.call_tool routes directly when app_name is provided.""" + """Server.call_tool routes via get_app_tool when app_name is set.""" app = FastMCPApp("contacts") @app.tool() @@ -459,7 +448,7 @@ class TestCallToolAppRouting: result = await server.call_tool("save", {"name": "alice"}, app_name="contacts") assert result.content[0].text == "saved alice" # type: ignore[union-attr] - async def test_call_tool_by_name_without_app_name(self): + async def test_call_tool_without_app_name(self): """Regular name-based resolution still works.""" app = FastMCPApp("test") @@ -474,7 +463,7 @@ class TestCallToolAppRouting: assert result.content[0].text == "saved bob" # type: ignore[union-attr] async def test_app_name_survives_namespace(self): - """app_name routing works even when the app is namespaced.""" + """app_name routing bypasses namespace transforms.""" app = FastMCPApp("crm") @app.tool() @@ -484,7 +473,6 @@ class TestCallToolAppRouting: server = FastMCP("Platform") server.add_provider(app, namespace="crm") - # app_name routes directly, bypassing namespace result = await server.call_tool( "save_contact", {"name": "alice"}, app_name="crm" ) @@ -527,7 +515,7 @@ class TestCallToolAppRouting: _current_transport.reset(token) async def test_two_apps_same_tool_name_routed_correctly(self): - """Two apps with same tool name are disambiguated by app_name.""" + """Two apps with same tool name disambiguated by app_name.""" contacts = FastMCPApp("contacts") billing = FastMCPApp("billing") @@ -549,6 +537,25 @@ class TestCallToolAppRouting: assert r1.content[0].text == "contact: alice" # type: ignore[union-attr] assert r2.content[0].text == "invoice: 100" # type: ignore[union-attr] + 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("hidden", {"x": "found"}, app_name="deep") + assert result.content[0].text == "found" # type: ignore[union-attr] + # --------------------------------------------------------------------------- # End-to-end via Client @@ -556,9 +563,6 @@ class TestCallToolAppRouting: class TestEndToEnd: - def setup_method(self) -> None: - _clear_registries() - async def test_ui_tool_visible_to_client(self): app = FastMCPApp("test") @@ -607,9 +611,6 @@ class TestRun: class TestAddTool: - def setup_method(self) -> None: - _clear_registries() - async def test_add_tool_from_function(self): app = FastMCPApp("test") @@ -622,14 +623,25 @@ class TestAddTool: tools = await app._list_tools() assert len(tools) == 1 - async def test_add_tool_registered_in_app_tools(self): - app = FastMCPApp("test") + 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) - assert ("test", "save") in _APP_TOOLS + tool = await app.get_app_tool("myapp", "save") + assert tool is not None async def test_add_tool_object(self): app = FastMCPApp("test") @@ -647,9 +659,6 @@ class TestAddTool: class TestComposition: - def setup_method(self) -> None: - _clear_registries() - async def test_multiple_apps_on_one_server(self): crm = FastMCPApp("CRM") billing = FastMCPApp("Billing")