diff --git a/docs/apps/low-level.mdx b/docs/apps/low-level.mdx index fc07e5b8d..46de3e03d 100644 --- a/docs/apps/low-level.mdx +++ b/docs/apps/low-level.mdx @@ -75,7 +75,9 @@ The `visibility` field controls where a tool appears: This is useful when you have tools that only make sense as part of the app's interactive flow, not as standalone LLM actions. -Visibility is a declaration, not server-side filtering. Every tool appears in `tools/list` carrying its `visibility` metadata, and the host decides what to show the model — the division the MCP Apps specification defines. Listing them is also what lets a proxy or gateway forward them: an intermediary can only route to a tool it can see. +Visibility is a declaration, and on `tools/list` the host does the filtering — the division the MCP Apps specification defines. Every tool is advertised carrying its `visibility` metadata, which is also what lets a proxy or gateway forward it: an intermediary can only route to a tool it can see. + +That division assumes a host stands between the server and the model. Where one doesn't, FastMCP applies the declaration itself. [Tool search](/servers/transforms/tool-search) and code mode reach the model as ordinary tool output rather than as an advertised listing, and their call-tool proxies execute a name the model supplies — nothing downstream can filter either, so app-only tools are excluded from both. The app's own UI still reaches its backends, because a UI calling by identity is not the model. ```python @mcp.tool( diff --git a/docs/servers/transforms/tool-search.mdx b/docs/servers/transforms/tool-search.mdx index 204004f5c..c3f44a3cf 100644 --- a/docs/servers/transforms/tool-search.mdx +++ b/docs/servers/transforms/tool-search.mdx @@ -153,6 +153,8 @@ Tools discovered through search can also be called directly via `client.call_too Search results respect the full authorization pipeline. Tools filtered by middleware, visibility transforms, or component-level auth checks won't appear in search results. +App-only tools are excluded too. A [MCP app](/apps/overview) can declare backend tools that only its UI may call, and normally the host keeps those from the model. A search result is tool output rather than an advertised listing, so no host filtering applies to it — the exclusion happens here instead. The `call_tool` proxy enforces the same boundary, since it executes a name the model supplies. + The search tool queries `list_tools()` through the complete pipeline at search time, so the same filtering that controls what a client sees in the listing also controls what they can discover through search. ```python diff --git a/fastmcp_slim/fastmcp/apps/config.py b/fastmcp_slim/fastmcp/apps/config.py index 3d89aa382..1686d8626 100644 --- a/fastmcp_slim/fastmcp/apps/config.py +++ b/fastmcp_slim/fastmcp/apps/config.py @@ -11,6 +11,7 @@ from typing import Any, Literal from pydantic import BaseModel, Field +from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.mime import UI_MIME_TYPE as UI_MIME_TYPE from fastmcp.utilities.mime import resolve_ui_mime_type as resolve_ui_mime_type @@ -182,3 +183,31 @@ def app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any]: if isinstance(app, AppConfig): return app.model_dump(by_alias=True, exclude_none=True) return app + + +def is_model_visible(component: FastMCPComponent) -> bool: + """Whether a component may be shown to, or invoked by, the model. + + Visibility is a declaration, and the MCP Apps spec puts the filtering on + the host — so ``tools/list`` carries app-only tools and the host keeps + them from the model. That division only works where a host stands between + the server and the model. + + It does not hold for surfaces a server drives itself. A search result or + a code-mode catalog reaches the model as ordinary tool output, and a + call-tool proxy invokes on a name the model supplies; nothing downstream + can filter either. Those surfaces have to apply the declaration here. + + A component with no ``visibility`` is visible: the field marks the + exception, and the spec's default is both audiences. + """ + meta = component.meta + if not meta: + return True + ui_meta = meta.get("ui") + if not isinstance(ui_meta, dict): + return True + visibility = ui_meta.get("visibility") + if not isinstance(visibility, list): + return True + return "model" in visibility diff --git a/fastmcp_slim/fastmcp/server/transforms/catalog.py b/fastmcp_slim/fastmcp/server/transforms/catalog.py index 936fcd9b3..e1ea30486 100644 --- a/fastmcp_slim/fastmcp/server/transforms/catalog.py +++ b/fastmcp_slim/fastmcp/server/transforms/catalog.py @@ -49,6 +49,7 @@ from collections.abc import Sequence from contextvars import ContextVar from typing import TYPE_CHECKING +from fastmcp.apps.config import is_model_visible from fastmcp.server.transforms import Transform from fastmcp.utilities.versions import dedupe_with_versions @@ -177,6 +178,16 @@ class CatalogTransform(Transform): of each tool is returned — matching what protocol handlers expose on the wire. + Tools the model may not see are excluded. A catalog is read by the + model as tool output rather than advertised as ``tools/list``, so the + host filtering the spec relies on never applies to it — this is the + only place the declaration can be enforced. + + Visibility is checked after deduplication, on the version a bare name + actually reaches. Checking first would let a model-visible older + version advertise a name whose highest version is app-only, and the + call would run the version nobody was shown. + Args: ctx: The current request context. run_middleware: Whether to run middleware on the inner call. @@ -188,7 +199,8 @@ class CatalogTransform(Transform): tools = await ctx.fastmcp.list_tools(run_middleware=run_middleware) finally: self._bypass.reset(token) - return dedupe_with_versions(tools, lambda t: t.name) + selected = dedupe_with_versions(tools, lambda t: t.name) + return [tool for tool in selected if is_model_visible(tool)] async def get_resource_catalog( self, ctx: Context, *, run_middleware: bool = True diff --git a/fastmcp_slim/fastmcp/server/transforms/search/base.py b/fastmcp_slim/fastmcp/server/transforms/search/base.py index cfeac6909..cdb47680b 100644 --- a/fastmcp_slim/fastmcp/server/transforms/search/base.py +++ b/fastmcp_slim/fastmcp/server/transforms/search/base.py @@ -31,6 +31,7 @@ from abc import abstractmethod from collections.abc import Awaitable, Callable, Sequence from typing import Annotated, Any +from fastmcp.exceptions import NotFoundError from fastmcp.server.context import Context from fastmcp.server.transforms import GetToolNext from fastmcp.server.transforms.catalog import CatalogTransform @@ -240,6 +241,13 @@ class BaseSearchTransform(CatalogTransform): raise ValueError( f"'{name}' is a synthetic search tool and cannot be called via the call_tool proxy" ) + # The name comes from the model, so this proxy is a second way + # into the server that no host mediates. It may reach only what + # the model was allowed to discover. + if not any( + tool.name == name for tool in await transform.get_tool_catalog(ctx) + ): + raise NotFoundError(f"Unknown tool: {name!r}") return await ctx.fastmcp.call_tool(name, arguments) return Tool.from_function(fn=call_tool, name=self._call_tool_name) diff --git a/tests/server/transforms/test_model_visibility_boundary.py b/tests/server/transforms/test_model_visibility_boundary.py new file mode 100644 index 000000000..f1d98b67d --- /dev/null +++ b/tests/server/transforms/test_model_visibility_boundary.py @@ -0,0 +1,168 @@ +"""App-only tools must not reach the model through server-driven surfaces. + +`tools/list` carries app-only tools on purpose — intermediaries need them to +forward, and the MCP Apps spec puts visibility filtering on the host. That +division holds only where a host sits between the server and the model. + +A search result, a code-mode catalog, and a call-tool proxy are all driven by +the server itself: the first two reach the model as ordinary tool output, and +the third invokes on a name the model supplies. No host mediates any of them, +so the visibility declaration has to be applied server-side. +""" + +from __future__ import annotations + +import json + +import pytest + +from fastmcp import Client, FastMCP, FastMCPApp +from fastmcp.exceptions import ToolError +from fastmcp.experimental.transforms.code_mode import CodeMode +from fastmcp.server.providers.addressing import hashed_backend_name +from fastmcp.server.transforms.search import BM25SearchTransform, RegexSearchTransform +from fastmcp.tools.base import Tool + + +def build_server_without_transform() -> FastMCP: + return _build(None) + + +def build_server(transform) -> FastMCP: + return _build(transform) + + +def _build(transform) -> FastMCP: + app = FastMCPApp("contacts") + + @app.tool() + def save_contact(name: str) -> str: + """UI-only backend that writes a contact.""" + return f"saved {name}" + + @app.tool(model=True) + def search_contacts(query: str) -> str: + """Model-visible backend.""" + return f"found {query}" + + @app.ui() + def contacts_ui() -> str: + return "ui" + + server = FastMCP("Platform") + server.add_provider(app) + if transform is not None: + server.add_transform(transform) + return server + + +CATALOG_TRANSFORMS = [ + pytest.param(RegexSearchTransform, id="regex-search"), + pytest.param(BM25SearchTransform, id="bm25-search"), + pytest.param(CodeMode, id="code-mode"), +] + + +@pytest.mark.parametrize("transform_cls", CATALOG_TRANSFORMS) +async def test_app_only_tools_stay_out_of_model_catalogs(transform_cls): + """Discovery surfaces hand tool definitions straight to the model.""" + server = build_server(transform_cls()) + + async with Client(server) as client: + blob = "" + for tool in await client.list_tools(): + if "search" not in tool.name: + continue + # Each transform names its search argument differently; the + # schema is the authority. + (argument,) = (tool.input_schema or {}).get("required", ["query"]) + result = await client.call_tool(tool.name, {argument: "search_contacts"}) + blob += json.dumps(result.structured_content or "") + blob += "".join( + block.text for block in result.content if hasattr(block, "text") + ) + + assert blob, "no search surface produced output" + assert "save_contact" not in blob + assert "search_contacts" in blob + + +async def test_app_only_tools_are_listed_for_forwarding(): + """The wire listing keeps them: a proxy cannot forward what it cannot see. + + Only the model-facing catalog is filtered, so a server without a catalog + transform still advertises the tool and its declaration for a host to + act on. + """ + plain = build_server_without_transform() + + async with Client(plain) as client: + listed = {tool.name: tool for tool in await client.list_tools()} + + assert "save_contact" in listed + assert listed["save_contact"].meta is not None + assert listed["save_contact"].meta["ui"]["visibility"] == ["app"] + + +async def test_call_tool_proxy_refuses_undiscoverable_tools(): + """The proxy takes a model-supplied name, so it is a second door in.""" + server = build_server(RegexSearchTransform()) + + async with Client(server) as client: + with pytest.raises(ToolError, match="save_contact"): + await client.call_tool( + "call_tool", + {"name": "save_contact", "arguments": {"name": "eve"}}, + ) + + allowed = await client.call_tool( + "call_tool", + {"name": "search_contacts", "arguments": {"query": "ada"}}, + ) + assert allowed.content[0].text == "found ada" # type: ignore[union-attr] + + +@pytest.mark.parametrize("transform_cls", CATALOG_TRANSFORMS) +async def test_the_apps_own_ui_still_reaches_its_backend(transform_cls): + """The point of the boundary is the audience, not the tool: a UI calling + by identity is not the model, and must still work. + """ + server = build_server(transform_cls()) + + result = await server.call_tool( + hashed_backend_name("contacts", "save_contact"), {"name": "ada"} + ) + assert result.content[0].text == "saved ada" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] + + +async def test_visibility_is_checked_on_the_version_a_name_reaches(): + """A bare name selects the highest version, so that is the one whose + declaration governs. Checking before deduplication would advertise a + model-visible older version whose name runs an app-only newer one. + """ + + def versioned(version: str, visibility: list[str], marker: str) -> Tool: + def same() -> str: + return f"ran {marker}" + + return Tool.from_function( + same, name="same", version=version, meta={"ui": {"visibility": visibility}} + ) + + app = FastMCPApp("contacts") + app.add_tool(versioned("1.0.0", ["app", "model"], "v1")) + app.add_tool(versioned("2.0.0", ["app"], "v2")) + + server = FastMCP("Platform") + server.add_provider(app) + server.add_transform(RegexSearchTransform()) + + async with Client(server) as client: + found = await client.call_tool("search_tools", {"pattern": "same"}) + blob = json.dumps(found.structured_content or "") + "".join( + block.text for block in found.content if hasattr(block, "text") + ) + assert "same" not in blob + + with pytest.raises(ToolError, match="same"): + await client.call_tool("call_tool", {"name": "same", "arguments": {}})