Route app tool calls via ___-prefixed names#3667
Conversation
Hosts (Goose, MCP Jam) don't forward _meta on callServerTool, which broke app tool routing entirely. Encode the app identity in the tool name on the wire instead: the resolver writes "AppName___tool_name", and the server parses it to route via get_app_tool.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 773f6586e0
ℹ️ 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 app_name and "___" not in name: | ||
| return f"{app_name}___{name}" |
There was a problem hiding this comment.
Prefix app tool refs even when tool names contain "___"
The resolver currently skips prefixing whenever a tool reference contains "___", which breaks legitimate app-local tool names like save___draft: CallTool("save___draft") is serialized unchanged, then call_tool parses it as app=save/tool=draft and returns Unknown tool. This regression affects both string and callable refs (via __name__/metadata) and makes valid tool names unreachable unless users manually hardcode fully-qualified names. The skip condition should only treat names already prefixed for the same app as resolved.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
App names are validated at construction time — FastMCPApp("my___app") raises ValueError. So the only way ___ appears in a tool name on the wire is through the resolver prefix.
The guard if "___" not in fn prevents double-prefixing when a name has already been resolved (e.g. passed through from another layer). A tool literally named save___draft would be unusual, but if it existed, it would still work through normal get_tool resolution — the ___ fallback only activates when normal lookup fails.
| tool: Tool | None = await self.get_tool(name, version=version) | ||
| if tool is None and "___" in name: |
There was a problem hiding this comment.
Route "___"-qualified app calls before generic lookup
For names like Files___store_files, call_tool now tries get_tool first and only falls back to app routing if that fails. If any normal/model-visible tool is registered with the same name, app UI calls are silently redirected to the wrong tool instead of the app-scoped backend tool, which can execute unintended logic and bypass app isolation assumptions. Prefixed names should be resolved through get_app_tool first (or generic tools using this reserved pattern should be rejected) to keep routing deterministic.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The normal-first order is intentional. A tool named Files___store_files would have to be explicitly registered with that exact name — nobody does that accidentally. The ___ separator is reserved for app routing, and app names are validated to not contain it.
Trying app routing first would add a provider tree walk to every single call_tool invocation, even for non-app tools. The current order keeps the common path fast and only does the bypass lookup when normal resolution fails and the name has the separator.
MCP hosts (Goose, MCP Jam, etc.) don't forward
_metaoncallServerTool, which meant app tool routing was broken in every real deployment. The previous approach injected_meta.fastmcp.appinto structured content and expected hosts to echo it back — they don't.This replaces that with a self-contained approach: the Prefab resolver writes
AppName___tool_nameon the wire (e.g.CallTool("store_files")becomes"Files___store_files"), and the server parses the___separator to route viaget_app_tool, bypassing transforms. No host cooperation needed.Both string and callable tool references get prefixed. App names containing
___are rejected at construction time. Theapp_nameparameter is removed fromcall_toolentirely — routing is derived from the tool name alone.