diff --git a/src/fastmcp/server/app.py b/src/fastmcp/server/app.py index 730aeb031..5ee9d2a52 100644 --- a/src/fastmcp/server/app.py +++ b/src/fastmcp/server/app.py @@ -354,9 +354,12 @@ class FastMCPApp(Provider): if not isinstance(tool, Tool): tool = Tool._ensure_tool(tool) - # Tag with app name for routing + # Tag with app name and visibility for routing meta = dict(tool.meta) if tool.meta else {} meta.setdefault("fastmcp", {})["app"] = self.name + ui = meta.setdefault("ui", {}) + if "visibility" not in ui: + ui["visibility"] = ["app"] tool.meta = meta self._local._add_component(tool) diff --git a/src/fastmcp/server/providers/base.py b/src/fastmcp/server/providers/base.py index 107eb3198..280c0c86c 100644 --- a/src/fastmcp/server/providers/base.py +++ b/src/fastmcp/server/providers/base.py @@ -194,7 +194,16 @@ class Provider: 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: + ui_meta = meta.get("ui") + # Must match app name AND have app visibility (not model-only) + visibility = ( + ui_meta.get("visibility", []) if isinstance(ui_meta, dict) else [] + ) + if ( + isinstance(fastmcp_meta, dict) + and fastmcp_meta.get("app") == app_name + and "app" in visibility + ): return tool return None diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py index 789a7c190..6606fb8e4 100644 --- a/src/fastmcp/server/providers/fastmcp_provider.py +++ b/src/fastmcp/server/providers/fastmcp_provider.py @@ -137,11 +137,23 @@ class FastMCPProviderTool(Tool): # Pass exact version so child executes the correct version version = VersionSpec(eq=self.version) if self.version else None + # If this tool belongs to a FastMCPApp, pass app_name so the + # child server routes via get_app_tool (bypassing transforms). + app_name: str | None = None + meta = self.meta or {} + fastmcp_meta = meta.get("fastmcp") + if isinstance(fastmcp_meta, dict): + app_name = fastmcp_meta.get("app") + with delegate_span( self._original_name or "", "FastMCPProvider", self._original_name or "" ): return await self._server.call_tool( - self._original_name, arguments, version=version, task_meta=task_meta + self._original_name, + arguments, + version=version, + task_meta=task_meta, + app_name=app_name, ) async def run(self, arguments: dict[str, Any]) -> ToolResult: @@ -153,8 +165,14 @@ class FastMCPProviderTool(Tool): # Pass exact version so child executes the correct version version = VersionSpec(eq=self.version) if self.version else None + app_name: str | None = None + meta = self.meta or {} + fastmcp_meta = meta.get("fastmcp") + if isinstance(fastmcp_meta, dict): + app_name = fastmcp_meta.get("app") + result = await self._server.call_tool( - self._original_name, arguments, version=version + self._original_name, arguments, version=version, app_name=app_name ) # Result from call_tool should always be ToolResult when no task_meta if isinstance(result, mcp.types.CreateTaskResult): @@ -563,8 +581,11 @@ class FastMCPProvider(Provider): return FastMCPProviderTool.wrap(self.server, raw_tool) 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) + """Delegate to nested server's get_app_tool, wrapping for middleware.""" + raw_tool = await self.server.get_app_tool(app_name, tool_name) + if raw_tool is None: + return None + return FastMCPProviderTool.wrap(self.server, raw_tool) # ------------------------------------------------------------------------- # Resource methods diff --git a/tests/test_fastmcp_app.py b/tests/test_fastmcp_app.py index e5ea97565..6fdeb2aed 100644 --- a/tests/test_fastmcp_app.py +++ b/tests/test_fastmcp_app.py @@ -386,8 +386,9 @@ class TestGetAppTool: assert tool is not None assert tool.name == "save_contact" - async def test_ui_tool_findable_by_app_name(self): - """@app.ui() tools are also tagged with app name.""" + async def test_ui_tool_not_findable_via_get_app_tool(self): + """@app.ui() tools have model visibility and should NOT be + returned by get_app_tool (only app-visible tools are).""" app = FastMCPApp("dashboard") @app.ui() @@ -395,6 +396,17 @@ class TestGetAppTool: return "ui" tool = await app.get_app_tool("dashboard", "show") + assert tool is None + + async def test_model_visible_tool_findable(self): + """@app.tool(model=True) has app visibility and IS findable.""" + app = FastMCPApp("test") + + @app.tool(model=True) + def query(q: str) -> str: + return q + + tool = await app.get_app_tool("test", "query") assert tool is not None