From 773f6586e0646d5a565d43673786d1c88c50c8f5 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 27 Mar 2026 21:31:16 -0400 Subject: [PATCH] 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. --- .../apps/file_upload/file_upload_server.py | 199 ++++++++++++++++++ src/fastmcp/apps/app.py | 63 +++--- src/fastmcp/server/app.py | 2 +- src/fastmcp/server/mixins/mcp_operations.py | 9 +- src/fastmcp/server/providers/base.py | 10 +- .../server/providers/fastmcp_provider.py | 24 +-- src/fastmcp/server/server.py | 23 +- src/fastmcp/tools/base.py | 17 +- tests/test_fastmcp_app.py | 93 +++++--- 9 files changed, 329 insertions(+), 111 deletions(-) create mode 100644 examples/apps/file_upload/file_upload_server.py diff --git a/examples/apps/file_upload/file_upload_server.py b/examples/apps/file_upload/file_upload_server.py new file mode 100644 index 000000000..73e994cc6 --- /dev/null +++ b/examples/apps/file_upload/file_upload_server.py @@ -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() diff --git a/src/fastmcp/apps/app.py b/src/fastmcp/apps/app.py index eaba588df..f4fed7e15 100644 --- a/src/fastmcp/apps/app.py +++ b/src/fastmcp/apps/app.py @@ -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", {}) diff --git a/src/fastmcp/server/app.py b/src/fastmcp/server/app.py index d097117d4..ad15d59b1 100644 --- a/src/fastmcp/server/app.py +++ b/src/fastmcp/server/app.py @@ -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( diff --git a/src/fastmcp/server/mixins/mcp_operations.py b/src/fastmcp/server/mixins/mcp_operations.py index df001376a..70bd65607 100644 --- a/src/fastmcp/server/mixins/mcp_operations.py +++ b/src/fastmcp/server/mixins/mcp_operations.py @@ -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): diff --git a/src/fastmcp/server/providers/base.py b/src/fastmcp/server/providers/base.py index 2ac2e2643..5f6e59cdd 100644 --- a/src/fastmcp/server/providers/base.py +++ b/src/fastmcp/server/providers/base.py @@ -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. diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py index 953daf2b8..f13b241c1 100644 --- a/src/fastmcp/server/providers/fastmcp_provider.py +++ b/src/fastmcp/server/providers/fastmcp_provider.py @@ -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 diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index a08da16a8..bec4f3ad9 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -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()) diff --git a/src/fastmcp/tools/base.py b/src/fastmcp/tools/base.py index 852a9c6c9..0c6251ee4 100644 --- a/src/fastmcp/tools/base.py +++ b/src/fastmcp/tools/base.py @@ -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 diff --git a/tests/test_fastmcp_app.py b/tests/test_fastmcp_app.py index bc02cf8ae..8a49d659f 100644 --- a/tests/test_fastmcp_app.py +++ b/tests/test_fastmcp_app.py @@ -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