Skip to content

Replace ___ with hash-based backend tool routing and per-tool prefab resources#3824

Merged
jlowin merged 10 commits intomainfrom
feature/provider-addressing
Apr 12, 2026
Merged

Replace ___ with hash-based backend tool routing and per-tool prefab resources#3824
jlowin merged 10 commits intomainfrom
feature/provider-addressing

Conversation

@jlowin
Copy link
Copy Markdown
Member

@jlowin jlowin commented Apr 10, 2026

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 at ui://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 deterministic hash(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 in meta.fastmcp._tool_hash. The dispatcher parses the prefix and calls get_tool_by_hash which walks the provider tree recursively — same delegation pattern as get_app_tool, through AggregateProvider, _WrappedProvider, and FastMCPProvider. 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_resources walks providers and returns fresh TextResource entries, read_resource intercepts matching URIs and produces the HTML + CSP from the tool's stored meta. Nothing is materialized or stored. list_tools rewrites tool meta via model_copy so 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 *_domains fields are preserved (the old singleton silently dropped frame_domains and base_uri_domains).

@mcp.tool(app=PrefabAppConfig(
    csp=ResourceCSP(frame_domains=["https://example.com"])
))
def my_widget() -> Component:
    return Column(Heading("Hello"))

# The renderer resource at ui://prefab/tool/<hash>/renderer.html
# now carries frame_domains=["https://example.com"] in its CSP meta.
# The tool's wire metadata has no CSP — only resourceUri + visibility.

Closes #3735, closes #3805

jlowin added 3 commits April 9, 2026 21:51
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.
@marvin-context-protocol marvin-context-protocol Bot added feature Major new functionality. Reserved for 2-4 significant PRs per release. Not for issues. breaking change Breaks backward compatibility. Requires minor version bump. Critical for maintainer attention. server Related to FastMCP server implementation or server-side functionality. labels Apr 10, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +235 to +238
if isinstance(inner, LocalProvider):
sources.append(inner)
if isinstance(inner, FastMCPApp):
sources.append(inner._local)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread src/fastmcp/server/server.py Outdated
Comment on lines +532 to +537
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +157 to +160
if isinstance(inner, LocalProvider):
sources.append(inner)
if isinstance(inner, FastMCPApp):
sources.append(inner._local)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +220 to +222
isinstance(fastmcp_meta, dict)
and fastmcp_meta.get("_tool_hash") == tool_hash
and "app" in visibility
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@jlowin jlowin changed the title Replace ___ separator with hash-addressed tool routing and per-tool prefab resources Replace ___ with hash-based backend tool routing and per-tool prefab resources Apr 11, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/fastmcp/apps/app.py
Comment on lines +67 to +69
def _prefix(local_name: str) -> str:
if app_name:
return hashed_backend_name(app_name, local_name)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +170 to +174
sources: list[LocalProvider] = []
if isinstance(inner, LocalProvider):
sources.append(inner)
if isinstance(inner, FastMCPApp):
sources.append(inner._local)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +1358 to +1361
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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@jlowin jlowin added enhancement Improvement to existing functionality. For issues and smaller PR improvements. and removed breaking change Breaks backward compatibility. Requires minor version bump. Critical for maintainer attention. feature Major new functionality. Reserved for 2-4 significant PRs per release. Not for issues. labels Apr 12, 2026
@jlowin jlowin merged commit af957e7 into main Apr 12, 2026
7 of 8 checks passed
@jlowin jlowin deleted the feature/provider-addressing branch April 12, 2026 16:52
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/fastmcp/apps/app.py
Comment on lines +74 to +75
if parse_hashed_backend_name(local_name) is not None:
return local_name
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: A single test timed out on Windows Python 3.10: test_progress_status_message_in_background_task in tests/server/tasks/test_progress_dependency.py. This test is unrelated to the PR's changes, and the same test passed on main immediately after this PR was merged.

Root Cause: The 4th test in test_progress_dependency.py uses asyncio.Event() for synchronization between the test body and a Docket background task (task=True). The test calls await step_started.wait() expecting the event to be set by the tool body running in a Docket worker. The likely culprit is notification_subscriber_loop in src/fastmcp/server/tasks/notifications.py, which calls redis.brpop(..., timeout=30) against the fakeredis (memory://) backend.

Docket itself already works around this problem for xreadgroup in worker.py (lines 474-495), with the comment: "This is necessary because fakeredis's async blocking operations don't properly yield control to the asyncio event loop." The notification_subscriber_loop uses the same fakeredis brpop but has no equivalent workaround. On Windows, this may block the event loop long enough for the 5-second test timeout to trigger before the background task runs.

Suggested Solution: Apply the same memory:// detection pattern in notification_subscriber_loop that Docket uses for task polling — replace the blocking brpop with a non-blocking rpop + asyncio.sleep loop when docket.url.startswith("memory://"). This is in src/fastmcp/server/tasks/notifications.py around line 112.

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 Analysis

Failing test: tests/server/tasks/test_progress_dependency.py::test_progress_status_message_in_background_task

3 tests in the file passed (dots), then the 4th timed out after 5 seconds:

tests\server\tasks\test_progress_dependency.py . . . ++++++++ Timeout ++++++++

Debug output captured at timeout:

DEBUG    [test] Handler called: call_tool task_with_progress with {}
DEBUG    Started notification subscriber for session 78ac6220-...
DEBUG    Starting notification subscriber for session 78ac6220-...

The notification subscriber started, but no task execution logs followed — the tool body never ran within the 5-second window.

What fakeredis does for brpop with a timeout:

In fakeredis/aioredis.py, _blocking() pauses the socket and creates an asyncio.Task for _async_blocking(). Meanwhile read_response() calls can_read(timeout=None), which spin-polls every 10ms via asyncio.sleep(0.01). While this should yield control, Docket's own code shows that fakeredis blocking ops have known issues on Windows with the SelectorEventLoop (set in tests/conftest.py via WindowsSelectorEventLoopPolicy).

Why 3 tests passed: Tests 1 and 3 run tools synchronously (no Docket). Test 2 (test_progress_in_background_task) uses task=True but only calls await task.result() — it doesn't use asyncio.Event() for early synchronization, so it completes without depending on the subscriber.

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
  • tests/server/tasks/test_progress_dependency.py — failing test (not in PR diff)
  • src/fastmcp/server/tasks/notifications.py:112brpop call without memory:// workaround
  • src/fastmcp/settings.py:70 — default Docket URL is memory://
  • docket/worker.py:474-495 — Docket's workaround for fakeredis blocking ops
  • tests/conftest.py:21 — sets WindowsSelectorEventLoopPolicy on Windows

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improvement to existing functionality. For issues and smaller PR improvements. server Related to FastMCP server implementation or server-side functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Provider addressing to enable per-mount resource materialization CSP should be set for an App's resource instead of tool

1 participant