mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Apply app visibility where no host can (#4692)
This commit is contained in:
parent
a8b5da9770
commit
81b1e818e5
6 changed files with 223 additions and 2 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
168
tests/server/transforms/test_model_visibility_boundary.py
Normal file
168
tests/server/transforms/test_model_visibility_boundary.py
Normal 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": {}})
|
||||
Loading…
Add table
Add a link
Reference in a new issue