feat(apps): Pass toolInfo to MCP Apps via hostContext (#7506)

Co-authored-by: Goose <opensource@block.xyz>
This commit is contained in:
Andrew Harvard
2026-03-23 09:12:47 -04:00
committed by GitHub
parent 259ca26dfe
commit a43cbe5875
7 changed files with 118 additions and 2 deletions
+3
View File
@@ -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));
+9
View File
@@ -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)]
+3
View File
@@ -8470,6 +8470,9 @@
"description": {
"type": "string"
},
"input_schema": {
"type": "object"
},
"name": {
"type": "string"
},
+3
View File
@@ -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}
/>