diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 9314b206b7..f33e18e565 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -551,6 +551,9 @@ async fn get_tools( get_parameter_names(&tool), permission, ) + .with_input_schema(serde_json::Value::Object( + tool.input_schema.as_ref().clone(), + )) }) .collect::>(); tools.sort_by(|a, b| a.name.cmp(&b.name)); diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index 36daf17316..e30bdb334d 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -548,6 +548,9 @@ pub struct ToolInfo { pub description: String, pub parameters: Vec, pub permission: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object)] + pub input_schema: Option, } impl ToolInfo { @@ -562,8 +565,14 @@ impl ToolInfo { description: description.to_string(), parameters, permission, + input_schema: None, } } + + pub fn with_input_schema(mut self, schema: serde_json::Value) -> Self { + self.input_schema = Some(schema); + self + } } #[cfg(test)] diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 2e3671b272..79f32fc4ee 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -8470,6 +8470,9 @@ "description": { "type": "string" }, + "input_schema": { + "type": "object" + }, "name": { "type": "string" }, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 6add7592e8..c842a0a540 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -1454,6 +1454,9 @@ export type ToolExecution = { */ export type ToolInfo = { description: string; + input_schema?: { + [key: string]: unknown; + }; name: string; parameters: Array; permission?: PermissionLevel | null; diff --git a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx index 4d442e69fb..0f67b9e460 100644 --- a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx +++ b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx @@ -23,10 +23,11 @@ import type { McpUiResourcePermissions, McpUiSizeChangedNotification, } from '@modelcontextprotocol/ext-apps/app-bridge'; -import type { CallToolResult, JSONRPCRequest } from '@modelcontextprotocol/sdk/types.js'; +import type { CallToolResult, JSONRPCRequest, Tool } from '@modelcontextprotocol/sdk/types.js'; import { GripHorizontal, Maximize2, PictureInPicture2, X } from 'lucide-react'; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; import { callTool, readResource } from '../../api'; +import { getCachedTools } from './toolsCache'; import { AppEvents } from '../../constants/events'; import { useTheme } from '../../contexts/ThemeContext'; import { cn } from '../../utils'; @@ -140,6 +141,7 @@ async function fetchMcpAppProxyUrl(csp: McpUiResourceCsp | null): Promise(null); + const toolDefRef = useRef(null); + useEffect(() => { + if (!sessionId || !toolName || toolDefRef.current) { + if (toolDefRef.current) setMcpTool(toolDefRef.current); + return; + } + + let cancelled = false; + (async () => { + const tools = await getCachedTools(sessionId, extensionName || undefined); + if (cancelled || !tools) return; + + const prefixedName = extensionName ? `${extensionName}__${toolName}` : toolName; + const match = tools.find((t) => t.name === prefixedName); + if (match) { + const tool: Tool = { + name: toolName, + description: match.description || undefined, + inputSchema: (match.input_schema as Tool['inputSchema']) ?? { type: 'object' as const }, + }; + toolDefRef.current = tool; + setMcpTool(tool); + } + })(); + + return () => { + cancelled = true; + }; + }, [sessionId, toolName, extensionName]); + // Survive StrictMode remounts — replay cached results instead of re-fetching, // which prevents the iframe from being torn down and recreated (visible flicker). // Declared before useReducer so the lazy initializer can read them. @@ -664,7 +703,7 @@ export default function McpAppRenderer({ const hostContext = useMemo((): McpUiHostContext => { const context: McpUiHostContext = { - // todo: toolInfo: {} + toolInfo: mcpTool ? { tool: mcpTool } : undefined, theme: resolvedTheme, styles: mcpHostStyles, displayMode: activeDisplayMode as McpUiDisplayMode, @@ -704,6 +743,7 @@ export default function McpAppRenderer({ containerWidth, containerHeight, effectiveDisplayModes, + mcpTool, ]); const isToolCancelled = !!toolCancelled; diff --git a/ui/desktop/src/components/McpApps/toolsCache.ts b/ui/desktop/src/components/McpApps/toolsCache.ts new file mode 100644 index 0000000000..5ab356383d --- /dev/null +++ b/ui/desktop/src/components/McpApps/toolsCache.ts @@ -0,0 +1,55 @@ +/** + * Module-level cache for MCP tool definitions fetched from /agent/tools. + * + * Multiple McpAppRenderer instances in the same session would otherwise each + * fetch the full tool list on mount (N+1 problem). This cache deduplicates + * those requests per (sessionId, extensionName) and shares the result. + * + * The cache stores promises so that concurrent requests for the same key + * automatically coalesce into a single network call. + */ + +import type { ToolInfo } from '../../api/types.gen'; +import { getTools } from '../../api'; + +type ToolsList = Array; + +const cache = new Map>(); + +function cacheKey(sessionId: string, extensionName: string | undefined): string { + return `${sessionId}:${extensionName ?? ''}`; +} + +export function getCachedTools( + sessionId: string, + extensionName: string | undefined +): Promise { + const key = cacheKey(sessionId, extensionName); + const existing = cache.get(key); + if (existing) return existing; + + const promise = getTools({ + query: { session_id: sessionId, extension_name: extensionName || undefined }, + }) + .then((response) => response.data ?? null) + .catch(() => { + // Evict on failure so the next caller retries + cache.delete(key); + return null; + }); + + cache.set(key, promise); + return promise; +} + +export function clearToolsCache(sessionId?: string): void { + if (!sessionId) { + cache.clear(); + return; + } + for (const key of cache.keys()) { + if (key.startsWith(`${sessionId}:`)) { + cache.delete(key); + } + } +} diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index f3c3df5d3c..033c584d43 100644 --- a/ui/desktop/src/components/ToolCallWithResponse.tsx +++ b/ui/desktop/src/components/ToolCallWithResponse.tsx @@ -115,6 +115,8 @@ function McpAppWrapper({ requestWithMeta.toolCall.status === 'success' ? requestWithMeta.toolCall.value.name : ''; const delimiterIndex = toolCallName.lastIndexOf('__'); const extensionName = delimiterIndex === -1 ? '' : toolCallName.substring(0, delimiterIndex); + const toolName = + delimiterIndex === -1 ? toolCallName : toolCallName.substring(delimiterIndex + 2); const toolArguments = requestWithMeta.toolCall.status === 'success' @@ -139,6 +141,7 @@ function McpAppWrapper({ toolInput={toolInput} toolResult={toolResult} extensionName={extensionName} + toolName={toolName} sessionId={sessionId} append={append} />