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

@ -61,17 +61,43 @@ The final tool result has two parts: `content` (a list of `TextContent` blocks f
## Tool call routing
Normal tool calls go through the provider chain, which applies transforms (namespace prefixes, visibility filters) before resolving by name. App UI calls need a different path.
A tool has two things that behave very differently. Its **name** is unstable by design — namespace transforms rename it, so `save_contact` becomes `contacts_save_contact` in one composition and something else in another. Its **identity** is a hash of the app name and the registered tool name, written once at registration and never changed.
### The hashed lookup bypass
A UI is serialized during the entry tool's call, deep inside whatever composition the server happens to have, so it cannot know what its backend tools will be called by the time the payload reaches a host.
Backend tools are typically hidden from the model (`visibility=["app"]`). Visibility transforms would filter them out of normal resolution. And namespace transforms might rename them — `save_contact` becomes `contacts_save_contact` — while the renderer needs a stable way to call the original backend.
### Late-bound tool names
Hashed lookup solves both problems. FastMCP first tries normal tool resolution. If no visible tool matches and the requested name looks like `<hash>_<local_name>`, FastMCP calls `get_tool_by_hash(hash, local_name)`. That lookup walks the provider tree directly, skipping transforms. It finds an app-visible tool by its original registered name and verifies that its stored `meta["fastmcp"]["_tool_hash"]` matches the requested hash.
The payload leaves the app addressed by identity, and every FastMCP server rewrites those references on the way out to whatever it lists that tool as. Servers unwind innermost-first, so the outermost server rewrites last — and its names are the only ones a client can actually invoke.
That's why `CallTool(save_contact)` keeps working when the server is mounted under a namespace. The renderer sends a deterministic hashed backend name; the server uses `get_tool_by_hash` to find the original tool without transforms in the way.
Rewriting a name in place would destroy the identity for the next layer up, so the payload carries a name-to-identity map under `_meta.fastmcp.toolNames`. Each layer resolves through the map and updates it. The action objects keep the exact shape `prefab_ui` defines: only the value of `tool` changes, and only ever to another valid tool name.
Authorization still applies. The hashed bypass skips name and visibility transforms, but auth checks still run against the tool's `auth` config before execution.
The result is that a renderer receives names that exist in the listing the host is looking at. Under three layers of namespacing the button calls `c_b_a_save`; behind a gateway it calls whatever the gateway lists. No intermediary has to understand a FastMCP-specific convention.
A reference this server cannot resolve is left alone rather than corrupted. This is what keeps apps working behind [tool search](/servers/transforms/tool-search) and code mode, which replace `tools/list` with a handful of synthetic tools: there is no better name to bind to, so the reference stays identity-addressed and the fallback below carries it.
### One copy of an app per server
**An app name must be unique within a server.** Composing the same app twice breaks its UI, and no namespace or mount arrangement makes it work.
The reason is structural. Identity is derived from the app name and the tool's registered name, and deliberately nothing else — that is what makes it survive renaming. Two copies of one app therefore produce two tools claiming a single identity, and no fact anywhere in the listing says which copy a given button belongs to. The information needed to choose was never recorded.
FastMCP declines to bind rather than picking a copy, so buttons stop working instead of quietly invoking the wrong tenant's tool. Expect a message naming the cause:
```
Ambiguous app tool 'save': 2 components share the identity '10c0803009ff'.
The same app is composed more than once, so this call cannot be routed to a
single tool.
```
Give each copy its own app name. Two tenants running the same product want `FastMCPApp("contacts-acme")` and `FastMCPApp("contacts-globex")` — not two instances of `FastMCPApp("contacts")` under different namespaces, since namespaces rename tools and identity is immune to renaming by design.
### The hashed lookup fallback
The identity-addressed form `<hash>_<local_name>` remains callable. FastMCP first tries normal tool resolution; if no tool matches and the name has that shape, it calls `get_tool_by_hash(hash, local_name)`, which walks the provider tree directly, skipping transforms.
When one identity is claimed by more than one tool — which happens when the same app is composed into two branches — the call is refused rather than resolved, since picking either one would silently route into the wrong branch.
Authorization still applies. The hashed path skips name and visibility transforms, but auth checks still run against the tool's `auth` config before execution.
### Provider delegation

View file

@ -89,7 +89,11 @@ A fair question. Any [Interactive Tool](/apps/prefab) can call a server tool —
- What happens to `CallTool("add_note")` when you mount this server under a namespace and the tool becomes `notes_add_note`?
- How do you keep it all wired correctly as you compose servers?
`FastMCPApp` owns these concerns. Entry points register as model-visible. Backend tools register as UI-only by default. Backend tools get globally stable identifiers that survive namespacing, and `CallTool` accepts function references, so references stay valid when you compose servers.
`FastMCPApp` owns these concerns. Entry points register as model-visible, backend tools register as UI-only, and hosts act on those declarations to decide what the model sees.
Composition is handled by never writing the name down. `CallTool` takes a function reference, and FastMCP resolves it when the UI is serialized — to whatever that tool is actually called by then. Mount the server under a namespace and the button calls `notes_add_note`; put a gateway in front and it calls whatever the gateway lists. Since you never wrote a name, renaming cannot break it. [The architecture page](/apps/architecture) covers how that resolution works.
The one rule that comes with this: **an app name must be unique within a server.** Composing the same app twice breaks its UI — two copies of `FastMCPApp("notes")` are indistinguishable no matter what namespaces you mount them under, so FastMCP declines to bind rather than picking one. Name apps for what they serve: `FastMCPApp("notes-acme")` and `FastMCPApp("notes-globex")`. [The architecture page](/apps/architecture) explains why identity works this way.
The rest of this page covers each piece in turn.

View file

@ -70,11 +70,13 @@ def my_tool() -> str:
The `visibility` field controls where a tool appears:
- `["model"]` — visible to the LLM (the default behavior)
- `["app"]` — only callable from within the app UI, hidden from the LLM
- `["app"]` — callable from within the app UI, kept out of the LLM's tool list
- `["model", "app"]` — both
This is useful when you have tools that only make sense as part of the app's interactive flow, not as standalone LLM actions.
Visibility is a declaration, not server-side filtering. Every tool appears in `tools/list` carrying its `visibility` metadata, and the host decides what to show the model — the division the MCP Apps specification defines. Listing them is also what lets a proxy or gateway forward them: an intermediary can only route to a tool it can see.
```python
@mcp.tool(
app=AppConfig(

View file

@ -54,16 +54,20 @@ F = TypeVar("F", bound=Callable[..., Any])
def _make_resolver(app_name: str | None = None) -> Any:
"""Create a CallTool resolver that prefixes tool names with a hash.
"""Create a CallTool resolver that addresses peer tools by identity.
Structurally identical to the old ``___`` resolver ``app_name`` is
the FastMCPApp's name, known at serialization time from the tool's
``meta["fastmcp"]["app"]`` tag. The only change is the wire format:
``<hash>_<local_name>`` instead of ``<app_name>___<local_name>``.
``app_name`` is the FastMCPApp's name, known at serialization time from
the tool's ``meta["fastmcp"]["app"]`` tag. Serialization happens deep
inside whatever composition the server has, so nothing here can know
what these tools will be *called* by the time the payload reaches a
host. References therefore start out identity-addressed, as
``<hash>_<local_name>``.
The dispatcher recognizes the hashed form and routes it via
``get_tool_by_hash`` which walks the provider tree recursively
same pattern as ``get_app_tool``.
Each FastMCP server rewrites those references on the way out to the
name it lists that tool under, so what a renderer finally receives is
an ordinary tool name (see ``server.providers.prefab_payload``). A
reference no server could resolve keeps this form, which the dispatcher
still routes via ``get_tool_by_hash``.
"""
from fastmcp.server.providers.addressing import (
hashed_backend_name,
@ -227,14 +231,17 @@ class FastMCPApp(Provider):
raise ValueError(f"Cannot determine tool name for {fn!r}")
from fastmcp.apps.config import AppConfig, app_config_to_meta_dict
from fastmcp.server.providers.addressing import hash_tool
from fastmcp.server.providers.addressing import (
TOOL_HASH_META_KEY,
hash_tool,
)
app_config = AppConfig(visibility=visibility)
meta: dict[str, Any] = {
"ui": app_config_to_meta_dict(app_config),
"fastmcp": {
"app": self.name,
"_tool_hash": hash_tool(self.name, resolved_name),
TOOL_HASH_META_KEY: hash_tool(self.name, resolved_name),
},
}
@ -318,7 +325,10 @@ class FastMCPApp(Provider):
def _register(fn: F, tool_name: str | None) -> F:
from fastmcp.apps.config import AppConfig, app_config_to_meta_dict
from fastmcp.server.providers.addressing import hash_tool
from fastmcp.server.providers.addressing import (
TOOL_HASH_META_KEY,
hash_tool,
)
from fastmcp.server.providers.local_provider.decorators.tools import (
PREFAB_RENDERER_URI,
)
@ -334,7 +344,7 @@ class FastMCPApp(Provider):
"ui": app_config_to_meta_dict(app_config),
"fastmcp": {
"app": self.name,
"_tool_hash": hash_tool(self.name, resolved),
TOOL_HASH_META_KEY: hash_tool(self.name, resolved),
},
}
@ -373,12 +383,15 @@ class FastMCPApp(Provider):
if not isinstance(tool, Tool):
tool = Tool._ensure_tool(tool)
from fastmcp.server.providers.addressing import hash_tool
from fastmcp.server.providers.addressing import (
TOOL_HASH_META_KEY,
hash_tool,
)
meta = dict(tool.meta) if tool.meta else {}
fm = meta.setdefault("fastmcp", {})
fm["app"] = self.name
fm["_tool_hash"] = hash_tool(self.name, tool.name)
fm[TOOL_HASH_META_KEY] = hash_tool(self.name, tool.name)
ui = meta.setdefault("ui", {})
if "visibility" not in ui:
ui["visibility"] = ["app"]

View file

@ -13,9 +13,16 @@ app name + tool name. The hash serves two purposes:
and ``read_resource`` synthesize these on demand from the tool's meta.
The hash is computed at registration time from ``(app_name, tool_name)``
both known at that moment and stored in ``meta["fastmcp"]["_tool_hash"]``.
both known at that moment and stored in ``meta["fastmcp"]["tool_hash"]``.
Deterministic across replicas (same code same hash), no registry walk
needed.
The key is deliberately public. Keys prefixed with ``_`` inside the
``fastmcp`` meta namespace are stripped at every serialization boundary
(see ``FastMCPComponent.get_meta``) because they hold process-local state
such as enabled/disabled marks. The hash is the opposite: a stable
identity that intermediaries need in order to recognize a tool they are
forwarding, so it must survive the wire.
"""
from __future__ import annotations
@ -25,6 +32,9 @@ import hashlib
#: Length of the hex hash prefix used in URIs and backend-tool names.
HASH_LENGTH = 12
#: Key inside the ``fastmcp`` meta namespace holding a tool's identity hash.
TOOL_HASH_META_KEY = "tool_hash"
def hash_tool(app_name: str, tool_name: str) -> str:
"""Deterministic hex hash for a tool in an app.

View file

@ -25,7 +25,7 @@ from collections.abc import AsyncIterator, Sequence
from contextlib import AsyncExitStack, asynccontextmanager
from typing import TYPE_CHECKING, Literal, TypeVar
from fastmcp.exceptions import NotFoundError
from fastmcp.exceptions import NotFoundError, ToolError
from fastmcp.server.providers.base import Provider
from fastmcp.server.transforms import Namespace
from fastmcp.utilities.async_utils import gather
@ -221,19 +221,41 @@ class AggregateProvider(Provider):
return None
async def get_tool_by_hash(self, tool_hash: str, tool_name: str) -> Tool | None:
"""Query all child providers for a tool matching a hash."""
"""Query all child providers for a tool matching a hash.
The hash identifies a tool by app name and registered name, with no
mount-point component, so composing one app into two branches yields
two distinct tools claiming the same identity. That is ambiguous
rather than resolvable: picking either one silently routes a UI's
call into the wrong branch. Raise instead.
An ambiguity raised by a child is a verdict, not a provider failure,
so it propagates whatever the error strategy is. Swallowing it would
turn a duplicated app into "unknown tool", which sends whoever hits
it looking for a missing registration instead of a duplicate one.
"""
results = await gather(
(p.get_tool_by_hash(tool_hash, tool_name) for p in self.providers),
return_exceptions=True,
)
matches: list[Tool] = []
for r in results:
if isinstance(r, BaseException):
if self.provider_error_strategy == "raise":
if isinstance(r, ToolError) or self.provider_error_strategy == "raise":
raise r
continue
if r is not None:
return r
matches.append(r)
if not matches:
return None
if len(matches) > 1:
raise ToolError(
f"Ambiguous app tool {tool_name!r}: {len(matches)} components share "
f"the identity {tool_hash!r}. The same app is composed more than "
f"once, so this call cannot be routed to a single tool."
)
return matches[0]
# -------------------------------------------------------------------------
# Resources

View file

@ -214,9 +214,11 @@ class Provider:
"""Look up an app-visible tool by its deterministic hash.
Same recursive-walk semantics as ``get_app_tool`` but matches on
``meta["fastmcp"]["_tool_hash"]`` instead of the app name tag.
``meta["fastmcp"]["tool_hash"]`` instead of the app name tag.
Used by the dispatcher when receiving hashed backend-tool calls.
"""
from fastmcp.server.providers.addressing import TOOL_HASH_META_KEY
tool = await self._get_tool(tool_name)
if tool is not None:
meta = tool.meta or {}
@ -227,7 +229,7 @@ class Provider:
)
if (
isinstance(fastmcp_meta, dict)
and fastmcp_meta.get("_tool_hash") == tool_hash
and fastmcp_meta.get(TOOL_HASH_META_KEY) == tool_hash
and "app" in visibility
):
return tool

View file

@ -0,0 +1,158 @@
"""Late-bound tool names in Prefab UI payloads.
A Prefab UI is serialized during the entry tool's call, deep inside whatever
composition the server happens to have. At that moment nothing knows what the
backend tools will be *called* by the time the payload reaches a host: every
layer above may rename them, and the outermost layer's names are the only ones
a client can actually invoke.
So the payload leaves the app addressed by identity ``<hash>_<local_name>``,
stable everywhere and every FastMCP server rewrites those references on the
way out to whatever it lists that tool as. Servers rewrite innermost-first, so
the edge writes last and wins.
Rewriting a name in place would destroy the identity for the next layer up, so
the payload carries a name-to-identity map under ``_meta.fastmcp.toolNames``.
Each layer resolves through the map and updates it. The action objects keep the
exact shape ``prefab_ui`` defines only the value of ``tool`` changes, and only
ever to another valid tool name.
Renderers read ``_meta`` already and ignore keys they don't recognize, so this
needs no renderer change.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from fastmcp.server.providers.addressing import parse_hashed_backend_name
#: Action discriminator emitted by ``prefab_ui``'s ``CallTool``.
_TOOL_CALL_ACTION = "toolCall"
_META_KEY = "_meta"
_FASTMCP_KEY = "fastmcp"
_TOOL_NAMES_KEY = "toolNames"
#: Resolves an identity hash to the name this server lists that tool under.
#: Returns None when the identity cannot be resolved here, in which case the
#: existing reference is left alone.
IdentityResolver = Callable[[str], str | None]
def _walk_tool_calls(node: Any) -> list[dict[str, Any]]:
"""Collect every ``toolCall`` action object in a payload tree."""
found: list[dict[str, Any]] = []
if isinstance(node, dict):
if node.get("action") == _TOOL_CALL_ACTION and isinstance(
node.get("tool"), str
):
found.append(node)
for value in node.values():
found.extend(_walk_tool_calls(value))
elif isinstance(node, list):
for item in node:
found.extend(_walk_tool_calls(item))
return found
def _read_map(payload: dict[str, Any]) -> dict[str, str]:
meta = payload.get(_META_KEY)
if not isinstance(meta, dict):
return {}
fastmcp_meta = meta.get(_FASTMCP_KEY)
if not isinstance(fastmcp_meta, dict):
return {}
names = fastmcp_meta.get(_TOOL_NAMES_KEY)
if not isinstance(names, dict):
return {}
return {k: v for k, v in names.items() if isinstance(k, str) and isinstance(v, str)}
def _write_map(payload: dict[str, Any], names: dict[str, str]) -> None:
meta = payload.setdefault(_META_KEY, {})
if not isinstance(meta, dict):
return
fastmcp_meta = meta.setdefault(_FASTMCP_KEY, {})
if not isinstance(fastmcp_meta, dict):
return
fastmcp_meta[_TOOL_NAMES_KEY] = names
def payload_has_identities(payload: Any) -> bool:
"""Cheap guard: does this payload carry tool references worth rewriting?
Runs on every tool result, so it must not walk the tree.
"""
return isinstance(payload, dict) and bool(_read_map(payload))
def annotate_payload_identities(payload: dict[str, Any]) -> dict[str, Any]:
"""Record the identity-addressed form of each reference, at serialization.
References start out as ``<hash>_<local_name>``, so the map begins as an
identity map to itself. Once a later layer rewrites a name, this is the
only remaining route back: it carries both what the reference points at
and the address any server can fall back to.
"""
if not isinstance(payload, dict):
return payload
addresses: dict[str, str] = dict(_read_map(payload))
for action in _walk_tool_calls(payload):
tool_name = action["tool"]
if tool_name in addresses:
continue
if parse_hashed_backend_name(tool_name) is not None:
addresses[tool_name] = tool_name
if addresses:
_write_map(payload, addresses)
return payload
def rewrite_payload_tool_names(
payload: Any,
resolve: IdentityResolver,
) -> Any:
"""Re-address a payload's tool references to this server's own names.
Mutates in place and returns the payload.
A reference this server cannot resolve is restored to its
identity-addressed form rather than left as-is. Leaving it would strand
whatever name an inner server chose a name that is correct there and
meaningless here and, unlike the identity form, a stranded name has no
route back. Restoring keeps the reference resolvable by the dispatcher,
or by any server further out with a better view.
"""
if not isinstance(payload, dict):
return payload
addresses = _read_map(payload)
if not addresses:
return payload
rebound: dict[str, str] = {}
for current_name, address in addresses.items():
parsed = parse_hashed_backend_name(address)
new_name = resolve(parsed[0]) if parsed is not None else None
if new_name is None:
new_name = address
if new_name != current_name:
rebound[current_name] = new_name
if not rebound:
return payload
for action in _walk_tool_calls(payload):
new_name = rebound.get(action["tool"])
if new_name is not None:
action["tool"] = new_name
_write_map(
payload,
{rebound.get(name, name): address for name, address in addresses.items()},
)
return payload

View file

@ -2,7 +2,7 @@
Tools marked as Prefab (via ``app=True``, ``PrefabAppConfig``, etc.) carry
a placeholder ``meta.ui.resourceUri`` and optionally a hash in
``meta.fastmcp._tool_hash``. This module synthesizes per-tool renderer
``meta.fastmcp.tool_hash``. This module synthesizes per-tool renderer
resources on demand at ``list_resources`` and ``read_resource`` time
without storing or materializing anything.
@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Any, cast
from fastmcp.server.providers.addressing import (
HASH_LENGTH,
TOOL_HASH_META_KEY,
hash_tool,
parse_hashed_resource_uri,
)
@ -48,7 +49,7 @@ def _get_tool_hash(tool: Tool) -> str | None:
meta = tool.meta or {}
fastmcp_meta = meta.get("fastmcp")
if isinstance(fastmcp_meta, dict):
h = fastmcp_meta.get("_tool_hash")
h = fastmcp_meta.get(TOOL_HASH_META_KEY)
if isinstance(h, str) and len(h) == HASH_LENGTH:
return h
# Fall back to computing from app name

View file

@ -39,7 +39,7 @@ from fastmcp.client.sampling import create_sampling_callback
from fastmcp.client.telemetry import client_span
from fastmcp.client.transports import ClientTransportT
from fastmcp.client.transports.base import TransportOptions
from fastmcp.exceptions import ResourceError
from fastmcp.exceptions import ResourceError, ToolError
from fastmcp.mcp_config import MCPConfig
from fastmcp.prompts import Message, Prompt, PromptResult
from fastmcp.prompts.base import InputRequiredPromptResult, PromptArgument
@ -856,6 +856,54 @@ class ProxyProvider(Provider):
return None
return max(matching, key=version_sort_key)
async def get_tool_by_hash(self, tool_hash: str, tool_name: str) -> Tool | None:
"""Resolve an identity against the remote listing.
The base implementation looks the tool up by its registered name,
which assumes the name survived to here. Across a proxy it need not:
a backend that mounts its app under a namespace advertises
``crm_save``, and nothing named ``save`` was ever listed. Matching on
the identity carried in meta is what the identity is for.
A remote that mounts one app twice sends back two tools claiming one
identity, exactly as a local composition would. That is refused here
on the same terms ``AggregateProvider`` refuses it, so a duplicated
app is caught wherever it is composed rather than only nearby.
"""
from fastmcp.server.providers.addressing import TOOL_HASH_META_KEY
cache = self._tools_cache
if cache is None or not cache.is_fresh(self._cache_ttl):
await self._list_tools()
cache = self._tools_cache
assert cache is not None
matches: list[Tool] = []
for tool in cache.items:
meta = tool.meta or {}
fastmcp_meta = meta.get("fastmcp")
ui_meta = meta.get("ui")
visibility = (
ui_meta.get("visibility", []) if isinstance(ui_meta, dict) else []
)
if (
isinstance(fastmcp_meta, dict)
and fastmcp_meta.get(TOOL_HASH_META_KEY) == tool_hash
and "app" in visibility
):
matches.append(tool)
if not matches:
return None
distinct = {tool.name for tool in matches}
if len(distinct) > 1:
raise ToolError(
f"Ambiguous app tool {tool_name!r}: {len(distinct)} components share "
f"the identity {tool_hash!r}. The same app is composed more than "
f"once, so this call cannot be routed to a single tool."
)
return max(matches, key=version_sort_key)
# -------------------------------------------------------------------------
# Resource methods
# -------------------------------------------------------------------------

View file

@ -145,8 +145,9 @@ def _version_request_meta(
# The MCP SDK warns "Tool X not listed, no validation will be performed"
# for every call to app-only tools (hidden from list_tools by design).
# This fires even when validate_input=False. Suppress it.
# for every call addressed by hashed backend name, since that address is
# an identity rather than a listed tool name. This fires even when
# validate_input=False. Suppress it.
class _SuppressUnlistedToolWarning(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
return "not listed, no validation" not in record.getMessage()
@ -223,64 +224,18 @@ def _get_auth_context() -> tuple[bool, Any]:
return (False, get_access_token())
def _is_backend_tool(tool: Tool) -> bool:
"""Check whether a tool is handled specially as backend tool
def _tool_identity(tool: Tool) -> str | None:
"""Read a tool's stable identity hash, if it carries one."""
from fastmcp.server.providers.addressing import TOOL_HASH_META_KEY
Tools registered via ``@app.tool()`` (without ``model=True``) have
``meta["ui"]["visibility"] == ["app"]`` they are callable by app UIs
but should not appear in tool list the client passes to the model.
They are handled specially for in various ways - e.g. they are looked
up via get_app_tool(), and don't appear in the tools/list output.
(FIXME: the latter isn't correct behavior according to the mcp-apps spec.)
Returns True (a backend tool) when:
- The tool has ``meta.fastmcp.app``.
- The tool has ``meta.ui.visibility``.
- The visibility is precisely ``["app"]``.
Returns False otherwise.
"""
meta = tool.meta
if not meta:
return False
fastmcp = meta.get("fastmcp")
if not isinstance(fastmcp, dict):
return False
if fastmcp.get("app") is None:
return False
ui = meta.get("ui")
if not isinstance(ui, dict):
return False
visibility = ui.get("visibility")
if not isinstance(visibility, list):
return False
return len(visibility) == 1 and visibility[0] == "app"
def _is_app_visible(tool: Tool) -> bool:
"""Check whether a tool has explicitly opted into app-callable visibility.
Gates the dispatcher's hashed-name routing path: only tools whose
``meta.ui.visibility`` list contains ``"app"`` can be reached via
``<hash>_<local_name>`` calls. Tools without an explicit visibility
declaration are NOT app-callable they must be reached by their
display name through the normal transform-aware resolution path.
This is the inverse of the "everything is dot-callable" trap: the
hashed-name path is an opt-in mechanism for FastMCPApp backend tools,
not a general bypass for arbitrary tools.
"""
meta = tool.meta
if not meta:
return False
ui = meta.get("ui")
if not isinstance(ui, dict):
return False
visibility = ui.get("visibility")
if not isinstance(visibility, list):
return False
return "app" in visibility
return None
fastmcp_meta = meta.get("fastmcp")
if not isinstance(fastmcp_meta, dict):
return None
identity = fastmcp_meta.get(TOOL_HASH_META_KEY)
return identity if isinstance(identity, str) else None
@asynccontextmanager
@ -728,7 +683,7 @@ class FastMCP(
"""Replace placeholder Prefab URIs with per-tool hashed ones.
For each tool whose ``meta.ui.resourceUri`` is the placeholder,
reads the tool's stored hash from ``meta.fastmcp._tool_hash``
reads the tool's stored hash from ``meta.fastmcp.tool_hash``
and rewrites the URI to the per-tool form. Also strips CSP from
tool meta (it belongs on the resource). Produces ``model_copy``
views originals are untouched.
@ -742,6 +697,78 @@ class FastMCP(
rewrite_tool_meta_for_wire(t) if _is_prefab_tool(t) else t for t in tools
]
async def _rebind_prefab_tool_names(self, result: Any) -> Any:
"""Re-address a Prefab payload's tool references to this server's names.
Runs on the way out of every ``tools/call``, above the middleware
chain so a payload is re-addressed however it was produced. Servers
unwind innermost-first, so the outermost server rewrites last and its
names the only ones a client can actually invoke are what ship.
A call does not always answer with a tool result: submitting a task
answers with the task's metadata. Anything that is not a tool result
passes through untouched.
An identity claimed by more than one tool is not bound. That happens
when one app is composed into a server twice, which leaves no fact
anywhere in the listing that says which copy a UI belongs to. The
reference keeps its identity-addressed form, and the dispatcher
reports the ambiguity rather than binding to a coin flip.
"""
from fastmcp.server.providers.prefab_payload import (
payload_has_identities,
rewrite_payload_tool_names,
)
if not isinstance(result, ToolResult):
return result
payload = result.structured_content
if not payload_has_identities(payload):
return result
# Binding is safe only where one identity, one name, and one
# component all agree. Each is tracked separately: collapsing them
# early is what lets a duplicated app pass as a single tool.
#
# The middleware chain runs, because the binding has to describe the
# listing a client will actually see. Middleware adds, removes and
# shadows tools — an injected tool sharing a backend's name owns that
# name at call time, and a listing taken beneath middleware would not
# know it exists.
claimed_by: dict[str, list[Tool]] = {}
owners_of: dict[str, set[str | None]] = {}
for tool in await self.list_tools():
identity = _tool_identity(tool)
owners_of.setdefault(tool.name, set()).add(identity)
if identity is not None:
claimed_by.setdefault(identity, []).append(tool)
def resolve(identity: str) -> str | None:
tools = claimed_by.get(identity, [])
names = {tool.name for tool in tools}
if len(names) != 1:
# Several names carry this identity: the app is composed more
# than once and nothing says which copy the UI belongs to.
return None
# One name can still be several components. `key` is the canonical
# identity — type, name and version — so versions of one tool have
# distinct keys while copies of one app repeat a key. A repeat
# means two components are indistinguishable, which is worse than
# the renamed case, not better.
if len({tool.key for tool in tools}) != len(tools):
return None
(name,) = names
# And the name has to lead back. Two apps can each expose `save`,
# or a plain tool can share the name — binding then hands one
# app's button to someone else's implementation.
return name if owners_of.get(name) == {identity} else None
rewrite_payload_tool_names(payload, resolve)
return result
# -------------------------------------------------------------------------
# Provider interface overrides - inherited from AggregateProvider
# -------------------------------------------------------------------------
@ -800,7 +827,7 @@ class FastMCP(
async def list_tools(self, *, run_middleware: bool = True) -> Sequence[Tool]:
"""List all enabled tools from providers.
Overrides Provider.list_tools() to add visibility filtering, auth filtering,
Overrides Provider.list_tools() to add enabled filtering, auth filtering,
and middleware execution. Returns all versions (no deduplication).
Protocol handlers deduplicate for MCP wire format.
"""
@ -820,11 +847,14 @@ class FastMCP(
# Core logic: list tools
with server_span("tools/list", "tools/list", self.name, "tool", ""):
# Get all tools, apply session transforms, then filter enabled
# and model-visible (app-only tools are hidden from the model).
# Get all tools, apply session transforms, then filter enabled.
# App-only tools (meta.ui.visibility == ["app"]) are listed:
# the mcp-apps spec puts visibility filtering on the host, and
# a tool absent from tools/list cannot be forwarded by any
# intermediary that routes by name.
tools = list(await super().list_tools())
tools = await apply_session_transforms(tools)
tools = [t for t in tools if is_enabled(t) and not _is_backend_tool(t)]
tools = [t for t in tools if is_enabled(t)]
# Rewrite per-tool Prefab renderer URIs based on the tool's
# mount-point address. The walk pairs each tool with the
@ -882,7 +912,7 @@ class FastMCP(
) -> Tool | None:
"""Get a tool by name, filtering disabled tools.
Overrides Provider.get_tool() to add visibility filtering after all
Overrides Provider.get_tool() to filter disabled tools after all
transforms (including session-level) have been applied. This ensures
session transforms can override provider-level disables.
@ -902,18 +932,18 @@ class FastMCP(
# Apply session transforms to single item
tools = await apply_session_transforms([tool])
if tools and is_enabled(tools[0]) and not _is_backend_tool(tools[0]):
if tools and is_enabled(tools[0]):
return tools[0]
# The highest version is disabled (or app-only). If an explicit version
# was requested, respect that. Otherwise fall back to the next-highest
# enabled, model-visible version.
# The highest version is disabled. If an explicit version was
# requested, respect that. Otherwise fall back to the next-highest
# enabled version.
if version is not None:
return None
all_tools = [t for t in await super().list_tools() if t.name == name]
all_tools = list(await apply_session_transforms(all_tools))
enabled = [t for t in all_tools if is_enabled(t) and not _is_backend_tool(t)]
enabled = [t for t in all_tools if is_enabled(t)]
skip_auth, token = _get_auth_context()
authorized: list[Tool] = []
@ -1398,7 +1428,7 @@ class FastMCP(
# the whole thing (so it observes every call), and the
# interceptors sit between it and the tool body (so each is the
# last gate before execution).
return await self._dispatch_component_middleware(
dispatched = await self._dispatch_component_middleware(
context=mw_context,
call_next=self._compose_tool_call_interceptors(
lambda context: self.call_tool(
@ -1409,6 +1439,10 @@ class FastMCP(
)
),
)
# Above the chain, so a Prefab payload is re-addressed however
# it was produced — middleware can answer a call itself, and
# such a result never reaches the core path below.
return await self._rebind_prefab_tool_names(dispatched)
# Core logic: find and execute tool
with server_span(

View file

@ -518,15 +518,17 @@ def _get_tool_resolver(app_name: str | None = None) -> Callable[..., str] | None
def _prefab_to_json(app: Any, fastmcp_app_name: str | None = None) -> dict[str, Any]:
"""Call PrefabApp.to_json() with the hash-based resolver.
"""Serialize a PrefabApp, addressing its peer-tool references by identity.
The resolver prefixes peer-tool references with a deterministic hash
derived from the app name + tool name. The dispatcher recognizes that
format and routes calls via ``get_tool_by_hash`` which walks the
provider tree recursively same pattern as the old ``get_app_tool``.
The resolver writes each reference as ``<hash>_<local_name>``, and the
identity behind it is recorded in the payload's meta so that servers
can re-address the reference on the way out without losing track of
what it points at.
"""
from fastmcp.server.providers.prefab_payload import annotate_payload_identities
data = app.to_json(tool_resolver=_get_tool_resolver(fastmcp_app_name))
return data
return annotate_payload_identities(data)
def _get_fastmcp_app_name(tool: Tool) -> str | None:

View file

@ -242,6 +242,50 @@ class ArgTransformConfig(FastMCPBaseModel):
return ArgTransform(**self.model_dump(exclude_unset=True)) # pyright: ignore[reportAny]
#: Meta namespaces the framework owns. An override replaces the caller-facing
#: meta wholesale, but these carry a component's app membership, identity, and
#: visibility — what intermediaries use to recognize a tool they are
#: forwarding. Both are needed together: an identity that survives a rename
#: while its ``ui.visibility`` marker does not leaves a tool that can be named
#: but no longer answers to its identity.
_FRAMEWORK_META_NAMESPACES = ("fastmcp", "ui")
def _apply_meta_override(
source_meta: dict[str, Any] | None,
override: dict[str, Any] | None | NotSetT,
) -> dict[str, Any] | None:
"""Apply a transform's ``meta=`` override, preserving framework namespaces.
An override replaces the caller-facing meta wholesale, which is what users
expect. Framework-owned namespaces are carried across regardless, since a
transform that renames a tool must not silently unwire it values the
override supplies for those namespaces still win key by key.
"""
if isinstance(override, NotSetT):
return source_meta
source = source_meta or {}
preserved = {
namespace: dict(source[namespace])
for namespace in _FRAMEWORK_META_NAMESPACES
if isinstance(source.get(namespace), dict) and source[namespace]
}
if override is None:
return preserved or None
merged = dict(override)
for namespace, source_values in preserved.items():
override_values = override.get(namespace)
merged[namespace] = (
{**source_values, **override_values}
if isinstance(override_values, dict)
else source_values
)
return merged
class TransformedTool(Tool):
"""A tool that is transformed from another tool.
@ -590,7 +634,7 @@ class TransformedTool(Tool):
description if not isinstance(description, NotSetT) else tool.description
)
final_title = title if not isinstance(title, NotSetT) else tool.title
final_meta = meta if not isinstance(meta, NotSetT) else tool.meta
final_meta = _apply_meta_override(tool.meta, meta)
final_annotations = (
annotations if not isinstance(annotations, NotSetT) else tool.annotations
)

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,14 +65,33 @@ 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
resolves when called back the full round-trip works."""
@ -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."""