Replace ___ with hash-based backend tool routing and per-tool prefab resources#3824
Replace ___ with hash-based backend tool routing and per-tool prefab resources#3824
Conversation
Replaces FastMCPApp's '___' separator with deterministic hashed-name routing built on a positional address registry. Each mount point in the provider tree gets an integer-tuple address; tool names hash to a fixed-length hex prefix derived from that address plus the tool's local name. Backend tools (visibility=['app']) are reachable via '<hash>_<local_name>' through a special dispatcher path that bypasses transforms via the registry's reverse-hash map. Display-name resolution unchanged for normal tools. After successful display-name resolution the dispatcher reverse-walks the registry to populate Context.mount_path so the Prefab peer-reference resolver can serialize backend-tool references with the correct address regardless of how the calling tool was invoked. Pure: registry is built lazily, invalidated on add_provider, no side effects, no mutation of tool meta. Foundation for #3805 and #3735.
Replaces the singleton ui://prefab/renderer.html with per-tool renderer resources synthesized on demand from the address registry. Each prefab tool gets a unique URI ui://prefab/tool/<hash>/renderer.html where the hash is deterministic from the tool's mount-point address. Nothing is materialized or stored: list_resources walks the registry and returns synthesized TextResource entries; read_resource intercepts matching URIs and produces fresh content + CSP from the tool's stored meta; list_tools rewrites tool meta via model_copy so the wire format exposes the per-tool URI without ever mutating the original Tool object. Closes the bug from PR #3754: PrefabAppConfig(csp=...) now actually applies to the rendered resource, all four *_domains fields are preserved (the old singleton silently dropped frame_domains and base_uri_domains), and CSP is stripped from the tool wire format where it never belonged. Closes #3735.
The Prefab peer-reference resolver now looks up each referenced tool by callable identity (id(fn)) in the server's callable map, computing the hashed backend name from the tool's own address. This eliminates the dispatch-time _find_owning_address walk entirely — the resolver doesn't need the calling tool's mount_path because it looks up the PEER's address directly. Context.mount_path replaced by Context.parent_address which accumulates across server boundaries. For single-server setups (the common case) it's always (). Cross-server propagation through FastMCPProvider is future work.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4bc415b73b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if isinstance(inner, LocalProvider): | ||
| sources.append(inner) | ||
| if isinstance(inner, FastMCPApp): | ||
| sources.append(inner._local) |
There was a problem hiding this comment.
Index mounted child tools in reverse hash map
_enumerate_tools only pulls tools from LocalProvider and FastMCPApp._local, so providers created by FastMCP.mount(...) (FastMCPProvider) contribute no entries to reverse_hash_map. In a mounted-server setup, hashed backend names from app-only tools cannot be resolved by the outer server and call_tool("<hash>_<name>") falls through to NotFoundError, which breaks backend routing for mounted apps.
Useful? React with 👍 / 👎.
| if self._reverse_hash_map is None: | ||
| from fastmcp.server.providers.addressing import build_reverse_hash_map | ||
|
|
||
| maps = build_reverse_hash_map(self.address_registry) | ||
| self._reverse_hash_map = maps.by_hash | ||
| self._callable_map = maps.by_callable |
There was a problem hiding this comment.
Rebuild hash maps when tool sets change
The reverse hash map is memoized after first access and only rebuilt when add_provider() runs, so adding tools to an already-mounted provider after the cache is populated leaves reverse_hash_map/callable_map stale. Newly registered backend tools then have no hash entry, causing hashed dispatch and synthesized prefab URI lookup to fail until process restart or manual cache reset.
Useful? React with 👍 / 👎.
| digest = hash_tool_address(address, tool_name) | ||
| by_hash[digest] = entry | ||
| if fn is not None: | ||
| by_callable[id(fn)] = entry |
There was a problem hiding this comment.
Avoid overwriting callable map for multi-mount apps
The callable index stores a single HashEntry per id(fn), so mounting the same app instance at multiple addresses collapses distinct tool addresses into the last one seen. Resolver lookups by callable identity then produce a hash for the wrong mount point, which can route peer tool calls to a different mounted app instance than the UI/tool that emitted the reference.
Useful? React with 👍 / 👎.
Drops the address registry, reverse-hash maps, callable maps, and Context.parent_address. The hash is now computed from (app_name, tool_name) — both known at registration time — and stored in meta.fastmcp._tool_hash. Dispatch uses get_tool_by_hash which walks the provider tree recursively (same pattern as get_app_tool), so nested mounts through FastMCPProvider work without a flat registry. Structurally identical to the old ___ approach: same registration-time tagging, same recursive walk dispatch, same resolver shape. Only the wire format changed (hash prefix instead of app_name___ prefix) and per-tool resources replaced the singleton.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9596ccc076
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if isinstance(inner, LocalProvider): | ||
| sources.append(inner) | ||
| if isinstance(inner, FastMCPApp): | ||
| sources.append(inner._local) |
There was a problem hiding this comment.
Traverse mounted FastMCPProvider in prefab tool walk
synthesize_prefab_resource_by_uri() depends on _walk_prefab_tools(), but this walker only collects tools from LocalProvider/FastMCPApp and only recurses through AggregateProvider. Mounted child servers are wrapped as FastMCPProvider, so their prefab tools are skipped. In a parent server that mounts a child with Prefab tools, clients can receive a ui://prefab/tool/<hash>/renderer.html URI from the mounted tool metadata, but read_resource on that URI cannot synthesize the resource and falls through to NotFoundError.
Useful? React with 👍 / 👎.
| isinstance(fastmcp_meta, dict) | ||
| and fastmcp_meta.get("_tool_hash") == tool_hash | ||
| and "app" in visibility |
There was a problem hiding this comment.
Support hash lookup for FastMCPApp.add_tool tools
Hash-based dispatch now resolves app-only calls through get_tool_by_hash(), but this matcher only accepts tools with meta.fastmcp._tool_hash. FastMCPApp.add_tool() still stamps only meta.fastmcp.app, so programmatically added app tools no longer match hashed backend names and become unreachable through call_tool("<hash>_<name>"). This is a regression for existing apps that register backend tools via add_tool.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 856cf29653
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
|
|
||
| resource_csp = ResourceCSP(**merged) if any(merged.values()) else None | ||
| resource_app = AppConfig(csp=resource_csp) if resource_csp else AppConfig() |
There was a problem hiding this comment.
Preserve Prefab permissions on synthesized resources
The synthesized renderer metadata only carries csp, so any PrefabAppConfig fields like permissions (and other non-CSP UI settings) are dropped from the wire entirely once list_tools strips them from tool metadata. In practice, a tool configured with permissions=... will never expose those permissions to clients, so hosts cannot grant the requested capabilities.
Useful? React with 👍 / 👎.
| if isinstance(app, str): | ||
| return hash_tool(app, tool.name) | ||
| # Root-level prefab tool (no app name) — hash from empty prefix. | ||
| return hash_tool("", tool.name) |
There was a problem hiding this comment.
Avoid hash collisions for prefab tools without app tags
For prefab tools that don't carry meta.fastmcp.app (e.g., FastMCP.tool(app=True) / PrefabAppConfig on non-FastMCPApp providers), the fallback hash is computed from only tool.name. Two mounted providers with the same prefab tool name will therefore get the same ui://prefab/tool/<hash>/renderer.html, and synthesize_prefab_resources dedupes to a single resource, causing one tool to resolve to the other tool's renderer metadata.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 67135dbd21
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| def _prefix(local_name: str) -> str: | ||
| if app_name: | ||
| return hashed_backend_name(app_name, local_name) |
There was a problem hiding this comment.
Preserve already-addressed CallTool names in resolver
When FastMCPApp context is present, _prefix now hashes every string unconditionally, so callers can no longer pass an already-addressed tool name through unchanged. This breaks any UI action that intentionally targets a pre-resolved backend identifier (for example cross-app references or precomputed hashed names), because the resolver rewrites it to a second hash and call_tool cannot find the intended target.
Useful? React with 👍 / 👎.
| sources: list[LocalProvider] = [] | ||
| if isinstance(inner, LocalProvider): | ||
| sources.append(inner) | ||
| if isinstance(inner, FastMCPApp): | ||
| sources.append(inner._local) |
There was a problem hiding this comment.
Include non-local providers in prefab tool synthesis walk
_walk_prefab_tools only inspects LocalProvider storage (_components) and FastMCPApp._local, so prefab tools served by other Provider implementations are never discovered. Those tools still get prefab resourceUri values on the wire, but list_resources/read_resource cannot synthesize matching renderer resources, leading to NotFoundError for valid prefab URIs from custom providers.
Useful? React with 👍 / 👎.
| synthesize_prefab_resources, | ||
| ) | ||
|
|
||
| resources.extend(await synthesize_prefab_resources(self)) |
There was a problem hiding this comment.
Apply enable/session filtering to synthesized resources
Synthetic prefab resources are appended after apply_session_transforms and is_enabled filtering, so they bypass the same visibility controls as normal resources. In practice, disable/session transforms cannot hide these renderer resources, and clients can still see prefab resource entries that should have been filtered out with the rest of the resource list.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4b83c6cf8b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| synthesize_prefab_resources, | ||
| ) | ||
|
|
||
| resources.extend(await synthesize_prefab_resources(self)) |
There was a problem hiding this comment.
Avoid double-synthesizing prefab resources on mounts
In FastMCP.list_resources, prefab renderer resources are appended unconditionally after super().list_resources(), but mounted providers already return inner.server.list_resources() through FastMCPProvider._list_resources, which includes those same synthesized prefab URIs. In a mounted-server setup this produces duplicate ui://prefab/tool/<hash>/renderer.html entries from the parent API path, so direct FastMCP.list_resources() consumers (and middleware running on that list) see repeated resources even though MCP wire handlers dedupe later.
Useful? React with 👍 / 👎.
| synthesized = await synthesize_prefab_resource_by_uri(self, uri) | ||
| if synthesized is not None: | ||
| span.set_attributes(synthesized.get_span_attributes()) | ||
| return await synthesized._read(task_meta=task_meta) |
There was a problem hiding this comment.
Enforce resource visibility checks for synthesized prefab reads
read_resource returns synthesized prefab resources before normal resource resolution, so this code path skips the standard filtering/authorization flow that get_resource applies (session transforms, enabled checks, and auth gating). As a result, if a client can derive a prefab URI (ui://prefab/tool/<hash>/renderer.html), it can read renderer resources even when the corresponding tool/resource would be hidden by normal server filters.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 143c403579
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if parse_hashed_backend_name(local_name) is not None: | ||
| return local_name |
There was a problem hiding this comment.
Do not treat hash-like local names as already-addressed
The resolver currently skips hashing whenever a local reference parses as <12hex>_<name>, which misclassifies valid backend tool names like deadbeefcafe_save as pre-addressed. In that case the UI emits the raw name, call_tool parses it as hash deadbeefcafe + local name save, and lookup fails because the stored hash was computed from (app_name, deadbeefcafe_save). This makes a subset of explicitly named backend tools unreachable from Prefab peer references.
Useful? React with 👍 / 👎.
| # provider that yielded it, computes the hashed URI, and | ||
| # produces a model_copy with the URI in place. Original | ||
| # Tool objects are not mutated. | ||
| tools = self._rewrite_prefab_uris(tools) |
There was a problem hiding this comment.
Rewrite prefab resource URIs in get_tool results
Prefab URI rewriting is only applied in list_tools, so FastMCP.get_tool(...) still returns tools with the placeholder ui://prefab/renderer.html. After this change that placeholder is no longer a concrete resource, so code that does tool = await get_tool(...); await read_resource(tool.meta['ui']['resourceUri']) now fails with NotFoundError even though the same tool works via list_tools.
Useful? React with 👍 / 👎.
Test Failure AnalysisSummary: A single test timed out on Windows Python 3.10: Root Cause: The 4th test in Docket itself already works around this problem for Suggested Solution: Apply the same Since this test passed on main right after the PR was merged (run 24311664313), this may be intermittent — only reproducible when Windows CI is slower than usual. Worth fixing to prevent future flakiness. Detailed AnalysisFailing test: 3 tests in the file passed (dots), then the 4th timed out after 5 seconds: Debug output captured at timeout: The notification subscriber started, but no task execution logs followed — the tool body never ran within the 5-second window. What fakeredis does for In Why 3 tests passed: Tests 1 and 3 run tools synchronously (no Docket). Test 2 ( Docket's workaround (worker.py, lines 474–495): # Use non-blocking read with in-memory backend + manual sleep
# This is necessary because fakeredis's async blocking operations don't
# properly yield control to the asyncio event loop
is_memory = self.docket.url.startswith("memory://")
...
block=0 if is_memory else int(...), # non-blocking for fakeredis
...
if is_memory and not result:
await asyncio.sleep(self.minimum_check_interval.total_seconds())notifications.py has no equivalent guard (line 112): result = await cast(Any, redis.brpop([queue_key], timeout=SUBSCRIBER_TIMEOUT_SECONDS))Related Files
|
The old
___separator for FastMCPApp backend tool routing encoded the app's display name into the wire format. It worked but had no path to per-tool CSP because every prefab tool shared a single renderer resource atui://prefab/renderer.html.This replaces both the
___wire format and the singleton renderer with a hash-based system that's structurally identical to the old approach — same registration-time tagging, same recursive provider-tree walk for dispatch, same resolver shape — but uses a deterministichash(app_name, tool_name)prefix instead of the literal app name, and gives each prefab tool its own renderer resource.Backend tool routing. Tools with
visibility=["app"]are now callable via<12-hex-hash>_<local_name>instead of<app_name>___<local_name>. The hash is computed from the app name + tool name at registration and stored inmeta.fastmcp._tool_hash. The dispatcher parses the prefix and callsget_tool_by_hashwhich walks the provider tree recursively — same delegation pattern asget_app_tool, throughAggregateProvider,_WrappedProvider, andFastMCPProvider. Nested mounts work the same way they always did.Per-tool prefab resources. Each prefab tool gets its own renderer resource at
ui://prefab/tool/<hash>/renderer.html, synthesized on demand —list_resourceswalks providers and returns freshTextResourceentries,read_resourceintercepts matching URIs and produces the HTML + CSP from the tool's stored meta. Nothing is materialized or stored.list_toolsrewrites tool meta viamodel_copyso the wire format shows the per-tool URI with CSP stripped.This fixes the bug from PR #3754 where
PrefabAppConfig(csp=ResourceCSP(frame_domains=[...]))never actually applied — the user's CSP now lands on the resource where the MCP Apps spec says it belongs, and all four*_domainsfields are preserved (the old singleton silently droppedframe_domainsandbase_uri_domains).Closes #3735, closes #3805