Late-bind app tool names so UIs survive composition (#4682)

This commit is contained in:
Jeremiah Lowin 2026-07-28 10:54:19 -04:00 committed by GitHub
commit a8b5da9770
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1184 additions and 165 deletions

View file

@ -141,7 +141,9 @@ class TestFileUploadProvider:
text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
assert "test.txt" in text
async def test_ui_tool_visible_backend_hidden(self):
async def test_backend_tool_listed_as_app_only(self):
"""``store_files`` is listed but declares visibility=["app"], so the
host keeps it out of the model's tool list."""
server = FastMCP("test", providers=[FileUpload()])
tools = await server.list_tools()
@ -150,7 +152,11 @@ class TestFileUploadProvider:
assert "file_manager" in tool_names
assert "list_files" in tool_names
assert "read_file" in tool_names
assert "store_files" not in tool_names
assert "store_files" in tool_names
store_files = next(t for t in tools if t.name == "store_files")
assert store_files.meta is not None
assert store_files.meta["ui"]["visibility"] == ["app"]
async def test_max_file_size_enforced_server_side(self):
server = FastMCP("test", providers=[FileUpload(max_file_size=100)])

View file

@ -8,22 +8,46 @@ single-server, namespaced mounts, and cross-server mounts.
from __future__ import annotations
import json
import pytest
from fastmcp import FastMCP, FastMCPApp
from fastmcp.server.providers.addressing import hashed_backend_name
from fastmcp.exceptions import ToolError
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.server.middleware.tool_injection import ToolInjectionMiddleware
from fastmcp.server.providers.addressing import hash_tool, hashed_backend_name
from fastmcp.server.providers.proxy import ProxyClient, ProxyProvider
from fastmcp.server.transforms.search import RegexSearchTransform
from fastmcp.server.transforms.tool_transform import ToolTransform
from fastmcp.tools.base import Tool
from fastmcp.tools.tool_transform import ToolTransformConfig
prefab_ui = pytest.importorskip("prefab_ui")
from prefab_ui.actions.mcp import CallTool # noqa: E402
from prefab_ui.components import Button, Column, Text # noqa: E402
def _tool_refs(payload) -> list[str]:
"""Every tool name the rendered UI would call, in document order."""
refs: list[str] = []
def walk(node) -> None:
if isinstance(node, dict):
if node.get("action") == "toolCall" and isinstance(node.get("tool"), str):
refs.append(node["tool"])
for value in node.values():
walk(value)
elif isinstance(node, list):
for item in node:
walk(item)
walk(payload)
return refs
class TestSingleServerRoundTrip:
async def test_ui_tool_serializes_hashed_peer_reference(self):
"""The resolver converts a CallTool string reference to a hashed
name that appears in the tool result's structured_content."""
async def test_payload_carries_the_servers_own_tool_name(self):
"""The renderer is handed a name that exists in this server's
tools/list, not the identity-addressed form."""
app = FastMCPApp("contacts")
@app.tool()
@ -41,13 +65,32 @@ class TestSingleServerRoundTrip:
result = await server.call_tool("contact_form", {})
assert result.structured_content is not None
assert _tool_refs(result.structured_content) == ["save_contact"]
# The hashed name should appear somewhere in the serialized output.
sc_json = json.dumps(result.structured_content)
expected_hash = hashed_backend_name("contacts", "save_contact")
assert expected_hash in sc_json, (
f"Expected {expected_hash!r} in structured_content but got: {sc_json[:200]}"
)
async def test_payload_records_the_identity_behind_each_reference(self):
"""The identity-addressed form survives alongside the rewritten name,
so an outer server can re-resolve it or fall back to it."""
app = FastMCPApp("contacts")
@app.tool()
def save_contact(name: str) -> str:
return f"saved {name}"
@app.ui()
def contact_form() -> Column:
return Column(
children=[Button(label="Save", on_click=CallTool(tool="save_contact"))]
)
server = FastMCP("Platform")
server.add_provider(app)
result = await server.call_tool("contact_form", {})
assert result.structured_content is not None
names = result.structured_content["_meta"]["fastmcp"]["toolNames"]
assert names == {
"save_contact": hashed_backend_name("contacts", "save_contact")
}
async def test_hashed_name_from_result_is_callable(self):
"""The hashed name that appears in structured_content actually
@ -131,6 +174,470 @@ class TestMountedServerRoundTrip:
assert result.content[0].text == "saved Carol" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
class TestProxiedServerRoundTrip:
"""A gateway proxying an app-bearing backend.
A proxy knows only what crossed the wire, so this is the topology that
breaks if app-only tools are filtered out of tools/list or if the
identity hash is stripped from meta.
"""
@staticmethod
def _backend() -> FastMCP:
app = FastMCPApp("contacts")
@app.tool()
def save(name: str) -> str:
return f"saved {name}"
@app.ui()
def form() -> Text:
return Text(content="Form")
backend = FastMCP("Backend")
backend.add_provider(app)
return backend
async def test_app_only_tool_is_forwarded_through_a_proxy(self):
backend = self._backend()
gateway = FastMCP("Gateway")
gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend)))
names = [t.name for t in await gateway.list_tools()]
assert "save" in names
async def test_identity_hash_survives_the_proxy(self):
backend = self._backend()
gateway = FastMCP("Gateway")
gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend)))
tool = next(t for t in await gateway.list_tools() if t.name == "save")
assert tool.meta is not None
assert tool.meta["fastmcp"]["tool_hash"] == hash_tool("contacts", "save")
async def test_backend_tool_callable_by_hash_through_a_proxy(self):
backend = self._backend()
gateway = FastMCP("Gateway")
gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend)))
hashed_name = hashed_backend_name("contacts", "save")
result = await gateway.call_tool(hashed_name, {"name": "Dana"})
assert result.content[0].text == "saved Dana" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
async def test_backend_tool_callable_through_a_namespaced_proxy(self):
backend = self._backend()
gateway = FastMCP("Gateway")
gateway.add_provider(
ProxyProvider(lambda: ProxyClient(backend)), namespace="up"
)
names = [t.name for t in await gateway.list_tools()]
assert "up_save" in names
hashed_name = hashed_backend_name("contacts", "save")
result = await gateway.call_tool(hashed_name, {"name": "Erin"})
assert result.content[0].text == "saved Erin" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
async def test_backend_tool_callable_through_chained_proxies(self):
backend = self._backend()
middle = FastMCP("Middle")
middle.add_provider(ProxyProvider(lambda: ProxyClient(backend)))
top = FastMCP("Top")
top.add_provider(ProxyProvider(lambda: ProxyClient(middle)))
hashed_name = hashed_backend_name("contacts", "save")
result = await top.call_tool(hashed_name, {"name": "Frank"})
assert result.content[0].text == "saved Frank" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
class TestLateBoundToolNames:
"""The payload is re-addressed on the way out of every FastMCP server.
Servers unwind innermost-first, so the outermost one rewrites last and its
names the only ones a client can invoke are what the renderer receives.
"""
@staticmethod
def _app(marker: str = "x", app_name: str = "contacts") -> FastMCPApp:
app = FastMCPApp(app_name)
@app.tool()
def save(name: str) -> str:
return f"[{marker}] saved {name}"
@app.ui()
def form() -> Column:
return Column(
children=[Button(label="Save", on_click=CallTool(tool="save"))]
)
return app
async def test_namespaced_server_emits_its_namespaced_name(self):
server = FastMCP("Platform")
server.add_provider(self._app(), namespace="crm")
result = await server.call_tool("crm_form", {})
assert _tool_refs(result.structured_content) == ["crm_save"]
async def test_name_accumulates_through_nested_mounts(self):
inner = FastMCP("Inner")
inner.add_provider(self._app(), namespace="a")
mid = FastMCP("Mid")
mid.add_provider(inner, namespace="b")
top = FastMCP("Top")
top.add_provider(mid, namespace="c")
result = await top.call_tool("c_b_a_form", {})
assert _tool_refs(result.structured_content) == ["c_b_a_save"]
async def test_gateway_emits_its_own_name_not_the_backends(self):
backend = FastMCP("Backend")
backend.add_provider(self._app())
gateway = FastMCP("Gateway")
gateway.add_provider(
ProxyProvider(lambda: ProxyClient(backend)), namespace="up"
)
result = await gateway.call_tool("up_form", {})
assert _tool_refs(result.structured_content) == ["up_save"]
async def test_emitted_name_is_callable_on_the_same_server(self):
"""The whole point: what the renderer is told to call, it can call."""
backend = FastMCP("Backend")
backend.add_provider(self._app(marker="be"))
gateway = FastMCP("Gateway")
gateway.add_provider(
ProxyProvider(lambda: ProxyClient(backend)), namespace="up"
)
result = await gateway.call_tool("up_form", {})
(ref,) = _tool_refs(result.structured_content)
assert ref in [t.name for t in await gateway.list_tools()]
clicked = await gateway.call_tool(ref, {"name": "alice"})
assert clicked.content[0].text == "[be] saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
@pytest.mark.parametrize(
"transform_factory,expected_listing",
[
(
lambda: RegexSearchTransform(),
["search_tools", "call_tool"],
),
(
lambda: CodeMode(),
["search", "get_schema", "execute"],
),
],
ids=["tool-search", "code-mode"],
)
async def test_survives_a_collapsed_catalog(
self, transform_factory, expected_listing
):
"""Tool search and code mode replace tools/list wholesale, so there is
no better name to bind to. The reference stays identity-addressed and
the hashed path still resolves it."""
server = FastMCP("Platform")
server.add_provider(self._app(marker="cat"))
server.add_transform(transform_factory())
assert [t.name for t in await server.list_tools()] == expected_listing
result = await server.call_tool("form", {})
(ref,) = _tool_refs(result.structured_content)
assert ref == hashed_backend_name("contacts", "save")
clicked = await server.call_tool(ref, {"name": "alice"})
assert clicked.content[0].text == "[cat] saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
@pytest.mark.parametrize(
"compose",
["siblings", "nested", "prefixing-namespaces"],
)
async def test_a_duplicated_app_is_not_bound(self, compose):
"""One app composed twice leaves no fact in the listing saying which
copy a UI belongs to, so no name is bound and the reference keeps its
identity. Covers copies as siblings, nested inside one subtree, and
under namespaces that prefix one another.
"""
if compose == "nested":
inner = FastMCP("Inner")
inner.add_provider(self._app(marker="A"), namespace="a")
inner.add_provider(self._app(marker="B"), namespace="b")
server = FastMCP("Top")
server.add_provider(inner, namespace="outer")
entry = "outer_a_form"
else:
second = "a_form" if compose == "prefixing-namespaces" else "b"
server = FastMCP("Top")
server.add_provider(self._app(marker="A"), namespace="a")
server.add_provider(self._app(marker="B"), namespace=second)
entry = "a_form"
result = await server.call_tool(entry, {})
(ref,) = _tool_refs(result.structured_content)
assert ref == hashed_backend_name("contacts", "save")
async def test_a_duplicated_app_reports_the_ambiguity(self):
"""The unbound reference must fail with a message that names the real
cause, at any depth a nested duplicate previously surfaced as
`Unknown tool`, sending readers after a missing registration.
"""
inner = FastMCP("Inner")
inner.add_provider(self._app(marker="A"), namespace="a")
inner.add_provider(self._app(marker="B"), namespace="b")
server = FastMCP("Top")
server.add_provider(inner, namespace="outer")
result = await server.call_tool("outer_a_form", {})
(ref,) = _tool_refs(result.structured_content)
with pytest.raises(ToolError, match="composed more than once"):
await server.call_tool(ref, {"name": "alice"})
@pytest.mark.parametrize("backend_namespace", [None, "crm"])
async def test_collapsed_catalog_over_a_proxy(self, backend_namespace):
"""The collapsed-catalog fallback has to survive a backend that
renamed its app tools. Nothing named `save` was ever listed across
the wire, so the identity has to resolve against the remote listing
rather than against a name that only exists at the origin.
"""
app = self._app(marker="be")
backend = FastMCP("Backend")
backend.add_provider(app, namespace=backend_namespace)
gateway = FastMCP("Gateway")
gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend)))
gateway.add_transform(RegexSearchTransform())
entry = f"{backend_namespace}_form" if backend_namespace else "form"
result = await gateway.call_tool(entry, {})
(ref,) = _tool_refs(result.structured_content)
assert ref == hashed_backend_name("contacts", "save")
clicked = await gateway.call_tool(ref, {"name": "alice"})
assert clicked.content[0].text == "[be] saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
async def test_versions_of_one_tool_are_a_single_target(self):
"""Versions are listed individually and share an identity, but they
also share a name that resolves to the highest version on its own.
Only distinct names mean distinct copies of an app.
"""
app = FastMCPApp("contacts")
for version, prefix in (("1.0.0", "v1"), ("2.0.0", "v2")):
def save(name: str, _prefix: str = prefix) -> str:
return f"{_prefix} saved {name}"
app.add_tool(Tool.from_function(save, name="save", version=version))
@app.ui()
def form() -> Column:
return Column(
children=[Button(label="Save", on_click=CallTool(tool="save"))]
)
server = FastMCP("Platform")
server.add_provider(app)
result = await server.call_tool("form", {})
(ref,) = _tool_refs(result.structured_content)
assert ref == "save"
clicked = await server.call_tool(ref, {"name": "alice"})
assert clicked.content[0].text == "v2 saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
async def test_distinct_apps_sharing_a_backend_name(self):
"""Identity and name must agree in both directions. Two apps can each
expose `save`: the identities differ and each has one candidate, but
the shared name resolves to only one of them.
"""
server = FastMCP("Platform")
for app_name, entry, marker in (
("crm", "crm_ui", "CRM"),
("billing", "billing_ui", "BILLING"),
):
app = FastMCPApp(app_name)
@app.tool()
def save(name: str, _marker: str = marker) -> str:
return f"[{_marker}] saved {name}"
@app.ui(entry)
def form() -> Column:
return Column(
children=[Button(label="Save", on_click=CallTool(tool="save"))]
)
server.add_provider(app)
result = await server.call_tool("billing_ui", {})
(ref,) = _tool_refs(result.structured_content)
assert ref == hashed_backend_name("billing", "save")
async def test_proxy_refuses_a_remote_that_duplicates_an_app(self):
"""A remote mounting one app twice sends back two tools claiming one
identity, and the proxy must refuse on the same terms a local
composition would rather than returning whichever came first.
"""
backend = FastMCP("Backend")
backend.add_provider(self._app(marker="A"), namespace="a")
backend.add_provider(self._app(marker="B"), namespace="b")
gateway = FastMCP("Gateway")
gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend)))
with pytest.raises(ToolError, match="composed more than once"):
await gateway.call_tool(
hashed_backend_name("contacts", "save"), {"name": "alice"}
)
async def test_middleware_owns_the_names_it_shadows(self):
"""Binding describes the listing a client will see, so it has to run
the middleware chain. An injected tool sharing a backend's name owns
that name at call time, and would be invisible to a listing taken
beneath middleware.
"""
app = FastMCPApp("contacts")
@app.tool()
def save(name: str) -> str:
return f"[APP] saved {name}"
@app.ui()
def form() -> Column:
return Column(
children=[Button(label="Save", on_click=CallTool(tool="save"))]
)
def injected(name: str) -> str:
return f"[INJECTED] saved {name}"
server = FastMCP("Platform")
server.add_provider(app)
server.add_middleware(
ToolInjectionMiddleware([Tool.from_function(injected, name="save")])
)
result = await server.call_tool("form", {})
(ref,) = _tool_refs(result.structured_content)
assert ref == hashed_backend_name("contacts", "save")
clicked = await server.call_tool(ref, {"name": "alice"})
assert clicked.content[0].text == "[APP] saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
async def test_middleware_produced_results_are_rebound(self):
"""Middleware can answer a call itself, and such a result never
reaches the core dispatch path so rebinding belongs above the
chain, not inside it.
"""
app = FastMCPApp("contacts")
@app.tool()
def save(name: str) -> str:
return f"saved {name}"
@app.ui()
def form() -> Column:
return Column(
children=[Button(label="Save", on_click=CallTool(tool="save"))]
)
server = FastMCP("Platform")
server.add_provider(app)
entry = await server.get_tool("form")
assert entry is not None
server.add_middleware(
ToolInjectionMiddleware([entry.model_copy(update={"name": "injected"})])
)
result = await server.call_tool("injected", {})
(ref,) = _tool_refs(result.structured_content)
assert ref == "save"
clicked = await server.call_tool(ref, {"name": "alice"})
assert clicked.content[0].text == "saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
async def test_a_transform_cannot_unwire_an_app_tool(self):
"""A meta override that keeps the identity but drops app visibility
leaves a tool that can be named yet no longer answers to its
identity which is the only address a collapsed catalog has.
"""
backend = FastMCP("Backend")
backend.add_provider(self._app(marker="be"))
backend.add_transform(
ToolTransform({"save": ToolTransformConfig(meta={"team": "crm"})})
)
transformed = next(t for t in await backend.list_tools() if t.name == "save")
assert transformed.meta is not None
assert transformed.meta["ui"]["visibility"] == ["app"]
assert transformed.meta["team"] == "crm"
gateway = FastMCP("Gateway")
gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend)))
gateway.add_transform(RegexSearchTransform())
result = await gateway.call_tool("form", {})
(ref,) = _tool_refs(result.structured_content)
assert ref == hashed_backend_name("contacts", "save")
clicked = await gateway.call_tool(ref, {"name": "alice"})
assert clicked.content[0].text == "[be] saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
async def test_duplicate_copies_are_not_collapsed_by_a_shared_name(self):
"""Copies whose backends collide on a name are the worst case, not the
safe one: two components become indistinguishable. Counting names
alone would see a single unambiguous target and bind to it.
"""
server = FastMCP("Platform")
for entry, marker in (("form_a", "A"), ("form_b", "B")):
app = FastMCPApp("contacts")
@app.tool()
def save(name: str, _marker: str = marker) -> str:
return f"[{_marker}] saved {name}"
@app.ui(entry)
def form() -> Column:
return Column(
children=[Button(label="Save", on_click=CallTool(tool="save"))]
)
server.add_provider(app)
listed = await server.list_tools()
assert [t.key for t in listed].count("tool:save@") == 2
result = await server.call_tool("form_b", {})
(ref,) = _tool_refs(result.structured_content)
assert ref == hashed_backend_name("contacts", "save")
async def test_unresolvable_identity_is_restored(self):
"""An inner server binds to a name that means nothing further out, so
a reference this server cannot resolve is restored to its identity
rather than left a stranded name has no route back, an identity does.
"""
app = FastMCPApp("contacts")
@app.ui()
def form() -> Column:
return Column(
children=[Button(label="Go", on_click=CallTool(tool="not_registered"))]
)
server = FastMCP("Platform")
server.add_provider(app)
result = await server.call_tool("form", {})
(ref,) = _tool_refs(result.structured_content)
assert ref == hashed_backend_name("contacts", "not_registered")
class TestDynamicToolAdd:
async def test_tool_added_after_first_call_is_reachable(self):
"""Tools added to an already-mounted app after the first call
@ -157,10 +664,9 @@ class TestDynamicToolAdd:
class TestCollision:
async def test_same_app_name_same_tool_name_first_wins(self):
"""Two apps with the same name and same tool name: the hash is
identical, so get_tool_by_hash returns the first match. This is
the same first-match behavior the old get_app_tool had."""
async def test_distinct_hashes_resolve_independently(self):
"""Two apps sharing a name but with different tool names hash
differently, so each tool resolves to itself."""
app_a = FastMCPApp("shared")
app_b = FastMCPApp("shared")
@ -172,14 +678,67 @@ class TestCollision:
def save_b(name: str) -> str:
return f"from B: {name}"
# Register under a different local tool name to avoid
# actual collision at the provider level. The hash collision
# only happens when both app name AND tool name match.
# This test just verifies one app's tool is reachable.
server = FastMCP("Platform")
server.add_provider(app_a)
server.add_provider(app_b)
hashed_name = hashed_backend_name("shared", "save")
result = await server.call_tool(hashed_name, {"name": "Eve"})
result = await server.call_tool(
hashed_backend_name("shared", "save"), {"name": "Eve"}
)
assert result.content[0].text == "from A: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
result_b = await server.call_tool(
hashed_backend_name("shared", "save_b"), {"name": "Eve"}
)
assert result_b.content[0].text == "from B: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
async def test_ambiguous_identity_raises_rather_than_guessing(self):
"""The same app composed into two branches yields two tools with one
identity. Routing to either would silently execute the wrong branch's
tool, so the call is refused."""
server = FastMCP("Platform")
for marker, namespace in (("A", "a"), ("B", "b")):
app = FastMCPApp("contacts")
@app.tool()
def save(name: str, _marker: str = marker) -> str:
return f"from {_marker}: {name}"
server.add_provider(app, namespace=namespace)
with pytest.raises(ToolError, match="Ambiguous app tool"):
await server.call_tool(
hashed_backend_name("contacts", "save"), {"name": "Eve"}
)
async def test_distinct_app_names_route_independently_through_a_gateway(self):
"""The multi-tenant gateway shape: distinct app names stay unambiguous
no matter how many backends sit behind one proxy."""
def backend(marker: str, app_name: str) -> FastMCP:
app = FastMCPApp(app_name)
@app.tool()
def save(name: str) -> str:
return f"from {marker}: {name}"
server = FastMCP(f"Backend-{marker}")
server.add_provider(app)
return server
first = backend("A", "crm")
second = backend("B", "billing")
gateway = FastMCP("Gateway")
gateway.add_provider(ProxyProvider(lambda: ProxyClient(first)), namespace="a")
gateway.add_provider(ProxyProvider(lambda: ProxyClient(second)), namespace="b")
result_a = await gateway.call_tool(
hashed_backend_name("crm", "save"), {"name": "Eve"}
)
assert result_a.content[0].text == "from A: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
result_b = await gateway.call_tool(
hashed_backend_name("billing", "save"), {"name": "Eve"}
)
assert result_b.content[0].text == "from B: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]

View file

@ -22,6 +22,7 @@ from fastmcp.apps.app import (
FastMCPApp,
_make_resolver,
)
from fastmcp.server.providers.addressing import hash_tool, hashed_backend_name
from fastmcp.tools.base import Tool
# ---------------------------------------------------------------------------
@ -579,13 +580,13 @@ class TestCallToolAppRouting:
# ---------------------------------------------------------------------------
# App-only tool filtering from server list_tools / get_tool
# App-only tool visibility: declared in meta, listed on the wire
# ---------------------------------------------------------------------------
class TestAppOnlyToolFiltering:
async def test_app_only_tool_hidden_from_list_tools(self):
"""@app.tool() (visibility=["app"]) should not appear in server.list_tools()."""
class TestAppOnlyToolVisibility:
async def test_app_only_tool_appears_in_list_tools(self):
"""@app.tool() (visibility=["app"]) is listed; the host filters it out."""
app = FastMCPApp("crm")
@app.tool()
@ -597,7 +598,40 @@ class TestAppOnlyToolFiltering:
tools = await server.list_tools()
names = [t.name for t in tools]
assert "save_contact" not in names
assert "save_contact" in names
async def test_app_only_tool_declares_app_visibility(self):
"""The listed tool carries visibility=["app"] so a host can filter it."""
app = FastMCPApp("crm")
@app.tool()
def save_contact(name: str) -> str:
return name
server = FastMCP("Platform")
server.add_provider(app)
tool = next(t for t in await server.list_tools() if t.name == "save_contact")
assert tool.meta is not None
assert tool.meta["ui"]["visibility"] == ["app"]
async def test_app_only_tool_visibility_survives_the_wire(self):
"""A client sees the visibility declaration, which is what it filters on."""
app = FastMCPApp("crm")
@app.tool()
def save_contact(name: str) -> str:
return name
server = FastMCP("Platform")
server.add_provider(app)
async with Client(server) as client:
tool = next(
t for t in await client.list_tools() if t.name == "save_contact"
)
assert tool.meta is not None
assert tool.meta["ui"]["visibility"] == ["app"]
async def test_model_visible_tool_in_list_tools(self):
"""@app.tool(model=True) (visibility=["app","model"]) appears in list_tools."""
@ -629,8 +663,8 @@ class TestAppOnlyToolFiltering:
names = [t.name for t in tools]
assert "show_dashboard" in names
async def test_app_only_tool_still_callable_via_app_name(self):
"""Even though filtered from list_tools, app-only tools are callable via call_tool with app_name."""
async def test_app_only_tool_callable_via_hashed_address(self):
"""The hashed address still resolves, independent of the display name."""
app = FastMCPApp("contacts")
@app.tool()
@ -640,35 +674,30 @@ class TestAppOnlyToolFiltering:
server = FastMCP("Platform")
server.add_provider(app)
# Verify it's hidden from list_tools
tools = await server.list_tools()
names = [t.name for t in tools]
assert "save" not in names
# But still callable via the hashed-address routing path.
from fastmcp.server.providers.addressing import hashed_backend_name
result = await server.call_tool(
hashed_backend_name("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):
"""server.get_tool() returns None for app-only tools."""
app = FastMCPApp("crm")
async def test_app_only_tool_callable_by_display_name(self):
"""App-only tools resolve normally; the host decides who may call them."""
app = FastMCPApp("contacts")
@app.tool()
def save_contact(name: str) -> str:
return name
def save(name: str) -> str:
return f"saved {name}"
server = FastMCP("Platform")
server.add_provider(app)
tool = await server.get_tool("save_contact")
assert tool is None
tool = await server.get_tool("save")
assert tool is not None
async def test_app_only_tool_hidden_with_namespace(self):
"""App-only tools hidden even when accessed through a namespace."""
result = await server.call_tool("save", {"name": "alice"})
assert result.content[0].text == "saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
async def test_app_only_tool_namespaced_in_list_tools(self):
"""Namespacing renames app-only tools like any other tool."""
app = FastMCPApp("crm")
@app.tool()
@ -680,7 +709,23 @@ class TestAppOnlyToolFiltering:
tools = await server.list_tools()
names = [t.name for t in tools]
assert "crm_save" not in names
assert "crm_save" in names
async def test_app_only_tool_carries_public_hash(self):
"""The identity hash is public meta, so intermediaries can match on it."""
app = FastMCPApp("crm")
@app.tool()
def save(name: str) -> str:
return name
server = FastMCP("Platform")
server.add_provider(app, namespace="crm")
async with Client(server) as client:
tool = next(t for t in await client.list_tools() if t.name == "crm_save")
assert tool.meta is not None
assert tool.meta["fastmcp"]["tool_hash"] == hash_tool("crm", "save")
# ---------------------------------------------------------------------------
@ -872,21 +917,25 @@ class TestAppIntegration:
server = FastMCP("Platform")
server.add_provider(app, namespace="crm")
# The @app.ui() tool should be visible (namespaced) to the client.
# The @app.tool() backend tool should NOT appear.
# Both tools are listed (namespaced). The backend tool declares
# visibility=["app"] so the host keeps it out of the model's list.
async with Client(server) as client:
tools = await client.list_tools()
tool_names = [t.name for t in tools]
assert "crm_contact_form" in tool_names
assert "crm_save_contact" not in tool_names
assert "crm_save_contact" in tool_names
backend = next(t for t in tools if t.name == "crm_save_contact")
assert backend.meta is not None
assert backend.meta["ui"]["visibility"] == ["app"]
# Call the UI tool through the client and check structured_content
result = await client.call_tool_mcp("crm_contact_form", {})
sc = result.structured_content
assert sc is not None
# Call the backend tool via its hashed address — bypasses namespace
# transforms and visibility filtering by going through the registry.
# Call the backend tool via its hashed address — resolves regardless
# of the namespace transform applied to the display name.
backend_result = await server.call_tool(
hashed_backend_name("contacts", "save_contact"),
{"name": "Alice", "email": "alice@example.com"},

View file

@ -172,6 +172,45 @@ def test_tool_transform_config_removes_meta(sample_tool):
assert transformed.meta is None
def test_meta_override_preserves_fastmcp_namespace(sample_tool):
"""A meta override replaces caller meta but keeps framework-owned data.
The fastmcp namespace carries app membership and the identity hash that
intermediaries match on. A rename via config must not destroy it.
"""
sample_tool.meta = {"original": True, "fastmcp": {"app": "crm", "tool_hash": "abc"}}
transformed = Tool.from_tool(sample_tool, meta={"custom": True})
assert transformed.meta == {
"custom": True,
"fastmcp": {"app": "crm", "tool_hash": "abc"},
}
def test_meta_none_preserves_fastmcp_namespace(sample_tool):
"""Clearing meta clears caller meta, not the framework namespace."""
sample_tool.meta = {"original": True, "fastmcp": {"app": "crm", "tool_hash": "abc"}}
transformed = Tool.from_tool(sample_tool, meta=None)
assert transformed.meta == {"fastmcp": {"app": "crm", "tool_hash": "abc"}}
def test_meta_override_can_extend_fastmcp_namespace(sample_tool):
"""An override may add to the fastmcp namespace without dropping its keys."""
sample_tool.meta = {"fastmcp": {"app": "crm", "tool_hash": "abc"}}
transformed = Tool.from_tool(sample_tool, meta={"fastmcp": {"extra": 1}})
assert transformed.meta == {
"fastmcp": {"app": "crm", "tool_hash": "abc", "extra": 1}
}
def test_config_meta_override_preserves_identity_hash(sample_tool):
"""The fastmcp.json `tools:` path goes through the same preservation."""
sample_tool.meta = {"fastmcp": {"app": "crm", "tool_hash": "abc"}}
config = ToolTransformConfig(name="renamed", meta={"team": "growth"})
transformed = config.apply(sample_tool)
assert transformed.meta is not None
assert transformed.meta["fastmcp"]["tool_hash"] == "abc"
# Enabled field tests
def test_tool_transform_config_enabled_defaults_to_true(sample_tool):
"""Test that enabled defaults to True and no visibility metadata is set."""