mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
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:
parent
3e1aadb282
commit
773f6586e0
9 changed files with 329 additions and 111 deletions
199
examples/apps/file_upload/file_upload_server.py
Normal file
199
examples/apps/file_upload/file_upload_server.py
Normal 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()
|
||||
|
|
@ -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", {})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue