mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 04:54:17 +02:00
Replaces the ___ separator for FastMCPApp backend tool routing with a deterministic hash(app_name, tool_name) prefix, and replaces the shared singleton prefab renderer resource with per-tool resources synthesized on demand. Backend tools are now callable via <hash>_<local_name> instead of <app_name>___<local_name>. The dispatcher walks the provider tree recursively via get_tool_by_hash (same pattern as get_app_tool). Each prefab tool gets its own renderer resource at ui://prefab/tool/<hash>/renderer.html with per-tool CSP — fixing the bug where PrefabAppConfig(csp=...) never actually applied. Closes #3735, closes #3805
185 lines
6.8 KiB
Python
185 lines
6.8 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 json
|
|
|
|
import pytest
|
|
|
|
from fastmcp import FastMCP, FastMCPApp
|
|
from fastmcp.server.providers.addressing import hashed_backend_name
|
|
|
|
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
|
|
|
|
|
|
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."""
|
|
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
|
|
|
|
# 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_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 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_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."""
|
|
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}"
|
|
|
|
# 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"})
|
|
assert result.content[0].text == "from A: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|