mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Late-bind app tool names so UIs survive composition (#4682)
This commit is contained in:
parent
0175bc9235
commit
a8b5da9770
17 changed files with 1184 additions and 165 deletions
|
|
@ -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)])
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue