Route app tool calls through provider chain#3587
Conversation
…istry Adds Provider.get_app_tool(app_name, tool_name) — a dedicated method for finding app-visible tools by their original name, bypassing transforms. AggregateProvider queries children, WrappedProvider delegates to inner, FastMCPProvider delegates to nested server. The default implementation checks _get_tool and matches meta.fastmcp.app. This replaces the process-level _APP_TOOLS registry. Tool routing now works through the provider tree, which exists in every process — no shared state needed for horizontal scaling.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 79450bcc1f
ℹ️ 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".
| tool = await self._get_tool(tool_name) | ||
| if tool is not None: | ||
| meta = tool.meta or {} | ||
| fastmcp_meta = meta.get("fastmcp") | ||
| if isinstance(fastmcp_meta, dict) and fastmcp_meta.get("app") == app_name: |
There was a problem hiding this comment.
Exclude model-only
@app.ui() tools from app routing
Provider.get_app_tool() now accepts any tool whose metadata contains fastmcp.app, and FastMCP.call_tool() prefers that path whenever _meta.fastmcp.app is present (src/fastmcp/server/server.py:1108-1121). @app.ui() tools already carry that tag so they become callable from inside the UI, which breaks the documented visibility=["model"] contract in docs/apps/interactive-apps.mdx:104 and lets a UI invoke its own entry-point or other model-only entry tools.
Useful? React with 👍 / 👎.
| async def get_app_tool(self, app_name: str, tool_name: str) -> Tool | None: | ||
| """Delegate to nested server's get_app_tool, bypassing transforms.""" | ||
| return await self.server.get_app_tool(app_name, tool_name) |
There was a problem hiding this comment.
Wrap mounted app tools before returning them
When app routing crosses a mounted FastMCP, this method returns the child server's raw Tool instead of a FastMCPProviderTool. The parent then executes that object directly via tool._run(...) in src/fastmcp/server/server.py:1108-1128, so the mounted server's middleware never runs for UI-originated CallTools. Any nested FastMCPApp that depends on child middleware for logging, caching, rate limiting, or similar behavior will now behave differently from normal mounted tool calls.
Useful? React with 👍 / 👎.
The previous PR (#3585) replaced UUID global keys with an
_APP_TOOLSregistry keyed by(app_name, tool_name). That was simpler but still process-level state — it doesn't survive horizontal scaling where each tool call can land in a different lambda handler.This moves routing into the provider tree itself.
Providergets a newget_app_tool(app_name, tool_name)method with a default implementation that checks_get_tool()and matchesmeta.fastmcp.app. Aggregate, wrapped, and FastMCPProvider override to delegate through the tree, bypassing transforms at every level.The result: no process-level registry at all. The provider tree IS the registry, and it exists in every process because it's constructed from code at import time.
Tested through 4 levels of nesting (FastMCPApp → namespace → FastMCPProvider → outer server). Two apps with the same tool name are disambiguated by app name. Auth checks still apply.