mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 04:54:17 +02:00
744 lines
29 KiB
Python
744 lines
29 KiB
Python
"""End-to-end round-trip tests for Prefab peer-tool references.
|
|
|
|
These simulate what a real host does: call the UI tool, extract the
|
|
hashed backend-tool name from structured_content, call back with
|
|
that name, and verify the backend tool actually executes. Covers
|
|
single-server, namespaced mounts, and cross-server mounts.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from fastmcp import FastMCP, FastMCPApp
|
|
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_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()
|
|
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
|
|
assert _tool_refs(result.structured_content) == ["save_contact"]
|
|
|
|
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
|
|
resolves when called back — the full round-trip works."""
|
|
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)
|
|
|
|
# Step 1: call UI tool, get structured_content with hashed ref
|
|
await server.call_tool("contact_form", {})
|
|
|
|
# Step 2: call the backend tool by its hashed name
|
|
hashed_name = hashed_backend_name("contacts", "save_contact")
|
|
backend_result = await server.call_tool(hashed_name, {"name": "Alice"})
|
|
assert backend_result.content[0].text == "saved Alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
|
|
|
|
|
class TestNamespacedMountRoundTrip:
|
|
async def test_namespaced_app_backend_tool_round_trip(self):
|
|
"""A FastMCPApp mounted with a namespace: the UI tool is called
|
|
by its namespaced display name, the backend tool is called by
|
|
its hashed name — both work."""
|
|
app = FastMCPApp("crm")
|
|
|
|
@app.tool()
|
|
def save(name: str) -> str:
|
|
return f"saved {name}"
|
|
|
|
@app.ui()
|
|
def form() -> Text:
|
|
return Text(content="Enter details")
|
|
|
|
server = FastMCP("Platform")
|
|
server.add_provider(app, namespace="crm")
|
|
|
|
# UI tool visible under namespace
|
|
result = await server.call_tool("crm_form", {})
|
|
assert result.structured_content is not None
|
|
|
|
# Backend tool reachable via hash
|
|
hashed_name = hashed_backend_name("crm", "save")
|
|
backend_result = await server.call_tool(hashed_name, {"name": "Bob"})
|
|
assert backend_result.content[0].text == "saved Bob" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
|
|
|
|
|
class TestMountedServerRoundTrip:
|
|
async def test_backend_tool_reachable_through_mounted_server(self):
|
|
"""A FastMCPApp inside a mounted FastMCP server: the outer
|
|
server's dispatcher walks through FastMCPProvider to find
|
|
the backend tool by hash."""
|
|
app = FastMCPApp("contacts")
|
|
|
|
@app.tool()
|
|
def save(name: str) -> str:
|
|
return f"saved {name}"
|
|
|
|
@app.ui()
|
|
def form() -> Text:
|
|
return Text(content="Form")
|
|
|
|
inner = FastMCP("Inner")
|
|
inner.add_provider(app)
|
|
|
|
outer = FastMCP("Outer")
|
|
outer.mount(inner, namespace="inner")
|
|
|
|
# Backend tool callable through the mount via hash dispatch
|
|
hashed_name = hashed_backend_name("contacts", "save")
|
|
result = await outer.call_tool(hashed_name, {"name": "Carol"})
|
|
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
|
|
are still reachable via their hashed name — get_tool_by_hash
|
|
does a live walk, not a cached lookup."""
|
|
app = FastMCPApp("contacts")
|
|
|
|
server = FastMCP("Platform")
|
|
server.add_provider(app)
|
|
|
|
# First call — nothing to call yet, just prime any caches.
|
|
tools = await server.list_tools()
|
|
assert len(tools) == 0
|
|
|
|
# Now add a backend tool dynamically.
|
|
@app.tool()
|
|
def save(name: str) -> str:
|
|
return f"saved {name}"
|
|
|
|
# The dynamically-added tool should be reachable.
|
|
hashed_name = hashed_backend_name("contacts", "save")
|
|
result = await server.call_tool(hashed_name, {"name": "Dan"})
|
|
assert result.content[0].text == "saved Dan" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
|
|
|
|
|
class TestCollision:
|
|
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")
|
|
|
|
@app_a.tool()
|
|
def save(name: str) -> str:
|
|
return f"from A: {name}"
|
|
|
|
@app_b.tool()
|
|
def save_b(name: str) -> str:
|
|
return f"from B: {name}"
|
|
|
|
server = FastMCP("Platform")
|
|
server.add_provider(app_a)
|
|
server.add_provider(app_b)
|
|
|
|
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]
|