mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Late-bind app tool names so UIs survive composition (#4682)
This commit is contained in:
parent
0175bc9235
commit
a8b5da9770
17 changed files with 1184 additions and 165 deletions
|
|
@ -61,17 +61,43 @@ The final tool result has two parts: `content` (a list of `TextContent` blocks f
|
||||||
|
|
||||||
## Tool call routing
|
## 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
|
### Provider delegation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`?
|
- 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?
|
- 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.
|
The rest of this page covers each piece in turn.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,11 +70,13 @@ def my_tool() -> str:
|
||||||
The `visibility` field controls where a tool appears:
|
The `visibility` field controls where a tool appears:
|
||||||
|
|
||||||
- `["model"]` — visible to the LLM (the default behavior)
|
- `["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
|
- `["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.
|
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
|
```python
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
app=AppConfig(
|
app=AppConfig(
|
||||||
|
|
|
||||||
|
|
@ -54,16 +54,20 @@ F = TypeVar("F", bound=Callable[..., Any])
|
||||||
|
|
||||||
|
|
||||||
def _make_resolver(app_name: str | None = None) -> 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
|
``app_name`` is the FastMCPApp's name, known at serialization time from
|
||||||
the FastMCPApp's name, known at serialization time from the tool's
|
the tool's ``meta["fastmcp"]["app"]`` tag. Serialization happens deep
|
||||||
``meta["fastmcp"]["app"]`` tag. The only change is the wire format:
|
inside whatever composition the server has, so nothing here can know
|
||||||
``<hash>_<local_name>`` instead of ``<app_name>___<local_name>``.
|
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
|
Each FastMCP server rewrites those references on the way out to the
|
||||||
``get_tool_by_hash`` which walks the provider tree recursively —
|
name it lists that tool under, so what a renderer finally receives is
|
||||||
same pattern as ``get_app_tool``.
|
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 (
|
from fastmcp.server.providers.addressing import (
|
||||||
hashed_backend_name,
|
hashed_backend_name,
|
||||||
|
|
@ -227,14 +231,17 @@ class FastMCPApp(Provider):
|
||||||
raise ValueError(f"Cannot determine tool name for {fn!r}")
|
raise ValueError(f"Cannot determine tool name for {fn!r}")
|
||||||
|
|
||||||
from fastmcp.apps.config import AppConfig, app_config_to_meta_dict
|
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)
|
app_config = AppConfig(visibility=visibility)
|
||||||
meta: dict[str, Any] = {
|
meta: dict[str, Any] = {
|
||||||
"ui": app_config_to_meta_dict(app_config),
|
"ui": app_config_to_meta_dict(app_config),
|
||||||
"fastmcp": {
|
"fastmcp": {
|
||||||
"app": self.name,
|
"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:
|
def _register(fn: F, tool_name: str | None) -> F:
|
||||||
from fastmcp.apps.config import AppConfig, app_config_to_meta_dict
|
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 (
|
from fastmcp.server.providers.local_provider.decorators.tools import (
|
||||||
PREFAB_RENDERER_URI,
|
PREFAB_RENDERER_URI,
|
||||||
)
|
)
|
||||||
|
|
@ -334,7 +344,7 @@ class FastMCPApp(Provider):
|
||||||
"ui": app_config_to_meta_dict(app_config),
|
"ui": app_config_to_meta_dict(app_config),
|
||||||
"fastmcp": {
|
"fastmcp": {
|
||||||
"app": self.name,
|
"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):
|
if not isinstance(tool, Tool):
|
||||||
tool = Tool._ensure_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 {}
|
meta = dict(tool.meta) if tool.meta else {}
|
||||||
fm = meta.setdefault("fastmcp", {})
|
fm = meta.setdefault("fastmcp", {})
|
||||||
fm["app"] = self.name
|
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", {})
|
ui = meta.setdefault("ui", {})
|
||||||
if "visibility" not in ui:
|
if "visibility" not in ui:
|
||||||
ui["visibility"] = ["app"]
|
ui["visibility"] = ["app"]
|
||||||
|
|
|
||||||
|
|
@ -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.
|
and ``read_resource`` synthesize these on demand from the tool's meta.
|
||||||
|
|
||||||
The hash is computed at registration time from ``(app_name, tool_name)`` —
|
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
|
Deterministic across replicas (same code → same hash), no registry walk
|
||||||
needed.
|
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
|
from __future__ import annotations
|
||||||
|
|
@ -25,6 +32,9 @@ import hashlib
|
||||||
#: Length of the hex hash prefix used in URIs and backend-tool names.
|
#: Length of the hex hash prefix used in URIs and backend-tool names.
|
||||||
HASH_LENGTH = 12
|
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:
|
def hash_tool(app_name: str, tool_name: str) -> str:
|
||||||
"""Deterministic hex hash for a tool in an app.
|
"""Deterministic hex hash for a tool in an app.
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ from collections.abc import AsyncIterator, Sequence
|
||||||
from contextlib import AsyncExitStack, asynccontextmanager
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
from typing import TYPE_CHECKING, Literal, TypeVar
|
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.providers.base import Provider
|
||||||
from fastmcp.server.transforms import Namespace
|
from fastmcp.server.transforms import Namespace
|
||||||
from fastmcp.utilities.async_utils import gather
|
from fastmcp.utilities.async_utils import gather
|
||||||
|
|
@ -221,19 +221,41 @@ class AggregateProvider(Provider):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_tool_by_hash(self, tool_hash: str, tool_name: str) -> Tool | 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(
|
results = await gather(
|
||||||
(p.get_tool_by_hash(tool_hash, tool_name) for p in self.providers),
|
(p.get_tool_by_hash(tool_hash, tool_name) for p in self.providers),
|
||||||
return_exceptions=True,
|
return_exceptions=True,
|
||||||
)
|
)
|
||||||
|
matches: list[Tool] = []
|
||||||
for r in results:
|
for r in results:
|
||||||
if isinstance(r, BaseException):
|
if isinstance(r, BaseException):
|
||||||
if self.provider_error_strategy == "raise":
|
if isinstance(r, ToolError) or self.provider_error_strategy == "raise":
|
||||||
raise r
|
raise r
|
||||||
continue
|
continue
|
||||||
if r is not None:
|
if r is not None:
|
||||||
return r
|
matches.append(r)
|
||||||
return None
|
|
||||||
|
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
|
# Resources
|
||||||
|
|
|
||||||
|
|
@ -214,9 +214,11 @@ class Provider:
|
||||||
"""Look up an app-visible tool by its deterministic hash.
|
"""Look up an app-visible tool by its deterministic hash.
|
||||||
|
|
||||||
Same recursive-walk semantics as ``get_app_tool`` but matches on
|
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.
|
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)
|
tool = await self._get_tool(tool_name)
|
||||||
if tool is not None:
|
if tool is not None:
|
||||||
meta = tool.meta or {}
|
meta = tool.meta or {}
|
||||||
|
|
@ -227,7 +229,7 @@ class Provider:
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
isinstance(fastmcp_meta, dict)
|
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
|
and "app" in visibility
|
||||||
):
|
):
|
||||||
return tool
|
return tool
|
||||||
|
|
|
||||||
158
fastmcp_slim/fastmcp/server/providers/prefab_payload.py
Normal file
158
fastmcp_slim/fastmcp/server/providers/prefab_payload.py
Normal 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
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Tools marked as Prefab (via ``app=True``, ``PrefabAppConfig``, etc.) carry
|
Tools marked as Prefab (via ``app=True``, ``PrefabAppConfig``, etc.) carry
|
||||||
a placeholder ``meta.ui.resourceUri`` and optionally a hash in
|
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
|
resources on demand at ``list_resources`` and ``read_resource`` time
|
||||||
without storing or materializing anything.
|
without storing or materializing anything.
|
||||||
|
|
||||||
|
|
@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from fastmcp.server.providers.addressing import (
|
from fastmcp.server.providers.addressing import (
|
||||||
HASH_LENGTH,
|
HASH_LENGTH,
|
||||||
|
TOOL_HASH_META_KEY,
|
||||||
hash_tool,
|
hash_tool,
|
||||||
parse_hashed_resource_uri,
|
parse_hashed_resource_uri,
|
||||||
)
|
)
|
||||||
|
|
@ -48,7 +49,7 @@ def _get_tool_hash(tool: Tool) -> str | None:
|
||||||
meta = tool.meta or {}
|
meta = tool.meta or {}
|
||||||
fastmcp_meta = meta.get("fastmcp")
|
fastmcp_meta = meta.get("fastmcp")
|
||||||
if isinstance(fastmcp_meta, dict):
|
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:
|
if isinstance(h, str) and len(h) == HASH_LENGTH:
|
||||||
return h
|
return h
|
||||||
# Fall back to computing from app name
|
# Fall back to computing from app name
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ from fastmcp.client.sampling import create_sampling_callback
|
||||||
from fastmcp.client.telemetry import client_span
|
from fastmcp.client.telemetry import client_span
|
||||||
from fastmcp.client.transports import ClientTransportT
|
from fastmcp.client.transports import ClientTransportT
|
||||||
from fastmcp.client.transports.base import TransportOptions
|
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.mcp_config import MCPConfig
|
||||||
from fastmcp.prompts import Message, Prompt, PromptResult
|
from fastmcp.prompts import Message, Prompt, PromptResult
|
||||||
from fastmcp.prompts.base import InputRequiredPromptResult, PromptArgument
|
from fastmcp.prompts.base import InputRequiredPromptResult, PromptArgument
|
||||||
|
|
@ -856,6 +856,54 @@ class ProxyProvider(Provider):
|
||||||
return None
|
return None
|
||||||
return max(matching, key=version_sort_key)
|
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
|
# Resource methods
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -145,8 +145,9 @@ def _version_request_meta(
|
||||||
|
|
||||||
|
|
||||||
# The MCP SDK warns "Tool X not listed, no validation will be performed"
|
# 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).
|
# for every call addressed by hashed backend name, since that address is
|
||||||
# This fires even when validate_input=False. Suppress it.
|
# an identity rather than a listed tool name. This fires even when
|
||||||
|
# validate_input=False. Suppress it.
|
||||||
class _SuppressUnlistedToolWarning(logging.Filter):
|
class _SuppressUnlistedToolWarning(logging.Filter):
|
||||||
def filter(self, record: logging.LogRecord) -> bool:
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
return "not listed, no validation" not in record.getMessage()
|
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())
|
return (False, get_access_token())
|
||||||
|
|
||||||
|
|
||||||
def _is_backend_tool(tool: Tool) -> bool:
|
def _tool_identity(tool: Tool) -> str | None:
|
||||||
"""Check whether a tool is handled specially as backend tool
|
"""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
|
meta = tool.meta
|
||||||
if not meta:
|
if not meta:
|
||||||
return False
|
return None
|
||||||
fastmcp = meta.get("fastmcp")
|
fastmcp_meta = meta.get("fastmcp")
|
||||||
if not isinstance(fastmcp, dict):
|
if not isinstance(fastmcp_meta, dict):
|
||||||
return False
|
return None
|
||||||
if fastmcp.get("app") is None:
|
identity = fastmcp_meta.get(TOOL_HASH_META_KEY)
|
||||||
return False
|
return identity if isinstance(identity, str) else None
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|
@ -728,7 +683,7 @@ class FastMCP(
|
||||||
"""Replace placeholder Prefab URIs with per-tool hashed ones.
|
"""Replace placeholder Prefab URIs with per-tool hashed ones.
|
||||||
|
|
||||||
For each tool whose ``meta.ui.resourceUri`` is the placeholder,
|
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
|
and rewrites the URI to the per-tool form. Also strips CSP from
|
||||||
tool meta (it belongs on the resource). Produces ``model_copy``
|
tool meta (it belongs on the resource). Produces ``model_copy``
|
||||||
views — originals are untouched.
|
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
|
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
|
# Provider interface overrides - inherited from AggregateProvider
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
@ -800,7 +827,7 @@ class FastMCP(
|
||||||
async def list_tools(self, *, run_middleware: bool = True) -> Sequence[Tool]:
|
async def list_tools(self, *, run_middleware: bool = True) -> Sequence[Tool]:
|
||||||
"""List all enabled tools from providers.
|
"""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).
|
and middleware execution. Returns all versions (no deduplication).
|
||||||
Protocol handlers deduplicate for MCP wire format.
|
Protocol handlers deduplicate for MCP wire format.
|
||||||
"""
|
"""
|
||||||
|
|
@ -820,11 +847,14 @@ class FastMCP(
|
||||||
|
|
||||||
# Core logic: list tools
|
# Core logic: list tools
|
||||||
with server_span("tools/list", "tools/list", self.name, "tool", ""):
|
with server_span("tools/list", "tools/list", self.name, "tool", ""):
|
||||||
# Get all tools, apply session transforms, then filter enabled
|
# Get all tools, apply session transforms, then filter enabled.
|
||||||
# and model-visible (app-only tools are hidden from the model).
|
# 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 = list(await super().list_tools())
|
||||||
tools = await apply_session_transforms(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
|
# Rewrite per-tool Prefab renderer URIs based on the tool's
|
||||||
# mount-point address. The walk pairs each tool with the
|
# mount-point address. The walk pairs each tool with the
|
||||||
|
|
@ -882,7 +912,7 @@ class FastMCP(
|
||||||
) -> Tool | None:
|
) -> Tool | None:
|
||||||
"""Get a tool by name, filtering disabled tools.
|
"""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
|
transforms (including session-level) have been applied. This ensures
|
||||||
session transforms can override provider-level disables.
|
session transforms can override provider-level disables.
|
||||||
|
|
||||||
|
|
@ -902,18 +932,18 @@ class FastMCP(
|
||||||
|
|
||||||
# Apply session transforms to single item
|
# Apply session transforms to single item
|
||||||
tools = await apply_session_transforms([tool])
|
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]
|
return tools[0]
|
||||||
|
|
||||||
# The highest version is disabled (or app-only). If an explicit version
|
# The highest version is disabled. If an explicit version was
|
||||||
# was requested, respect that. Otherwise fall back to the next-highest
|
# requested, respect that. Otherwise fall back to the next-highest
|
||||||
# enabled, model-visible version.
|
# enabled version.
|
||||||
if version is not None:
|
if version is not None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
all_tools = [t for t in await super().list_tools() if t.name == name]
|
all_tools = [t for t in await super().list_tools() if t.name == name]
|
||||||
all_tools = list(await apply_session_transforms(all_tools))
|
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()
|
skip_auth, token = _get_auth_context()
|
||||||
authorized: list[Tool] = []
|
authorized: list[Tool] = []
|
||||||
|
|
@ -1398,7 +1428,7 @@ class FastMCP(
|
||||||
# the whole thing (so it observes every call), and the
|
# the whole thing (so it observes every call), and the
|
||||||
# interceptors sit between it and the tool body (so each is the
|
# interceptors sit between it and the tool body (so each is the
|
||||||
# last gate before execution).
|
# last gate before execution).
|
||||||
return await self._dispatch_component_middleware(
|
dispatched = await self._dispatch_component_middleware(
|
||||||
context=mw_context,
|
context=mw_context,
|
||||||
call_next=self._compose_tool_call_interceptors(
|
call_next=self._compose_tool_call_interceptors(
|
||||||
lambda context: self.call_tool(
|
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
|
# Core logic: find and execute tool
|
||||||
with server_span(
|
with server_span(
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
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
|
The resolver writes each reference as ``<hash>_<local_name>``, and the
|
||||||
derived from the app name + tool name. The dispatcher recognizes that
|
identity behind it is recorded in the payload's meta so that servers
|
||||||
format and routes calls via ``get_tool_by_hash`` which walks the
|
can re-address the reference on the way out without losing track of
|
||||||
provider tree recursively — same pattern as the old ``get_app_tool``.
|
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))
|
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:
|
def _get_fastmcp_app_name(tool: Tool) -> str | None:
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,50 @@ class ArgTransformConfig(FastMCPBaseModel):
|
||||||
return ArgTransform(**self.model_dump(exclude_unset=True)) # pyright: ignore[reportAny]
|
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):
|
class TransformedTool(Tool):
|
||||||
"""A tool that is transformed from another 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
|
description if not isinstance(description, NotSetT) else tool.description
|
||||||
)
|
)
|
||||||
final_title = title if not isinstance(title, NotSetT) else tool.title
|
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 = (
|
final_annotations = (
|
||||||
annotations if not isinstance(annotations, NotSetT) else tool.annotations
|
annotations if not isinstance(annotations, NotSetT) else tool.annotations
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,9 @@ class TestFileUploadProvider:
|
||||||
text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||||
assert "test.txt" in text
|
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()])
|
server = FastMCP("test", providers=[FileUpload()])
|
||||||
|
|
||||||
tools = await server.list_tools()
|
tools = await server.list_tools()
|
||||||
|
|
@ -150,7 +152,11 @@ class TestFileUploadProvider:
|
||||||
assert "file_manager" in tool_names
|
assert "file_manager" in tool_names
|
||||||
assert "list_files" in tool_names
|
assert "list_files" in tool_names
|
||||||
assert "read_file" 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):
|
async def test_max_file_size_enforced_server_side(self):
|
||||||
server = FastMCP("test", providers=[FileUpload(max_file_size=100)])
|
server = FastMCP("test", providers=[FileUpload(max_file_size=100)])
|
||||||
|
|
|
||||||
|
|
@ -8,22 +8,46 @@ single-server, namespaced mounts, and cross-server mounts.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from fastmcp import FastMCP, FastMCPApp
|
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")
|
prefab_ui = pytest.importorskip("prefab_ui")
|
||||||
from prefab_ui.actions.mcp import CallTool # noqa: E402
|
from prefab_ui.actions.mcp import CallTool # noqa: E402
|
||||||
from prefab_ui.components import Button, Column, Text # 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:
|
class TestSingleServerRoundTrip:
|
||||||
async def test_ui_tool_serializes_hashed_peer_reference(self):
|
async def test_payload_carries_the_servers_own_tool_name(self):
|
||||||
"""The resolver converts a CallTool string reference to a hashed
|
"""The renderer is handed a name that exists in this server's
|
||||||
name that appears in the tool result's structured_content."""
|
tools/list, not the identity-addressed form."""
|
||||||
app = FastMCPApp("contacts")
|
app = FastMCPApp("contacts")
|
||||||
|
|
||||||
@app.tool()
|
@app.tool()
|
||||||
|
|
@ -41,13 +65,32 @@ class TestSingleServerRoundTrip:
|
||||||
|
|
||||||
result = await server.call_tool("contact_form", {})
|
result = await server.call_tool("contact_form", {})
|
||||||
assert result.structured_content is not None
|
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.
|
async def test_payload_records_the_identity_behind_each_reference(self):
|
||||||
sc_json = json.dumps(result.structured_content)
|
"""The identity-addressed form survives alongside the rewritten name,
|
||||||
expected_hash = hashed_backend_name("contacts", "save_contact")
|
so an outer server can re-resolve it — or fall back to it."""
|
||||||
assert expected_hash in sc_json, (
|
app = FastMCPApp("contacts")
|
||||||
f"Expected {expected_hash!r} in structured_content but got: {sc_json[:200]}"
|
|
||||||
)
|
@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):
|
async def test_hashed_name_from_result_is_callable(self):
|
||||||
"""The hashed name that appears in structured_content actually
|
"""The hashed name that appears in structured_content actually
|
||||||
|
|
@ -131,6 +174,470 @@ class TestMountedServerRoundTrip:
|
||||||
assert result.content[0].text == "saved Carol" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
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:
|
class TestDynamicToolAdd:
|
||||||
async def test_tool_added_after_first_call_is_reachable(self):
|
async def test_tool_added_after_first_call_is_reachable(self):
|
||||||
"""Tools added to an already-mounted app after the first call
|
"""Tools added to an already-mounted app after the first call
|
||||||
|
|
@ -157,10 +664,9 @@ class TestDynamicToolAdd:
|
||||||
|
|
||||||
|
|
||||||
class TestCollision:
|
class TestCollision:
|
||||||
async def test_same_app_name_same_tool_name_first_wins(self):
|
async def test_distinct_hashes_resolve_independently(self):
|
||||||
"""Two apps with the same name and same tool name: the hash is
|
"""Two apps sharing a name but with different tool names hash
|
||||||
identical, so get_tool_by_hash returns the first match. This is
|
differently, so each tool resolves to itself."""
|
||||||
the same first-match behavior the old get_app_tool had."""
|
|
||||||
app_a = FastMCPApp("shared")
|
app_a = FastMCPApp("shared")
|
||||||
app_b = FastMCPApp("shared")
|
app_b = FastMCPApp("shared")
|
||||||
|
|
||||||
|
|
@ -172,14 +678,67 @@ class TestCollision:
|
||||||
def save_b(name: str) -> str:
|
def save_b(name: str) -> str:
|
||||||
return f"from B: {name}"
|
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 = FastMCP("Platform")
|
||||||
server.add_provider(app_a)
|
server.add_provider(app_a)
|
||||||
server.add_provider(app_b)
|
server.add_provider(app_b)
|
||||||
|
|
||||||
hashed_name = hashed_backend_name("shared", "save")
|
result = await server.call_tool(
|
||||||
result = await server.call_tool(hashed_name, {"name": "Eve"})
|
hashed_backend_name("shared", "save"), {"name": "Eve"}
|
||||||
|
)
|
||||||
assert result.content[0].text == "from A: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
assert result.content[0].text == "from A: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||||
|
|
||||||
|
result_b = await server.call_tool(
|
||||||
|
hashed_backend_name("shared", "save_b"), {"name": "Eve"}
|
||||||
|
)
|
||||||
|
assert result_b.content[0].text == "from B: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||||
|
|
||||||
|
async def test_ambiguous_identity_raises_rather_than_guessing(self):
|
||||||
|
"""The same app composed into two branches yields two tools with one
|
||||||
|
identity. Routing to either would silently execute the wrong branch's
|
||||||
|
tool, so the call is refused."""
|
||||||
|
server = FastMCP("Platform")
|
||||||
|
for marker, namespace in (("A", "a"), ("B", "b")):
|
||||||
|
app = FastMCPApp("contacts")
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def save(name: str, _marker: str = marker) -> str:
|
||||||
|
return f"from {_marker}: {name}"
|
||||||
|
|
||||||
|
server.add_provider(app, namespace=namespace)
|
||||||
|
|
||||||
|
with pytest.raises(ToolError, match="Ambiguous app tool"):
|
||||||
|
await server.call_tool(
|
||||||
|
hashed_backend_name("contacts", "save"), {"name": "Eve"}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_distinct_app_names_route_independently_through_a_gateway(self):
|
||||||
|
"""The multi-tenant gateway shape: distinct app names stay unambiguous
|
||||||
|
no matter how many backends sit behind one proxy."""
|
||||||
|
|
||||||
|
def backend(marker: str, app_name: str) -> FastMCP:
|
||||||
|
app = FastMCPApp(app_name)
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def save(name: str) -> str:
|
||||||
|
return f"from {marker}: {name}"
|
||||||
|
|
||||||
|
server = FastMCP(f"Backend-{marker}")
|
||||||
|
server.add_provider(app)
|
||||||
|
return server
|
||||||
|
|
||||||
|
first = backend("A", "crm")
|
||||||
|
second = backend("B", "billing")
|
||||||
|
|
||||||
|
gateway = FastMCP("Gateway")
|
||||||
|
gateway.add_provider(ProxyProvider(lambda: ProxyClient(first)), namespace="a")
|
||||||
|
gateway.add_provider(ProxyProvider(lambda: ProxyClient(second)), namespace="b")
|
||||||
|
|
||||||
|
result_a = await gateway.call_tool(
|
||||||
|
hashed_backend_name("crm", "save"), {"name": "Eve"}
|
||||||
|
)
|
||||||
|
assert result_a.content[0].text == "from A: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||||
|
|
||||||
|
result_b = await gateway.call_tool(
|
||||||
|
hashed_backend_name("billing", "save"), {"name": "Eve"}
|
||||||
|
)
|
||||||
|
assert result_b.content[0].text == "from B: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from fastmcp.apps.app import (
|
||||||
FastMCPApp,
|
FastMCPApp,
|
||||||
_make_resolver,
|
_make_resolver,
|
||||||
)
|
)
|
||||||
|
from fastmcp.server.providers.addressing import hash_tool, hashed_backend_name
|
||||||
from fastmcp.tools.base import Tool
|
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:
|
class TestAppOnlyToolVisibility:
|
||||||
async def test_app_only_tool_hidden_from_list_tools(self):
|
async def test_app_only_tool_appears_in_list_tools(self):
|
||||||
"""@app.tool() (visibility=["app"]) should not appear in server.list_tools()."""
|
"""@app.tool() (visibility=["app"]) is listed; the host filters it out."""
|
||||||
app = FastMCPApp("crm")
|
app = FastMCPApp("crm")
|
||||||
|
|
||||||
@app.tool()
|
@app.tool()
|
||||||
|
|
@ -597,7 +598,40 @@ class TestAppOnlyToolFiltering:
|
||||||
|
|
||||||
tools = await server.list_tools()
|
tools = await server.list_tools()
|
||||||
names = [t.name for t in 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):
|
async def test_model_visible_tool_in_list_tools(self):
|
||||||
"""@app.tool(model=True) (visibility=["app","model"]) appears in list_tools."""
|
"""@app.tool(model=True) (visibility=["app","model"]) appears in list_tools."""
|
||||||
|
|
@ -629,8 +663,8 @@ class TestAppOnlyToolFiltering:
|
||||||
names = [t.name for t in tools]
|
names = [t.name for t in tools]
|
||||||
assert "show_dashboard" in names
|
assert "show_dashboard" in names
|
||||||
|
|
||||||
async def test_app_only_tool_still_callable_via_app_name(self):
|
async def test_app_only_tool_callable_via_hashed_address(self):
|
||||||
"""Even though filtered from list_tools, app-only tools are callable via call_tool with app_name."""
|
"""The hashed address still resolves, independent of the display name."""
|
||||||
app = FastMCPApp("contacts")
|
app = FastMCPApp("contacts")
|
||||||
|
|
||||||
@app.tool()
|
@app.tool()
|
||||||
|
|
@ -640,35 +674,30 @@ class TestAppOnlyToolFiltering:
|
||||||
server = FastMCP("Platform")
|
server = FastMCP("Platform")
|
||||||
server.add_provider(app)
|
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(
|
result = await server.call_tool(
|
||||||
hashed_backend_name("contacts", "save"), {"name": "alice"}
|
hashed_backend_name("contacts", "save"), {"name": "alice"}
|
||||||
)
|
)
|
||||||
assert result.content[0].text == "saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
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):
|
async def test_app_only_tool_callable_by_display_name(self):
|
||||||
"""server.get_tool() returns None for app-only tools."""
|
"""App-only tools resolve normally; the host decides who may call them."""
|
||||||
app = FastMCPApp("crm")
|
app = FastMCPApp("contacts")
|
||||||
|
|
||||||
@app.tool()
|
@app.tool()
|
||||||
def save_contact(name: str) -> str:
|
def save(name: str) -> str:
|
||||||
return name
|
return f"saved {name}"
|
||||||
|
|
||||||
server = FastMCP("Platform")
|
server = FastMCP("Platform")
|
||||||
server.add_provider(app)
|
server.add_provider(app)
|
||||||
|
|
||||||
tool = await server.get_tool("save_contact")
|
tool = await server.get_tool("save")
|
||||||
assert tool is None
|
assert tool is not None
|
||||||
|
|
||||||
async def test_app_only_tool_hidden_with_namespace(self):
|
result = await server.call_tool("save", {"name": "alice"})
|
||||||
"""App-only tools hidden even when accessed through a namespace."""
|
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 = FastMCPApp("crm")
|
||||||
|
|
||||||
@app.tool()
|
@app.tool()
|
||||||
|
|
@ -680,7 +709,23 @@ class TestAppOnlyToolFiltering:
|
||||||
|
|
||||||
tools = await server.list_tools()
|
tools = await server.list_tools()
|
||||||
names = [t.name for t in 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 = FastMCP("Platform")
|
||||||
server.add_provider(app, namespace="crm")
|
server.add_provider(app, namespace="crm")
|
||||||
|
|
||||||
# The @app.ui() tool should be visible (namespaced) to the client.
|
# Both tools are listed (namespaced). The backend tool declares
|
||||||
# The @app.tool() backend tool should NOT appear.
|
# visibility=["app"] so the host keeps it out of the model's list.
|
||||||
async with Client(server) as client:
|
async with Client(server) as client:
|
||||||
tools = await client.list_tools()
|
tools = await client.list_tools()
|
||||||
tool_names = [t.name for t in tools]
|
tool_names = [t.name for t in tools]
|
||||||
assert "crm_contact_form" in tool_names
|
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
|
# Call the UI tool through the client and check structured_content
|
||||||
result = await client.call_tool_mcp("crm_contact_form", {})
|
result = await client.call_tool_mcp("crm_contact_form", {})
|
||||||
sc = result.structured_content
|
sc = result.structured_content
|
||||||
assert sc is not None
|
assert sc is not None
|
||||||
|
|
||||||
# Call the backend tool via its hashed address — bypasses namespace
|
# Call the backend tool via its hashed address — resolves regardless
|
||||||
# transforms and visibility filtering by going through the registry.
|
# of the namespace transform applied to the display name.
|
||||||
backend_result = await server.call_tool(
|
backend_result = await server.call_tool(
|
||||||
hashed_backend_name("contacts", "save_contact"),
|
hashed_backend_name("contacts", "save_contact"),
|
||||||
{"name": "Alice", "email": "alice@example.com"},
|
{"name": "Alice", "email": "alice@example.com"},
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,45 @@ def test_tool_transform_config_removes_meta(sample_tool):
|
||||||
assert transformed.meta is None
|
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
|
# Enabled field tests
|
||||||
def test_tool_transform_config_enabled_defaults_to_true(sample_tool):
|
def test_tool_transform_config_enabled_defaults_to_true(sample_tool):
|
||||||
"""Test that enabled defaults to True and no visibility metadata is set."""
|
"""Test that enabled defaults to True and no visibility metadata is set."""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue