Apply app visibility where no host can (#4692)

This commit is contained in:
Jeremiah Lowin 2026-07-28 16:12:38 -04:00 committed by GitHub
commit 81b1e818e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 223 additions and 2 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": {}})