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),
|
get_parameter_names(&tool),
|
||||||
permission,
|
permission,
|
||||||
)
|
)
|
||||||
|
.with_input_schema(serde_json::Value::Object(
|
||||||
|
tool.input_schema.as_ref().clone(),
|
||||||
|
))
|
||||||
})
|
})
|
||||||
.collect::<Vec<ToolInfo>>();
|
.collect::<Vec<ToolInfo>>();
|
||||||
tools.sort_by(|a, b| a.name.cmp(&b.name));
|
tools.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
|||||||
@@ -548,6 +548,9 @@ pub struct ToolInfo {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
pub parameters: Vec<String>,
|
pub parameters: Vec<String>,
|
||||||
pub permission: Option<PermissionLevel>,
|
pub permission: Option<PermissionLevel>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[schema(value_type = Object)]
|
||||||
|
pub input_schema: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolInfo {
|
impl ToolInfo {
|
||||||
@@ -562,8 +565,14 @@ impl ToolInfo {
|
|||||||
description: description.to_string(),
|
description: description.to_string(),
|
||||||
parameters,
|
parameters,
|
||||||
permission,
|
permission,
|
||||||
|
input_schema: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_input_schema(mut self, schema: serde_json::Value) -> Self {
|
||||||
|
self.input_schema = Some(schema);
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -8470,6 +8470,9 @@
|
|||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1454,6 +1454,9 @@ export type ToolExecution = {
|
|||||||
*/
|
*/
|
||||||
export type ToolInfo = {
|
export type ToolInfo = {
|
||||||
description: string;
|
description: string;
|
||||||
|
input_schema?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
name: string;
|
name: string;
|
||||||
parameters: Array<string>;
|
parameters: Array<string>;
|
||||||
permission?: PermissionLevel | null;
|
permission?: PermissionLevel | null;
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ import type {
|
|||||||
McpUiResourcePermissions,
|
McpUiResourcePermissions,
|
||||||
McpUiSizeChangedNotification,
|
McpUiSizeChangedNotification,
|
||||||
} from '@modelcontextprotocol/ext-apps/app-bridge';
|
} 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 { GripHorizontal, Maximize2, PictureInPicture2, X } from 'lucide-react';
|
||||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||||
import { callTool, readResource } from '../../api';
|
import { callTool, readResource } from '../../api';
|
||||||
|
import { getCachedTools } from './toolsCache';
|
||||||
import { AppEvents } from '../../constants/events';
|
import { AppEvents } from '../../constants/events';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
import { cn } from '../../utils';
|
import { cn } from '../../utils';
|
||||||
@@ -140,6 +141,7 @@ async function fetchMcpAppProxyUrl(csp: McpUiResourceCsp | null): Promise<string
|
|||||||
interface McpAppRendererProps {
|
interface McpAppRendererProps {
|
||||||
resourceUri: string;
|
resourceUri: string;
|
||||||
extensionName: string;
|
extensionName: string;
|
||||||
|
toolName?: string;
|
||||||
sessionId?: string | null;
|
sessionId?: string | null;
|
||||||
toolInput?: McpAppToolInput;
|
toolInput?: McpAppToolInput;
|
||||||
toolInputPartial?: McpAppToolInputPartial;
|
toolInputPartial?: McpAppToolInputPartial;
|
||||||
@@ -237,6 +239,7 @@ function appReducer(state: AppState, action: AppAction): AppState {
|
|||||||
export default function McpAppRenderer({
|
export default function McpAppRenderer({
|
||||||
resourceUri,
|
resourceUri,
|
||||||
extensionName,
|
extensionName,
|
||||||
|
toolName,
|
||||||
sessionId,
|
sessionId,
|
||||||
toolInput,
|
toolInput,
|
||||||
toolInputPartial,
|
toolInputPartial,
|
||||||
@@ -271,6 +274,42 @@ export default function McpAppRenderer({
|
|||||||
|
|
||||||
const { resolvedTheme, mcpHostStyles } = useTheme();
|
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,
|
// Survive StrictMode remounts — replay cached results instead of re-fetching,
|
||||||
// which prevents the iframe from being torn down and recreated (visible flicker).
|
// which prevents the iframe from being torn down and recreated (visible flicker).
|
||||||
// Declared before useReducer so the lazy initializer can read them.
|
// Declared before useReducer so the lazy initializer can read them.
|
||||||
@@ -664,7 +703,7 @@ export default function McpAppRenderer({
|
|||||||
|
|
||||||
const hostContext = useMemo((): McpUiHostContext => {
|
const hostContext = useMemo((): McpUiHostContext => {
|
||||||
const context: McpUiHostContext = {
|
const context: McpUiHostContext = {
|
||||||
// todo: toolInfo: {}
|
toolInfo: mcpTool ? { tool: mcpTool } : undefined,
|
||||||
theme: resolvedTheme,
|
theme: resolvedTheme,
|
||||||
styles: mcpHostStyles,
|
styles: mcpHostStyles,
|
||||||
displayMode: activeDisplayMode as McpUiDisplayMode,
|
displayMode: activeDisplayMode as McpUiDisplayMode,
|
||||||
@@ -704,6 +743,7 @@ export default function McpAppRenderer({
|
|||||||
containerWidth,
|
containerWidth,
|
||||||
containerHeight,
|
containerHeight,
|
||||||
effectiveDisplayModes,
|
effectiveDisplayModes,
|
||||||
|
mcpTool,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isToolCancelled = !!toolCancelled;
|
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 : '';
|
requestWithMeta.toolCall.status === 'success' ? requestWithMeta.toolCall.value.name : '';
|
||||||
const delimiterIndex = toolCallName.lastIndexOf('__');
|
const delimiterIndex = toolCallName.lastIndexOf('__');
|
||||||
const extensionName = delimiterIndex === -1 ? '' : toolCallName.substring(0, delimiterIndex);
|
const extensionName = delimiterIndex === -1 ? '' : toolCallName.substring(0, delimiterIndex);
|
||||||
|
const toolName =
|
||||||
|
delimiterIndex === -1 ? toolCallName : toolCallName.substring(delimiterIndex + 2);
|
||||||
|
|
||||||
const toolArguments =
|
const toolArguments =
|
||||||
requestWithMeta.toolCall.status === 'success'
|
requestWithMeta.toolCall.status === 'success'
|
||||||
@@ -139,6 +141,7 @@ function McpAppWrapper({
|
|||||||
toolInput={toolInput}
|
toolInput={toolInput}
|
||||||
toolResult={toolResult}
|
toolResult={toolResult}
|
||||||
extensionName={extensionName}
|
extensionName={extensionName}
|
||||||
|
toolName={toolName}
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
append={append}
|
append={append}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user