Route app tool calls through provider chain, remove process-level registry (#3587)

Adds Provider.get_app_tool(app_name, tool_name) — a dedicated method for
finding app-visible tools by their original name, bypassing transforms.
AggregateProvider queries children, WrappedProvider delegates to inner,
FastMCPProvider delegates to nested server. The default implementation
checks _get_tool and matches meta.fastmcp.app.

This replaces the process-level _APP_TOOLS registry. Tool routing now
works through the provider tree, which exists in every process — no
shared state needed for horizontal scaling.
This commit is contained in:
Jeremiah Lowin 2026-03-22 19:29:33 -04:00 committed by GitHub
commit 96497acd16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 164 additions and 127 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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