Route app tool calls via ___-prefixed names instead of _meta

Hosts (Goose, MCP Jam) don't forward _meta on callServerTool, which
broke app tool routing entirely. Encode the app identity in the tool
name on the wire instead: the resolver writes "AppName___tool_name",
and the server parses it to route via get_app_tool.
This commit is contained in:
Jeremiah Lowin 2026-03-27 21:31:16 -04:00
commit 773f6586e0
No known key found for this signature in database
9 changed files with 329 additions and 111 deletions

View file

@ -0,0 +1,199 @@
"""File upload — bypass the LLM context window to get files onto the server.
The most practical use of MCP Apps: letting users upload files directly to
the server without pushing bytes through the model's context. The LLM asks
the user to upload, the user drops files, clicks Upload, and the server
stores them. The LLM can then work with the files through backend tools.
Usage:
uv run python file_upload_server.py
"""
from __future__ import annotations
import base64
from datetime import datetime
from prefab_ui.actions import SetState, ShowToast
from prefab_ui.actions.mcp import CallTool
from prefab_ui.app import PrefabApp
from prefab_ui.components import (
H3,
Badge,
Button,
Card,
CardContent,
CardFooter,
CardHeader,
Column,
DropZone,
Muted,
Row,
Separator,
Small,
Text,
)
from prefab_ui.components.control_flow import Else, ForEach, If
from prefab_ui.rx import ERROR, RESULT, STATE, Rx
from fastmcp import FastMCP, FastMCPApp
# ---------------------------------------------------------------------------
# In-memory file store
# ---------------------------------------------------------------------------
_files: dict[str, dict] = {}
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
app = FastMCPApp("Files")
@app.tool()
def store_files(files: list[dict]) -> list[dict]:
"""Store uploaded files. Receives file objects with name, size, type, data (base64)."""
for f in files:
_files[f["name"]] = {
"name": f["name"],
"size": f["size"],
"type": f["type"],
"data": f["data"],
"uploaded_at": datetime.now().isoformat(timespec="seconds"),
}
return _file_summaries()
@app.tool(model=True)
def list_files() -> list[dict]:
"""List all uploaded files with metadata."""
return _file_summaries()
@app.tool(model=True)
def read_file(name: str) -> dict:
"""Read an uploaded file's contents by name."""
if name not in _files:
available = list(_files.keys())
raise ValueError(f"File {name!r} not found. Available: {available}")
entry = _files[name]
result = {
"name": entry["name"],
"size": entry["size"],
"type": entry["type"],
"uploaded_at": entry["uploaded_at"],
}
if entry["type"].startswith("text/") or entry["name"].endswith(
(".csv", ".json", ".txt", ".md", ".py", ".yaml", ".yml", ".toml")
):
try:
result["content"] = base64.b64decode(entry["data"]).decode("utf-8")
except (UnicodeDecodeError, Exception):
result["content_base64"] = entry["data"][:200] + "..."
else:
result["content_base64"] = entry["data"][:200] + "..."
return result
@app.ui()
def file_manager() -> PrefabApp:
"""Upload and manage files. Drop files here to send them to the server."""
with Card(css_class="max-w-2xl mx-auto") as view:
with CardHeader():
with Row(gap=2, align="center"):
H3("File Upload")
with If(STATE.stored.length()):
Badge(STATE.stored.length(), variant="secondary")
with CardContent():
with Column(gap=4):
Muted(
"Drop files to upload them to the server. "
"The model can then read and analyze them "
"without using the context window."
)
DropZone(
name="pending",
icon="inbox",
label="Drop files here",
description="Any file type, up to 10MB",
multiple=True,
max_size=10 * 1024 * 1024,
)
# Show pending files
with If(STATE.pending.length()):
with Column(gap=2):
with ForEach("pending") as (i, item):
with Row(gap=2, align="center"):
with Column(gap=0):
Small(item.name)
Muted(f"{item.type} · {item.size} bytes")
Button(
"Upload to Server",
on_click=CallTool(
"store_files",
arguments={"files": Rx("pending")},
on_success=[
SetState("stored", RESULT),
SetState("pending", []),
ShowToast("Files uploaded!", variant="success"),
],
on_error=ShowToast(ERROR, variant="error"),
),
)
# Show uploaded files
with If(STATE.stored.length()):
Separator()
Text("Uploaded", css_class="font-medium text-sm")
with ForEach("stored") as f:
with Row(gap=2, align="center", css_class="justify-between"):
with Column(gap=0):
Small(f.name)
Muted(f.uploaded_at)
with Row(gap=2):
Badge(f.type, variant="secondary")
Badge(f.size_display, variant="outline")
with CardFooter():
with Row(align="center", css_class="w-full"):
with If(STATE.stored.length()):
Muted(
f"{STATE.stored.length()}"
f" {STATE.stored.length().pluralize('file')} on server"
)
with Else():
Muted("No files uploaded yet")
return PrefabApp(view=view, state={"pending": [], "stored": _file_summaries()})
def _file_summaries() -> list[dict]:
summaries = []
for entry in _files.values():
size = entry["size"]
if size < 1024:
size_display = f"{size} B"
elif size < 1024 * 1024:
size_display = f"{size / 1024:.1f} KB"
else:
size_display = f"{size / (1024 * 1024):.1f} MB"
summaries.append(
{
"name": entry["name"],
"type": entry["type"],
"size_display": size_display,
"uploaded_at": entry["uploaded_at"],
}
)
return summaries
mcp = FastMCP("File Upload Server", providers=[app])
if __name__ == "__main__":
mcp.run()

View file

@ -50,38 +50,47 @@ F = TypeVar("F", bound=Callable[..., Any])
# ---------------------------------------------------------------------------
def _resolve_tool_ref(fn: Any) -> Any:
"""Resolve a callable or string to a ``ResolvedTool`` for CallTool serialization.
def _make_resolver(app_name: str | None = None) -> Any:
"""Create a CallTool resolver that prefixes tool names with the app name.
For strings, passes them through as-is the server resolves them at
call time using ``_meta.fastmcp.app``.
For callables, extracts the tool name from ``__fastmcp__`` metadata
or ``__name__``.
When ``app_name`` is set, tool references like ``CallTool("store_files")``
or ``CallTool(store_files)`` are resolved to
``ResolvedTool(name="Files___store_files")``. This produces stable
identifiers that bypass transforms and work without host ``_meta``
forwarding.
"""
from prefab_ui.app import ResolvedTool
if isinstance(fn, str):
return ResolvedTool(name=fn)
def _prefix(name: str) -> str:
if app_name and "___" not in name:
return f"{app_name}___{name}"
return name
fmeta: Any = None
try:
from fastmcp.decorators import get_fastmcp_meta
def _resolve_tool_ref(fn: Any) -> Any:
from prefab_ui.app import ResolvedTool
fmeta = get_fastmcp_meta(fn)
except Exception:
pass
if isinstance(fn, str):
return ResolvedTool(name=_prefix(fn))
if fmeta is not None:
name: str | None = getattr(fmeta, "name", None)
if name is not None:
return ResolvedTool(name=name)
fmeta: Any = None
try:
from fastmcp.decorators import get_fastmcp_meta
fn_name = getattr(fn, "__name__", None)
if fn_name is not None:
return ResolvedTool(name=fn_name)
fmeta = get_fastmcp_meta(fn)
except Exception:
pass
raise ValueError(f"Cannot resolve tool reference: {fn!r}")
if fmeta is not None:
name: str | None = getattr(fmeta, "name", None)
if name is not None:
return ResolvedTool(name=_prefix(name))
fn_name = getattr(fn, "__name__", None)
if fn_name is not None:
return ResolvedTool(name=_prefix(fn_name))
raise ValueError(f"Cannot resolve tool reference: {fn!r}")
return _resolve_tool_ref
def _dispatch_decorator(
@ -129,6 +138,11 @@ class FastMCPApp(Provider):
"""
def __init__(self, name: str) -> None:
if "___" in name:
raise ValueError(
f"App name {name!r} must not contain '___' "
"(reserved as the app tool routing separator)"
)
super().__init__()
self.name = name
self._local = LocalProvider(on_duplicate="error")
@ -354,7 +368,6 @@ class FastMCPApp(Provider):
if not isinstance(tool, Tool):
tool = Tool._ensure_tool(tool)
# Tag with app name and visibility for routing
meta = dict(tool.meta) if tool.meta else {}
meta.setdefault("fastmcp", {})["app"] = self.name
ui = meta.setdefault("ui", {})

View file

@ -8,7 +8,7 @@ import warnings
from fastmcp.apps.app import FastMCPApp as FastMCPApp
from fastmcp.apps.app import _dispatch_decorator as _dispatch_decorator
from fastmcp.apps.app import _resolve_tool_ref as _resolve_tool_ref
from fastmcp.apps.app import _make_resolver as _make_resolver
from fastmcp.exceptions import FastMCPDeprecationWarning
warnings.warn(

View file

@ -217,15 +217,12 @@ class MCPOperationsMixin:
# fn_key is set by call_tool() after finding the tool.
version_str: str | None = None
task_meta: TaskMeta | None = None
app_name: str | None = None
try:
ctx = server._mcp_server.request_context
# Extract version and app name from _meta.fastmcp
# Extract version from _meta.fastmcp
if ctx.meta:
meta_dict = ctx.meta.model_dump(exclude_none=True)
fastmcp_meta = meta_dict.get("fastmcp", {})
version_str = fastmcp_meta.get("version")
app_name = fastmcp_meta.get("app")
version_str = meta_dict.get("fastmcp", {}).get("version")
# Extract SEP-1686 task metadata
if ctx.experimental.is_task:
mcp_task_meta = ctx.experimental.task_metadata
@ -236,7 +233,7 @@ class MCPOperationsMixin:
version = VersionSpec(eq=version_str) if version_str else None
result = await server.call_tool(
key, arguments, version=version, task_meta=task_meta, app_name=app_name
key, arguments, version=version, task_meta=task_meta
)
if isinstance(result, mcp.types.CreateTaskResult):

View file

@ -178,14 +178,8 @@ class Provider:
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.
Searches for a tool named ``tool_name`` tagged with the given app
name. Skips the transform chain entirely.
Returns:
The tool if found and tagged with the given app name, else None.

View file

@ -138,14 +138,6 @@ class FastMCPProviderTool(Tool):
# Pass exact version so child executes the correct version
version = VersionSpec(eq=self.version) if self.version else None
# If this tool belongs to a FastMCPApp, pass app_name so the
# child server routes via get_app_tool (bypassing transforms).
app_name: str | None = None
meta = self.meta or {}
fastmcp_meta = meta.get("fastmcp")
if isinstance(fastmcp_meta, dict):
app_name = fastmcp_meta.get("app")
with delegate_span(
self._original_name or "", "FastMCPProvider", self._original_name or ""
):
@ -154,7 +146,6 @@ class FastMCPProviderTool(Tool):
arguments,
version=version,
task_meta=task_meta,
app_name=app_name,
)
async def run(self, arguments: dict[str, Any]) -> ToolResult:
@ -166,14 +157,8 @@ class FastMCPProviderTool(Tool):
# Pass exact version so child executes the correct version
version = VersionSpec(eq=self.version) if self.version else None
app_name: str | None = None
meta = self.meta or {}
fastmcp_meta = meta.get("fastmcp")
if isinstance(fastmcp_meta, dict):
app_name = fastmcp_meta.get("app")
result = await self._server.call_tool(
self._original_name, arguments, version=version, app_name=app_name
self._original_name, arguments, version=version
)
# Result from call_tool should always be ToolResult when no task_meta
if isinstance(result, mcp.types.CreateTaskResult):
@ -586,7 +571,12 @@ class FastMCPProvider(Provider):
raw_tool = await self.server.get_app_tool(app_name, tool_name)
if raw_tool is None:
return None
return FastMCPProviderTool.wrap(self.server, raw_tool)
wrapped = FastMCPProviderTool.wrap(self.server, raw_tool)
# Use the ___-prefixed name so the inner server's call_tool also
# takes the app-tool bypass path (app-only tools are hidden from
# normal get_tool visibility filtering).
wrapped._original_name = f"{app_name}___{tool_name}"
return wrapped
# -------------------------------------------------------------------------
# Resource methods

View file

@ -1046,7 +1046,6 @@ class FastMCP(
version: VersionSpec | None = None,
run_middleware: bool = True,
task_meta: None = None,
app_name: str | None = None,
) -> ToolResult: ...
@overload
@ -1058,7 +1057,6 @@ class FastMCP(
version: VersionSpec | None = None,
run_middleware: bool = True,
task_meta: TaskMeta,
app_name: str | None = None,
) -> mcp.types.CreateTaskResult: ...
async def call_tool(
@ -1069,7 +1067,6 @@ class FastMCP(
version: VersionSpec | None = None,
run_middleware: bool = True,
task_meta: TaskMeta | None = None,
app_name: str | None = None,
) -> ToolResult | mcp.types.CreateTaskResult:
"""Call a tool by name.
@ -1084,9 +1081,6 @@ class FastMCP(
task_meta: If provided, execute as a background task and return
CreateTaskResult. If None (default), execute synchronously and
return ToolResult.
app_name: If set (from ``_meta.fastmcp.app``), the call originated
from an app UI and should be routed directly to the named app's
tool registry, bypassing transforms.
Returns:
ToolResult when task_meta is None.
@ -1120,7 +1114,6 @@ class FastMCP(
version=version,
run_middleware=False,
task_meta=task_meta,
app_name=app_name,
),
)
@ -1129,13 +1122,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),
# 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:
tool = await self.get_app_tool(app_name, name)
# Try normal resolution first. If that fails and the name
# contains "___" (app tool prefix), parse out the app name
# and route via get_app_tool which bypasses transforms.
tool: Tool | None = await self.get_tool(name, version=version)
if tool is None and "___" in name:
app_prefix, _, tool_suffix = name.partition("___")
tool = await self.get_app_tool(app_prefix, tool_suffix)
if tool is not None:
# Auth still applies to app tools
skip_auth, token = _get_auth_context()
@ -1146,8 +1139,6 @@ class FastMCP(
raise NotFoundError(f"Unknown tool: {name!r}")
except AuthorizationError:
raise NotFoundError(f"Unknown tool: {name!r}") from None
if tool is None:
tool = await self.get_tool(name, version=version)
if tool is None:
raise NotFoundError(f"Unknown tool: {name!r}")
span.set_attributes(tool.get_span_attributes())

View file

@ -498,12 +498,12 @@ def _convert_to_single_content_block(
_PREFAB_TEXT_FALLBACK = "[Rendered Prefab UI]"
def _get_tool_resolver() -> Callable[..., str] | None:
def _get_tool_resolver(app_name: str | None = None) -> Callable[..., str] | None:
"""Get the FastMCPApp callable resolver, if available."""
try:
from fastmcp.apps.app import _resolve_tool_ref
from fastmcp.apps.app import _make_resolver
return _resolve_tool_ref
return _make_resolver(app_name)
except ImportError:
return None
@ -511,14 +511,11 @@ def _get_tool_resolver() -> Callable[..., str] | None:
def _prefab_to_json(app: Any, fastmcp_app_name: str | None = None) -> dict[str, Any]:
"""Call PrefabApp.to_json() with the FastMCPApp callable resolver.
If ``fastmcp_app_name`` is set, injects ``_meta.fastmcp.app`` into the
serialized output so the renderer can tag subsequent tool calls with the
app identity for direct routing.
The resolver prefixes tool names with the app name (e.g.
``"store_files"`` ``"Files___store_files"``) so the server can
find them via the bypass lookup regardless of transforms.
"""
data = app.to_json(tool_resolver=_get_tool_resolver())
if fastmcp_app_name is not None:
meta = data.setdefault("_meta", {})
meta.setdefault("fastmcp", {})["app"] = fastmcp_app_name
data = app.to_json(tool_resolver=_get_tool_resolver(fastmcp_app_name))
return data

View file

@ -20,7 +20,7 @@ from prefab_ui.components import Text
from fastmcp import Client, FastMCP
from fastmcp.apps.app import (
FastMCPApp,
_resolve_tool_ref,
_make_resolver,
)
from fastmcp.tools.base import Tool
@ -29,6 +29,16 @@ from fastmcp.tools.base import Tool
# ---------------------------------------------------------------------------
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")
@ -277,20 +287,42 @@ class TestAppUI:
class TestResolveToolRef:
def test_resolve_string_passes_through(self):
"""Strings pass through as-is — server resolves at call time."""
result = _resolve_tool_ref("save_contact")
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_callable_uses_name(self):
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 = _resolve_tool_ref(my_tool)
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
@ -299,13 +331,25 @@ class TestResolveToolRef:
my_tool.__fastmcp__ = ToolMeta(name="custom_name") # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
result = _resolve_tool_ref(my_tool)
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):
_resolve_tool_ref(42)
_make_resolver()(42)
# ---------------------------------------------------------------------------
@ -458,7 +502,7 @@ class TestCallToolAppRouting:
server = FastMCP("Platform")
server.add_provider(app)
result = await server.call_tool("save", {"name": "alice"}, app_name="contacts")
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):
@ -486,9 +530,7 @@ class TestCallToolAppRouting:
server = FastMCP("Platform")
server.add_provider(app, namespace="crm")
result = await server.call_tool(
"save_contact", {"name": "alice"}, app_name="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):
@ -523,7 +565,7 @@ class TestCallToolAppRouting:
token = _current_transport.set("streamable-http")
try:
with pytest.raises(NotFoundError):
await server.call_tool("secret", {}, app_name="test")
await server.call_tool("test___secret", {})
finally:
_current_transport.reset(token)
@ -544,8 +586,8 @@ class TestCallToolAppRouting:
server.add_provider(contacts)
server.add_provider(billing)
r1 = await server.call_tool("save", {"name": "alice"}, app_name="contacts")
r2 = await server.call_tool("save", {"amount": "100"}, app_name="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]
@ -566,7 +608,7 @@ class TestCallToolAppRouting:
# Normal resolution: would need "inner_app_hidden"
# App routing: bypasses all transforms
result = await outer.call_tool("hidden", {"x": "found"}, app_name="deep")
result = await outer.call_tool("deep___hidden", {"x": "found"})
assert result.content[0].text == "found" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
@ -638,7 +680,7 @@ class TestAppOnlyToolFiltering:
assert "save" not in names
# But still callable via app_name routing
result = await server.call_tool("save", {"name": "alice"}, app_name="contacts")
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):
@ -789,10 +831,8 @@ class TestComposition:
server.add_provider(crm, namespace="crm")
server.add_provider(billing, namespace="billing")
r1 = await server.call_tool("save_contact", {"name": "alice"}, app_name="CRM")
r2 = await server.call_tool(
"create_invoice", {"amount": 100}, app_name="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]
@ -833,8 +873,8 @@ class TestComposition:
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 contains _meta.fastmcp.app),
then call the backend tool via server.call_tool with app_name."""
through a client (verifying structured_content is returned), then
call the backend tool via the ___-prefixed name."""
app = FastMCPApp("contacts")
@app.ui()
@ -860,15 +900,12 @@ class TestAppIntegration:
result = await client.call_tool_mcp("crm_contact_form", {})
sc = result.structuredContent
assert sc is not None
assert "_meta" in sc
assert sc["_meta"]["fastmcp"]["app"] == "contacts"
# Call the backend tool via server.call_tool with app_name
# Call the backend tool via prefixed name
# (bypasses namespace transforms and visibility filtering)
backend_result = await server.call_tool(
"save_contact",
"contacts___save_contact",
{"name": "Alice", "email": "alice@example.com"},
app_name="contacts",
)
result_text = backend_result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
assert "Alice" in result_text