mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
feat(apps): Pass toolInfo to MCP Apps via hostContext (#7506)
Co-authored-by: Goose <opensource@block.xyz>
This commit is contained in:
@@ -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::<Vec<ToolInfo>>();
|
||||
tools.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
@@ -548,6 +548,9 @@ pub struct ToolInfo {
|
||||
pub description: String,
|
||||
pub parameters: Vec<String>,
|
||||
pub permission: Option<PermissionLevel>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = Object)]
|
||||
pub input_schema: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
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)]
|
||||
|
||||
@@ -8470,6 +8470,9 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"input_schema": {
|
||||
"type": "object"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -1454,6 +1454,9 @@ export type ToolExecution = {
|
||||
*/
|
||||
export type ToolInfo = {
|
||||
description: string;
|
||||
input_schema?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
name: string;
|
||||
parameters: Array<string>;
|
||||
permission?: PermissionLevel | null;
|
||||
|
||||
@@ -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<string
|
||||
interface McpAppRendererProps {
|
||||
resourceUri: string;
|
||||
extensionName: string;
|
||||
toolName?: string;
|
||||
sessionId?: string | null;
|
||||
toolInput?: McpAppToolInput;
|
||||
toolInputPartial?: McpAppToolInputPartial;
|
||||
@@ -237,6 +239,7 @@ function appReducer(state: AppState, action: AppAction): AppState {
|
||||
export default function McpAppRenderer({
|
||||
resourceUri,
|
||||
extensionName,
|
||||
toolName,
|
||||
sessionId,
|
||||
toolInput,
|
||||
toolInputPartial,
|
||||
@@ -271,6 +274,42 @@ export default function McpAppRenderer({
|
||||
|
||||
const { resolvedTheme, mcpHostStyles } = useTheme();
|
||||
|
||||
// Fetch the MCP Tool definition (name, description, inputSchema) for hostContext.toolInfo.
|
||||
// Note: the spec also calls for toolInfo.id — the JSON-RPC id of the tools/call request
|
||||
// between the MCP client and server. That id is generated internally by rmcp's transport
|
||||
// layer and isn't surfaced through the extension manager or message stream to the frontend.
|
||||
// Plumbing it would require changes from rmcp → extension_manager → message → SSE → UI.
|
||||
const [mcpTool, setMcpTool] = useState<Tool | null>(null);
|
||||
const toolDefRef = useRef<Tool | null>(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;
|
||||
|
||||
@@ -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<ToolInfo>;
|
||||
|
||||
const cache = new Map<string, Promise<ToolsList | null>>();
|
||||
|
||||
function cacheKey(sessionId: string, extensionName: string | undefined): string {
|
||||
return `${sessionId}:${extensionName ?? ''}`;
|
||||
}
|
||||
|
||||
export function getCachedTools(
|
||||
sessionId: string,
|
||||
extensionName: string | undefined
|
||||
): Promise<ToolsList | null> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user