From ffcdb6a9eeffb226bf698c655f110c176ba3d803 Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Thu, 16 Apr 2026 10:29:47 -0400 Subject: [PATCH] refactor(ui): align mcp app rendering with tools list metadata Signed-off-by: Andrew Harvard --- .../src/features/chat/hooks/useAcpStream.ts | 2 + ui/goose2/src/features/chat/ui/ChatView.tsx | 2 +- .../{McpAppToolOutput.tsx => McpAppView.tsx} | 576 ++---------------- .../src/features/chat/ui/MessageBubble.tsx | 29 +- .../src/features/chat/ui/MessageTimeline.tsx | 2 +- .../src/features/chat/ui/ToolCallAdapter.tsx | 28 +- .../src/features/chat/ui/ToolChainCards.tsx | 2 +- .../ui/__tests__/ToolCallAdapter.test.tsx | 150 ++--- .../src/features/chat/ui/mcpAppCatalog.ts | 388 ++++++++++++ 9 files changed, 493 insertions(+), 686 deletions(-) rename ui/goose2/src/features/chat/ui/{McpAppToolOutput.tsx => McpAppView.tsx} (78%) create mode 100644 ui/goose2/src/features/chat/ui/mcpAppCatalog.ts diff --git a/ui/goose2/src/features/chat/hooks/useAcpStream.ts b/ui/goose2/src/features/chat/hooks/useAcpStream.ts index 06ff677bcc..463043ffc7 100644 --- a/ui/goose2/src/features/chat/hooks/useAcpStream.ts +++ b/ui/goose2/src/features/chat/hooks/useAcpStream.ts @@ -29,6 +29,7 @@ import type { AcpUsageUpdatePayload, AcpReplayCompletePayload, } from "./acpStreamTypes"; +import { warmSessionToolsCatalog } from "../ui/mcpAppCatalog"; function getAssistantProviderId(sessionId: string): string | undefined { const pending = useChatStore @@ -516,6 +517,7 @@ export function useAcpStream(enabled: boolean): void { event.payload.sessionId, event.payload.gooseSessionId, ); + warmSessionToolsCatalog(event.payload.gooseSessionId); }), ); diff --git a/ui/goose2/src/features/chat/ui/ChatView.tsx b/ui/goose2/src/features/chat/ui/ChatView.tsx index 67952efc02..fed71768c7 100644 --- a/ui/goose2/src/features/chat/ui/ChatView.tsx +++ b/ui/goose2/src/features/chat/ui/ChatView.tsx @@ -25,7 +25,7 @@ import { getHomeDir } from "@/shared/api/system"; import { ArtifactPolicyProvider } from "../hooks/ArtifactPolicyContext"; import type { ModelOption } from "../types"; import { ChatContextPanel } from "./ChatContextPanel"; -import type { McpAppMessageRequest } from "./McpAppToolOutput"; +import type { McpAppMessageRequest } from "./McpAppView"; const EMPTY_MODELS: ModelOption[] = []; diff --git a/ui/goose2/src/features/chat/ui/McpAppToolOutput.tsx b/ui/goose2/src/features/chat/ui/McpAppView.tsx similarity index 78% rename from ui/goose2/src/features/chat/ui/McpAppToolOutput.tsx rename to ui/goose2/src/features/chat/ui/McpAppView.tsx index f27d978283..63bf70abff 100644 --- a/ui/goose2/src/features/chat/ui/McpAppToolOutput.tsx +++ b/ui/goose2/src/features/chat/ui/McpAppView.tsx @@ -16,15 +16,13 @@ import { } from "@mcp-ui/client"; import { acpCallTool, - acpGetTools, + acpListResources, acpListPrompts, acpListResourceTemplates, - acpListResources, acpReadResource, type AcpCallToolResult, type AcpListPromptsResult, type AcpListResourceTemplatesResult, - type AcpListResourcesResult, type AcpReadResourceResponse, type AcpToolInfo, } from "@/shared/api/acp"; @@ -36,66 +34,28 @@ import { Collapsible, CollapsibleContent } from "@/shared/ui/collapsible"; import { ToolInput, ToolOutput } from "@/shared/ui/ai-elements/tool"; import type { ToolCallStatus } from "@/shared/types/messages"; import { ChevronDown, Server as ServerIcon } from "lucide-react"; +import { + extractToolTitle, + getCanonicalToolDisplayName, + getSessionTools, + getToolMetaExtensionName, + isToolVisibleToApp, + resolveCatalogToolInfo, + type McpAppCatalogEntry, +} from "./mcpAppCatalog"; const MCP_APP_HOST_DEBUG = import.meta.env.DEV; const INITIAL_APP_FRAME_HEIGHT = 460; -const LEGACY_RESOURCE_URI_META_KEY = "ui/resourceUri"; export const MCP_APP_FRAME_RESIZE_EVENT = "goose2:mcp-app-frame-resize"; -type LegacyMcpAppOutput = { - kind: "mcp_app"; - mimeType?: string; - data?: string; -}; - -type StructuredMcpAppOutput = { - content?: unknown; - extensionName?: string; - isError?: boolean; - structuredContent?: unknown; - _meta?: { - ui?: { - csp?: AppSandboxCsp; - prefersBorder?: boolean; - resourceUri?: string; - }; - }; -}; - -type InlineMcpAppDescriptor = { - mode: "inline"; - html: string; - extensionName: string | null; -}; - -type ResourceMcpAppDescriptor = { - mode: "resource"; - resourceUri: string; - extensionName: string; -}; - -type LookupMcpAppDescriptor = { - mode: "lookup"; - extensionName: string; - toolName: string; - lookupName?: string; -}; - -type McpAppDescriptor = - | InlineMcpAppDescriptor - | ResourceMcpAppDescriptor - | LookupMcpAppDescriptor; - type PromptArgument = { name: string; description?: string; required?: boolean; }; -const sessionToolsCache = new Map>(); const resourceCache = new Map>(); -const LOOKUP_RETRY_DELAYS_MS = [0, 120, 300, 700]; const HOST_APP_INFO = { name: "Goose2", @@ -182,78 +142,6 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } -function canonicalize(value: string): string { - return value.toLowerCase().replace(/[^a-z0-9]+/g, ""); -} - -function deriveExtensionNameFromTitle(toolName: string): string | null { - const [prefix] = toolName.split(":"); - if (!prefix || prefix.trim() === toolName.trim()) { - return null; - } - - const normalized = prefix - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_+|_+$/g, ""); - - return normalized || null; -} - -function extractToolTitle(toolName: string): string { - const separatorIndex = toolName.indexOf(":"); - if (separatorIndex === -1) { - return toolName.trim(); - } - - return toolName.slice(separatorIndex + 1).trim(); -} - -function splitCatalogToolName(toolName: string): { - extensionName: string; - toolName: string; -} { - const separatorIndex = toolName.lastIndexOf("__"); - if (separatorIndex === -1) { - return { - extensionName: "", - toolName, - }; - } - - return { - extensionName: toolName.slice(0, separatorIndex), - toolName: toolName.slice(separatorIndex + 2), - }; -} - -function getToolMetaResourceUri(meta: unknown): string | null { - if (!isRecord(meta)) { - return null; - } - - const ui = meta.ui; - if ( - isRecord(ui) && - typeof ui.resourceUri === "string" && - ui.resourceUri.length > 0 - ) { - return ui.resourceUri; - } - - const legacy = meta[LEGACY_RESOURCE_URI_META_KEY]; - return typeof legacy === "string" && legacy.length > 0 ? legacy : null; -} - -function getToolMetaExtensionName(tool: AcpToolInfo): string | null { - if (isRecord(tool._meta) && typeof tool._meta.goose_extension === "string") { - return tool._meta.goose_extension; - } - - return splitCatalogToolName(tool.name).extensionName || null; -} - function isPromptArgument(value: unknown): value is PromptArgument { return ( isRecord(value) && @@ -350,24 +238,6 @@ function getResourceDocumentHtml( return null; } -function getToolVisibility(meta: unknown): Set<"model" | "app"> { - if (!isRecord(meta) || !isRecord(meta.ui) || !Array.isArray(meta.ui.visibility)) { - return new Set(["model", "app"]); - } - - const visibility = meta.ui.visibility.filter( - (value): value is "model" | "app" => value === "model" || value === "app", - ); - - return visibility.length > 0 - ? new Set(visibility) - : new Set(["model", "app"]); -} - -function isToolVisibleToApp(tool: AcpToolInfo): boolean { - return getToolVisibility(tool._meta).has("app"); -} - function getDefaultFileExtension(mimeType?: string | null): string { switch (mimeType) { case "text/html": @@ -443,20 +313,6 @@ function buildInnerIframeAllowAttribute( return granted.length > 0 ? granted.join("; ") : undefined; } -function getSessionTools(sessionId: string): Promise { - const cached = sessionToolsCache.get(sessionId); - if (cached) { - return cached; - } - - const request = acpGetTools(sessionId).catch((error) => { - sessionToolsCache.delete(sessionId); - throw error; - }); - sessionToolsCache.set(sessionId, request); - return request; -} - function getResourceCacheKey( sessionId: string, uri: string, @@ -486,235 +342,6 @@ function getResourceHtml( return request; } -function invalidateSessionToolsCache(sessionId: string): void { - sessionToolsCache.delete(sessionId); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => { - window.setTimeout(resolve, ms); - }); -} - -async function resolveCatalogDescriptor( - sessionId: string, - descriptor: LookupMcpAppDescriptor, -): Promise { - const tools = await getSessionTools(sessionId); - const targetExtension = canonicalize(descriptor.extensionName); - const targetTool = canonicalize(extractToolTitle(descriptor.toolName)); - const targetLookupName = descriptor.lookupName - ? canonicalize(descriptor.lookupName) - : null; - type CatalogResourceCandidate = { - resourceUri: string | null; - extensionName: string; - catalogTool: string; - canonicalTool: string; - }; - const extensionCatalogNames = new Set(); - const extensionCandidates = tools.flatMap((tool) => { - const extensionName = getToolMetaExtensionName(tool); - if (!extensionName || canonicalize(extensionName) !== targetExtension) { - return []; - } - extensionCatalogNames.add(extensionName); - - const catalogTool = splitCatalogToolName(tool.name).toolName; - const resourceUri = getToolMetaResourceUri(tool._meta); - return [ - { - resourceUri, - extensionName, - catalogTool, - canonicalTool: canonicalize(catalogTool), - }, - ]; - }); - - const matchByCanonicalTool = ( - matcher: string | null, - ): ResourceMcpAppDescriptor | null => { - if (!matcher) { - return null; - } - - const matchedCandidate = extensionCandidates.find( - ( - candidate, - ): candidate is CatalogResourceCandidate & { resourceUri: string } => - Boolean(candidate.resourceUri) && candidate.canonicalTool === matcher, - ); - - return matchedCandidate - ? { - mode: "resource", - resourceUri: matchedCandidate.resourceUri, - extensionName: matchedCandidate.extensionName, - } - : null; - }; - - const exactMatch = - matchByCanonicalTool(targetTool) ?? - matchByCanonicalTool(targetLookupName) ?? - matchByCanonicalTool(targetExtension); - - if (exactMatch) { - return exactMatch; - } - - const resourceCandidates = extensionCandidates.filter( - ( - candidate, - ): candidate is CatalogResourceCandidate & { resourceUri: string } => - Boolean(candidate.resourceUri), - ); - - if (resourceCandidates.length === 1) { - return { - mode: "resource", - resourceUri: resourceCandidates[0].resourceUri, - extensionName: resourceCandidates[0].extensionName, - }; - } - - const resolvedExtensionName = - extensionCatalogNames.size === 1 - ? Array.from(extensionCatalogNames)[0] ?? descriptor.extensionName - : descriptor.extensionName; - - const resourceList = await acpListResources(sessionId, resolvedExtensionName); - const appResources = resourceList.resources.filter( - (resource) => - resource.mimeType === "text/html;profile=mcp-app" || - resource.uri.startsWith("ui://"), - ); - - const getResourceMatchers = (resource: AcpListResourcesResult["resources"][number]) => - new Set( - [ - resource.name, - resource.uri, - ...resource.uri - .replace(/^ui:\/\//, "") - .split(/[/:_-]+/g) - .filter(Boolean), - ] - .filter((value): value is string => typeof value === "string") - .map((value) => canonicalize(value)), - ); - - const matchResource = (matcher: string | null) => { - if (!matcher) { - return null; - } - - const matchedResource = appResources.find((resource) => - getResourceMatchers(resource).has(matcher), - ); - - return matchedResource - ? { - mode: "resource" as const, - resourceUri: matchedResource.uri, - extensionName: resolvedExtensionName, - } - : null; - }; - - const resourceMatch = - matchResource(targetTool) ?? - matchResource(targetLookupName) ?? - matchResource(targetExtension); - - if (resourceMatch) { - return resourceMatch; - } - - if (appResources.length === 1) { - return { - mode: "resource", - resourceUri: appResources[0].uri, - extensionName: resolvedExtensionName, - }; - } - - throw new Error( - `Could not resolve MCP app resource for ${descriptor.extensionName}:${extractToolTitle(descriptor.toolName)}`, - ); -} - -export async function resolveLookupMcpAppDescriptor( - sessionId: string, - descriptor: LookupMcpAppDescriptor, -): Promise { - let lastError: unknown = null; - - for (const delayMs of LOOKUP_RETRY_DELAYS_MS) { - if (delayMs > 0) { - invalidateSessionToolsCache(sessionId); - await sleep(delayMs); - } - - try { - return await resolveCatalogDescriptor(sessionId, descriptor); - } catch (error) { - lastError = error; - } - } - - logMcpAppHost("failed to resolve lookup descriptor", { - sessionId, - descriptor, - error: - lastError instanceof Error ? lastError.message : String(lastError ?? ""), - }); - - return null; -} - -async function loadDescriptorAndResource( - sessionId: string, - descriptor: LookupMcpAppDescriptor | ResourceMcpAppDescriptor, -): Promise<{ - descriptor: ResourceMcpAppDescriptor; - resource: AcpReadResourceResponse; -}> { - if (descriptor.mode === "resource") { - return { - descriptor, - resource: await getResourceHtml(sessionId, descriptor.resourceUri, descriptor.extensionName), - }; - } - - const resolvedDescriptor = await resolveLookupMcpAppDescriptor( - sessionId, - descriptor, - ); - - if (!resolvedDescriptor) { - throw new Error( - `Could not resolve MCP app resource for ${descriptor.extensionName}:${extractToolTitle(descriptor.toolName)}`, - ); - } - - return { - descriptor: resolvedDescriptor, - resource: await getResourceHtml( - sessionId, - resolvedDescriptor.resourceUri, - resolvedDescriptor.extensionName, - ), - }; -} - -function getDescriptorExtensionName( - descriptor: McpAppDescriptor, -): string | null { - return descriptor.extensionName; -} - function getFallbackToolResult( resultText?: string, isError?: boolean, @@ -821,25 +448,17 @@ function normalizeListPromptsResult( }; } -function getCanonicalToolDisplayName(tool: AcpToolInfo): string { - return splitCatalogToolName(tool.name).toolName || tool.name; -} - function normalizeListToolsResult( tools: AcpToolInfo[], extensionName: string | null, ): { tools: HostToolDefinition[] } { - const targetExtension = extensionName ? canonicalize(extensionName) : null; + const targetExtension = extensionName ?? null; return { tools: tools .filter((tool) => { const catalogExtension = getToolMetaExtensionName(tool); - if ( - targetExtension && - (!catalogExtension || - canonicalize(catalogExtension) !== targetExtension) - ) { + if (targetExtension && catalogExtension !== targetExtension) { return false; } @@ -970,35 +589,6 @@ function getServerIconSrc(meta: unknown): string | null { return null; } -async function resolveCatalogToolInfo( - sessionId: string, - toolName: string, - extensionName: string | null, -): Promise { - const tools = await getSessionTools(sessionId); - const targetTool = canonicalize(extractToolTitle(toolName)); - const targetExtension = extensionName ? canonicalize(extensionName) : null; - - for (const tool of tools) { - const catalogToolName = canonicalize(getCanonicalToolDisplayName(tool)); - if (catalogToolName !== targetTool) { - continue; - } - - const catalogExtension = getToolMetaExtensionName(tool); - if ( - targetExtension && - (!catalogExtension || canonicalize(catalogExtension) !== targetExtension) - ) { - continue; - } - - return tool; - } - - return null; -} - function getTextBlocks( content?: | AppMessageParams["content"] @@ -1327,67 +917,10 @@ function buildHostStyles(): HostStyles | undefined { }; } -export function getMcpAppDescriptor( - rawOutput: unknown, - toolName: string, -): McpAppDescriptor | null { - if (!isRecord(rawOutput)) { - return null; - } - - const legacy = rawOutput as LegacyMcpAppOutput; - if ( - legacy.kind === "mcp_app" && - legacy.mimeType === "text/html;profile=mcp-app" && - typeof legacy.data === "string" - ) { - return { - mode: "inline", - html: legacy.data, - extensionName: deriveExtensionNameFromTitle(toolName), - }; - } - - const structured = rawOutput as StructuredMcpAppOutput; - const resourceUri = structured._meta?.ui?.resourceUri; - const lookupName = - isRecord(structured.structuredContent) && - typeof structured.structuredContent.name === "string" && - structured.structuredContent.name.length > 0 - ? structured.structuredContent.name - : undefined; - const extensionName = - typeof structured.extensionName === "string" && - structured.extensionName.length > 0 - ? structured.extensionName - : deriveExtensionNameFromTitle(toolName); - - if (!extensionName) { - return null; - } - - if (typeof resourceUri !== "string" || resourceUri.length === 0) { - return { - mode: "lookup", - extensionName, - toolName, - lookupName, - }; - } - - return { - mode: "resource", - resourceUri, - extensionName, - }; -} - -interface McpAppToolOutputProps { +interface McpAppViewProps { sessionId: string; - toolName: string; - catalogToolName?: string; + catalogEntry: McpAppCatalogEntry; status?: ToolCallStatus; - descriptor: McpAppDescriptor; toolInput: Record; rawOutput?: unknown; resultText?: string; @@ -1396,19 +929,17 @@ interface McpAppToolOutputProps { onFrameResize?: () => void; } -export function McpAppToolOutput({ +export function McpAppView({ sessionId, - toolName, - catalogToolName, + catalogEntry, status, - descriptor, toolInput, rawOutput, resultText, isError, onMessage, onFrameResize, -}: McpAppToolOutputProps) { +}: McpAppViewProps) { const { resolvedTheme } = useTheme(); const frameContainerRef = useRef(null); const sandboxFrameRef = useRef(null); @@ -1420,16 +951,10 @@ export function McpAppToolOutput({ const pendingInitialFrameHeightRef = useRef(null); const appInitializedRef = useRef(false); const appVisibleRef = useRef(false); - const [html, setHtml] = useState( - descriptor.mode === "inline" ? descriptor.html : null, - ); + const [html, setHtml] = useState(null); const [error, setError] = useState(null); - const [loading, setLoading] = useState(descriptor.mode !== "inline"); + const [loading, setLoading] = useState(true); const [dismissed, setDismissed] = useState(false); - const [catalogTool, setCatalogTool] = useState(null); - const [resolvedExtensionName, setResolvedExtensionName] = useState< - string | null - >(getDescriptorExtensionName(descriptor)); const [resourceCsp, setResourceCsp] = useState(); const [resourcePermissions, setResourcePermissions] = useState< AppResourcePermissions | undefined @@ -1444,10 +969,10 @@ export function McpAppToolOutput({ const [bridgeConnected, setBridgeConnected] = useState(false); const [appVisible, setAppVisible] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false); - const sdkToolName = useMemo( - () => catalogToolName ?? toolName, - [catalogToolName, toolName], - ); + const catalogTool = catalogEntry.tool; + const activeExtensionName = catalogEntry.extensionName; + const catalogToolName = catalogEntry.catalogToolName; + const sdkToolName = catalogEntry.catalogToolName; useEffect(() => { let cancelled = false; @@ -1466,40 +991,29 @@ export function McpAppToolOutput({ setAppVisible(false); setDetailsOpen(false); - if (descriptor.mode === "inline") { - setHtml(descriptor.html); - setResolvedExtensionName(descriptor.extensionName); - setResourceCsp(undefined); - setResourcePermissions(undefined); - setResourcePrefersBorder(undefined); - setCatalogTool(null); - setError(null); - setLoading(false); - return; - } - setLoading(true); setError(null); setHtml(null); - setResolvedExtensionName(getDescriptorExtensionName(descriptor)); setResourceCsp(undefined); setResourcePermissions(undefined); setResourcePrefersBorder(undefined); - setCatalogTool(null); - void loadDescriptorAndResource(sessionId, descriptor) - .then((loaded) => { + void getResourceHtml( + sessionId, + catalogEntry.resourceUri, + catalogEntry.extensionName, + ) + .then((resource) => { if (cancelled) { return; } - setResolvedExtensionName(loaded.descriptor.extensionName); - setResourceCsp(getResourceCsp(loaded.resource._meta)); - setResourcePermissions(getResourcePermissions(loaded.resource._meta)); + setResourceCsp(getResourceCsp(resource._meta)); + setResourcePermissions(getResourcePermissions(resource._meta)); setResourcePrefersBorder( - getResourcePrefersBorder(loaded.resource._meta), + getResourcePrefersBorder(resource._meta), ); - setHtml(getResourceDocumentHtml(loaded.resource)); + setHtml(getResourceDocumentHtml(resource)); setLoading(false); }) .catch((resourceError) => { @@ -1518,10 +1032,8 @@ export function McpAppToolOutput({ return () => { cancelled = true; }; - }, [descriptor, sessionId]); + }, [catalogEntry.extensionName, catalogEntry.resourceUri, sessionId]); - const activeExtensionName = - resolvedExtensionName ?? getDescriptorExtensionName(descriptor); const shouldRenderFrameBorder = resourcePrefersBorder !== false; const sandboxUrl = useMemo( () => new URL("/sandbox_proxy.html", window.location.origin), @@ -1534,26 +1046,6 @@ export function McpAppToolOutput({ const hasToolInput = Object.keys(toolInput).length > 0; const hasToolResult = Boolean(toolResult || resultText || isError); - useEffect(() => { - let cancelled = false; - - void resolveCatalogToolInfo(sessionId, sdkToolName, activeExtensionName) - .then((tool) => { - if (!cancelled) { - setCatalogTool(tool); - } - }) - .catch(() => { - if (!cancelled) { - setCatalogTool(null); - } - }); - - return () => { - cancelled = true; - }; - }, [activeExtensionName, sdkToolName, sessionId]); - useEffect(() => { const container = frameContainerRef.current; if (!container || typeof ResizeObserver === "undefined") { diff --git a/ui/goose2/src/features/chat/ui/MessageBubble.tsx b/ui/goose2/src/features/chat/ui/MessageBubble.tsx index 93df963fe5..5fc8dc17bb 100644 --- a/ui/goose2/src/features/chat/ui/MessageBubble.tsx +++ b/ui/goose2/src/features/chat/ui/MessageBubble.tsx @@ -1,4 +1,4 @@ -import { useState, memo } from "react"; +import { useMemo, useState, memo } from "react"; import { useTranslation } from "react-i18next"; import { Copy, @@ -33,10 +33,8 @@ import { import { ToolChainCards, type ToolChainItem } from "./ToolChainCards"; import { ClickableImage } from "./ClickableImage"; import { useArtifactLinkHandler } from "@/features/chat/hooks/useArtifactLinkHandler"; -import { - getMcpAppDescriptor, - type McpAppMessageRequest, -} from "./McpAppToolOutput"; +import type { McpAppMessageRequest } from "./McpAppView"; +import { useHasMcpAppCatalogEntries } from "./mcpAppCatalog"; import type { Message, MessageAttachment, @@ -187,8 +185,9 @@ function groupContentSections(content: MessageContent[]): ContentSection[] { return sections; } -function hasMcpAppContent(content: MessageContent[]): boolean { +function getMcpAppCatalogToolNames(content: MessageContent[]): string[] { const requestCatalogNames = new Map(); + const toolNames: string[] = []; for (const block of content) { if (block.type === "toolRequest") { @@ -196,7 +195,7 @@ function hasMcpAppContent(content: MessageContent[]): boolean { continue; } - if (block.type !== "toolResponse" || !block.rawOutput) { + if (block.type !== "toolResponse") { continue; } @@ -206,12 +205,12 @@ function hasMcpAppContent(content: MessageContent[]): boolean { block.name ?? ""; - if (toolName && getMcpAppDescriptor(block.rawOutput, toolName)) { - return true; + if (toolName) { + toolNames.push(toolName); } } - return false; + return toolNames; } function renderContentBlock( @@ -376,7 +375,15 @@ export const MessageBubble = memo(function MessageBubble({ } const isUser = role === "user"; - const hasMcpApp = !isUser && hasMcpAppContent(content); + const mcpAppCatalogToolNames = useMemo( + () => getMcpAppCatalogToolNames(content), + [content], + ); + const hasMcpAppCatalogContent = useHasMcpAppCatalogEntries( + isUser ? undefined : sessionId, + isUser ? [] : mcpAppCatalogToolNames, + ); + const hasMcpApp = !isUser && hasMcpAppCatalogContent; const assistantProviderId = message.metadata?.providerId; const assistantProviderName = assistantProviderId ? (getCatalogEntry(assistantProviderId)?.displayName ?? diff --git a/ui/goose2/src/features/chat/ui/MessageTimeline.tsx b/ui/goose2/src/features/chat/ui/MessageTimeline.tsx index 0e74e965f1..d7b3dfb3c9 100644 --- a/ui/goose2/src/features/chat/ui/MessageTimeline.tsx +++ b/ui/goose2/src/features/chat/ui/MessageTimeline.tsx @@ -7,7 +7,7 @@ import { getTextContent, type Message } from "@/shared/types/messages"; import { MCP_APP_FRAME_RESIZE_EVENT, type McpAppMessageRequest, -} from "./McpAppToolOutput"; +} from "./McpAppView"; interface MessageTimelineProps { sessionId?: string; diff --git a/ui/goose2/src/features/chat/ui/ToolCallAdapter.tsx b/ui/goose2/src/features/chat/ui/ToolCallAdapter.tsx index 70b3b0407c..2dbf56f131 100644 --- a/ui/goose2/src/features/chat/ui/ToolCallAdapter.tsx +++ b/ui/goose2/src/features/chat/ui/ToolCallAdapter.tsx @@ -16,11 +16,8 @@ import { toolStatusMap } from "../lib/toolStatusMap"; import type { ToolCallStatus } from "@/shared/types/messages"; import { useArtifactPolicyContext } from "@/features/chat/hooks/ArtifactPolicyContext"; import type { ArtifactPathCandidate } from "@/features/chat/lib/artifactPathPolicy"; -import { - getMcpAppDescriptor, - McpAppToolOutput, - type McpAppMessageRequest, -} from "./McpAppToolOutput"; +import { McpAppView, type McpAppMessageRequest } from "./McpAppView"; +import { useMcpAppCatalogEntry } from "./mcpAppCatalog"; interface ToolCallAdapterProps { sessionId?: string; @@ -232,20 +229,19 @@ export function ToolCallAdapter({ }: ToolCallAdapterProps) { const elapsed = useElapsedTime(status, startedAt); const state = toolStatusMap[status]; - const descriptorToolName = catalogName ?? name; - const baseMcpAppDescriptor = useMemo( - () => - !isError && rawOutput && sessionId - ? getMcpAppDescriptor(rawOutput, descriptorToolName) - : null, - [descriptorToolName, isError, rawOutput, sessionId], + const catalogToolName = catalogName ?? name; + const shouldRenderMcpApp = !isError && Boolean(sessionId); + const mcpAppCatalogEntry = useMcpAppCatalogEntry( + sessionId, + catalogToolName, + shouldRenderMcpApp, ); const output = (rawOutput ?? result) as ToolOutputProps["output"]; const elapsedSeconds = status === "executing" && elapsed >= 3 ? elapsed : undefined; - if (baseMcpAppDescriptor && sessionId) { + if (mcpAppCatalogEntry.entry && sessionId) { return (
@@ -257,12 +253,10 @@ export function ToolCallAdapter({ )}
- Promise>(); const mockOpenResolvedPath = vi.fn<(path: string) => Promise>(); const mockAcpReadResource = vi.fn(); const mockAcpGetTools = vi.fn(); -const mockAcpListResources = vi.fn(); const { mockAppBridgeInstances } = vi.hoisted(() => ({ mockAppBridgeInstances: [] as Array>, })); @@ -42,7 +41,6 @@ vi.mock("@/shared/api/acp", async () => { ...actual, acpReadResource: (...args: unknown[]) => mockAcpReadResource(...args), acpGetTools: (...args: unknown[]) => mockAcpGetTools(...args), - acpListResources: (...args: unknown[]) => mockAcpListResources(...args), }; }); @@ -127,15 +125,14 @@ describe("ToolCallAdapter — ArtifactActions", () => { mockOpenResolvedPath.mockReset(); mockAcpReadResource.mockReset(); mockAcpGetTools.mockReset(); - mockAcpListResources.mockReset(); mockAppBridgeInstances.length = 0; }); - it("renders MCP apps inline when lookup falls back from a summarized tool title", async () => { + it("renders MCP apps inline when cached tools/list metadata links the canonical tool", async () => { mockResolveToolCardDisplay.mockReturnValue(EMPTY_DISPLAY); mockAcpGetTools.mockResolvedValue([ { - name: "mcpappbench___mcp_app_bench", + name: "mcpappbench__mcp_app_bench", _meta: { goose_extension: "mcpappbench", ui: { @@ -154,7 +151,7 @@ describe("ToolCallAdapter — ArtifactActions", () => { renderAdapter({ sessionId: "session-summarized-title", name: "running mcp app bench tool", - catalogName: "running mcp app bench tool", + catalogName: "mcpappbench: mcp app bench", open: true, rawOutput: { content: [ @@ -173,6 +170,7 @@ describe("ToolCallAdapter — ArtifactActions", () => { }); expect(await screen.findByTestId("mcp-app-frame")).toBeInTheDocument(); + expect(mockAcpGetTools).toHaveBeenCalledWith("session-summarized-title"); expect(mockAcpReadResource).toHaveBeenCalledWith( "session-summarized-title", "ui://mcp-app-bench/launcher", @@ -180,32 +178,21 @@ describe("ToolCallAdapter — ArtifactActions", () => { ); }); - it("keeps the MCP app path alive when tools/list is briefly unavailable", async () => { + it("does not render an MCP app when the matched tools/list entry has no resource URI", async () => { mockResolveToolCardDisplay.mockReturnValue(EMPTY_DISPLAY); - mockAcpGetTools - .mockRejectedValueOnce(new Error("catalog not ready")) - .mockResolvedValue([ - { - name: "mcpappbench___mcp_app_bench", - _meta: { - goose_extension: "mcpappbench", - ui: { - resourceUri: "ui://mcp-app-bench/launcher", - }, - }, + mockAcpGetTools.mockResolvedValue([ + { + name: "mcpappbench__mcp_app_bench", + _meta: { + goose_extension: "mcpappbench", }, - ]); - mockAcpReadResource.mockResolvedValue({ - uri: "ui://mcp-app-bench/launcher", - text: "

MCP App Bench

", - mimeType: "text/html;profile=mcp-app", - _meta: null, - }); + }, + ]); renderAdapter({ - sessionId: "session-lookup-retry", + sessionId: "session-no-resource-uri", name: "running mcp app benchmark suite", - catalogName: "running mcp app benchmark suite", + catalogName: "mcpappbench: mcp app bench", open: true, rawOutput: { content: [ @@ -223,47 +210,40 @@ describe("ToolCallAdapter — ArtifactActions", () => { result: "MCP App Bench launcher loaded.", }); - expect(await screen.findByTestId("mcp-app-frame")).toBeInTheDocument(); - expect(mockAcpGetTools).toHaveBeenCalledTimes(2); - expect(mockAcpReadResource).toHaveBeenCalledWith( - "session-lookup-retry", - "ui://mcp-app-bench/launcher", - "mcpappbench", + await vi.waitFor(() => + expect(mockAcpGetTools).toHaveBeenCalledWith("session-no-resource-uri"), ); + expect(await screen.findByText("MCP App Bench launcher loaded.")).toBeInTheDocument(); + expect(screen.queryByTestId("mcp-app-frame")).not.toBeInTheDocument(); + expect(mockAcpReadResource).not.toHaveBeenCalled(); }); - it("falls back to extension resources when tool metadata omits the resource URI", async () => { + it("does not render an MCP app when no cached tools/list entry matches the catalog tool name", async () => { mockResolveToolCardDisplay.mockReturnValue(EMPTY_DISPLAY); mockAcpGetTools.mockResolvedValue([ { - name: "mcpappbench___launcher", + name: "mcpappbench__different_tool", _meta: { goose_extension: "mcpappbench", + ui: { + resourceUri: "ui://mcp-app-bench/launcher", + }, }, }, ]); - mockAcpListResources.mockResolvedValue({ - resources: [ - { - uri: "ui://mcp-app-bench/launcher", - name: "mcp-app-bench", - mimeType: "text/html;profile=mcp-app", - }, - ], - }); - mockAcpReadResource.mockResolvedValue({ - uri: "ui://mcp-app-bench/launcher", - text: "

MCP App Bench

", - mimeType: "text/html;profile=mcp-app", - _meta: null, - }); renderAdapter({ - sessionId: "session-resource-list-fallback", + sessionId: "session-no-tool-match", name: "running mcp app benchmark", - catalogName: "running mcp app benchmark", + catalogName: "mcpappbench: mcp app bench", open: true, rawOutput: { + content: [ + { + type: "text", + text: "Interactive app available", + }, + ], extensionName: "mcpappbench", structuredContent: { name: "mcp-app-bench", @@ -272,68 +252,12 @@ describe("ToolCallAdapter — ArtifactActions", () => { result: "Interactive app available", }); - expect(await screen.findByTestId("mcp-app-frame")).toBeInTheDocument(); - expect(mockAcpListResources).toHaveBeenCalledWith( - "session-resource-list-fallback", - "mcpappbench", - ); - expect(mockAcpReadResource).toHaveBeenCalledWith( - "session-resource-list-fallback", - "ui://mcp-app-bench/launcher", - "mcpappbench", - ); - }); - - it("uses the exact catalog extension name for resource-list fallback", async () => { - mockResolveToolCardDisplay.mockReturnValue(EMPTY_DISPLAY); - mockAcpGetTools.mockResolvedValue([ - { - name: "axonneighborhood_aharavard___get-restaurants-nearby", - _meta: { - goose_extension: "axonneighborhood_aharavard_", - }, - }, - ]); - mockAcpListResources.mockResolvedValue({ - resources: [ - { - uri: "ui://widget/restaurants-widget-5b5f69b9.html", - name: "restaurants-widget", - mimeType: "text/html;profile=mcp-app", - }, - ], - }); - mockAcpReadResource.mockResolvedValue({ - uri: "ui://widget/restaurants-widget-5b5f69b9.html", - text: "

Restaurants

", - mimeType: "text/html;profile=mcp-app", - _meta: null, - }); - - renderAdapter({ - sessionId: "session-exact-extension-name", - name: "getting restaurants near new york", - catalogName: "axonneighborhood aharavard: get-restaurants-nearby", - open: true, - rawOutput: { - extensionName: "axonneighborhood_aharavard", - structuredContent: { - name: "restaurants-widget", - }, - }, - result: "Interactive app available", - }); - - expect(await screen.findByTestId("mcp-app-frame")).toBeInTheDocument(); - expect(mockAcpListResources).toHaveBeenCalledWith( - "session-exact-extension-name", - "axonneighborhood_aharavard_", - ); - expect(mockAcpReadResource).toHaveBeenCalledWith( - "session-exact-extension-name", - "ui://widget/restaurants-widget-5b5f69b9.html", - "axonneighborhood_aharavard_", + await vi.waitFor(() => + expect(mockAcpGetTools).toHaveBeenCalledWith("session-no-tool-match"), ); + expect(await screen.findByText("Interactive app available")).toBeInTheDocument(); + expect(screen.queryByTestId("mcp-app-frame")).not.toBeInTheDocument(); + expect(mockAcpReadResource).not.toHaveBeenCalled(); }); it('renders "Open file" button when primary candidate exists', () => { diff --git a/ui/goose2/src/features/chat/ui/mcpAppCatalog.ts b/ui/goose2/src/features/chat/ui/mcpAppCatalog.ts new file mode 100644 index 0000000000..b13a5b4918 --- /dev/null +++ b/ui/goose2/src/features/chat/ui/mcpAppCatalog.ts @@ -0,0 +1,388 @@ +import { useEffect, useMemo, useState } from "react"; +import { acpGetTools, type AcpToolInfo } from "@/shared/api/acp"; + +const LEGACY_RESOURCE_URI_META_KEY = "ui/resourceUri"; + +const sessionToolsPromiseCache = new Map>(); +const sessionToolsValueCache = new Map(); + +export type McpAppCatalogEntry = { + tool: AcpToolInfo; + extensionName: string; + resourceUri: string; + catalogToolName: string; + canonicalToolName: string; +}; + +type McpAppCatalogEntryState = { + status: "idle" | "loading" | "ready"; + entry: McpAppCatalogEntry | null; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function canonicalize(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, ""); +} + +export function formatCatalogToolName(toolName: string): string { + const separatorIndex = toolName.indexOf("__"); + if (separatorIndex === -1) { + return toolName.replaceAll("_", " ").trim(); + } + + const extension = toolName.slice(0, separatorIndex); + const tool = toolName.slice(separatorIndex + 2); + return `${extension.replaceAll("_", " ").trim()}: ${tool.replaceAll("_", " ").trim()}`; +} + +function splitOwnedToolName(toolName: string): { + extensionName: string; + toolName: string; +} { + const separatorIndex = toolName.indexOf("__"); + if (separatorIndex === -1) { + return { + extensionName: "", + toolName, + }; + } + + return { + extensionName: toolName.slice(0, separatorIndex), + toolName: toolName.slice(separatorIndex + 2), + }; +} + +export function extractToolTitle(toolName: string): string { + const separatorIndex = toolName.indexOf(":"); + if (separatorIndex === -1) { + return toolName.trim(); + } + + return toolName.slice(separatorIndex + 1).trim(); +} + +export function getToolMetaResourceUri(meta: unknown): string | null { + if (!isRecord(meta)) { + return null; + } + + const ui = meta.ui; + if ( + isRecord(ui) && + typeof ui.resourceUri === "string" && + ui.resourceUri.length > 0 + ) { + return ui.resourceUri; + } + + const legacy = meta[LEGACY_RESOURCE_URI_META_KEY]; + return typeof legacy === "string" && legacy.length > 0 ? legacy : null; +} + +export function getToolMetaExtensionName(tool: AcpToolInfo): string | null { + if (isRecord(tool._meta) && typeof tool._meta.goose_extension === "string") { + return tool._meta.goose_extension; + } + + return splitOwnedToolName(tool.name).extensionName || null; +} + +function getToolVisibility(meta: unknown): Set<"model" | "app"> { + if ( + !isRecord(meta) || + !isRecord(meta.ui) || + !Array.isArray(meta.ui.visibility) + ) { + return new Set(["model", "app"]); + } + + const visibility = meta.ui.visibility.filter( + (value): value is "model" | "app" => value === "model" || value === "app", + ); + + return visibility.length > 0 + ? new Set(visibility) + : new Set(["model", "app"]); +} + +export function isToolVisibleToApp(tool: AcpToolInfo): boolean { + return getToolVisibility(tool._meta).has("app"); +} + +export function getCanonicalToolDisplayName(tool: AcpToolInfo): string { + return splitOwnedToolName(tool.name).toolName || tool.name; +} + +function matchesCatalogToolName(tool: AcpToolInfo, toolName: string): boolean { + const target = canonicalize(toolName); + if (!target) { + return false; + } + + return ( + canonicalize(tool.name) === target || + canonicalize(formatCatalogToolName(tool.name)) === target + ); +} + +function buildCatalogEntry(tool: AcpToolInfo): McpAppCatalogEntry | null { + const resourceUri = getToolMetaResourceUri(tool._meta); + const extensionName = getToolMetaExtensionName(tool); + if (!resourceUri || !extensionName) { + return null; + } + + return { + tool, + extensionName, + resourceUri, + catalogToolName: formatCatalogToolName(tool.name), + canonicalToolName: getCanonicalToolDisplayName(tool), + }; +} + +function findMcpAppCatalogEntry( + tools: AcpToolInfo[], + toolName: string, +): McpAppCatalogEntry | null { + for (const tool of tools) { + if (!matchesCatalogToolName(tool, toolName)) { + continue; + } + + return buildCatalogEntry(tool); + } + + return null; +} + +export function getSessionTools(sessionId: string): Promise { + const cached = sessionToolsPromiseCache.get(sessionId); + if (cached) { + return cached; + } + + const request = acpGetTools(sessionId) + .then((tools) => { + sessionToolsValueCache.set(sessionId, tools); + return tools; + }) + .catch((error) => { + sessionToolsPromiseCache.delete(sessionId); + sessionToolsValueCache.delete(sessionId); + throw error; + }); + + sessionToolsPromiseCache.set(sessionId, request); + return request; +} + +export function warmSessionToolsCatalog(sessionId: string): void { + void getSessionTools(sessionId).catch(() => undefined); +} + +export function invalidateSessionToolsCache(sessionId: string): void { + sessionToolsPromiseCache.delete(sessionId); + sessionToolsValueCache.delete(sessionId); +} + +export function getCachedMcpAppCatalogEntry( + sessionId: string, + toolName: string, +): McpAppCatalogEntry | null { + const tools = sessionToolsValueCache.get(sessionId); + return tools ? findMcpAppCatalogEntry(tools, toolName) : null; +} + +export async function resolveMcpAppCatalogEntry( + sessionId: string, + toolName: string, +): Promise { + const tools = await getSessionTools(sessionId); + return findMcpAppCatalogEntry(tools, toolName); +} + +export async function resolveCatalogToolInfo( + sessionId: string, + toolName: string, + extensionName: string | null, +): Promise { + const tools = await getSessionTools(sessionId); + const targetTool = canonicalize(toolName); + const targetExtension = extensionName ? canonicalize(extensionName) : null; + + for (const tool of tools) { + const catalogExtension = getToolMetaExtensionName(tool); + if ( + targetExtension && + (!catalogExtension || + canonicalize(catalogExtension) !== targetExtension) + ) { + continue; + } + + if (canonicalize(getCanonicalToolDisplayName(tool)) !== targetTool) { + continue; + } + + return tool; + } + + return null; +} + +function getInitialEntryState( + sessionId: string | undefined, + toolName: string | undefined, + enabled: boolean, +): McpAppCatalogEntryState { + if (!enabled || !sessionId || !toolName) { + return { + status: "idle", + entry: null, + }; + } + + const cachedEntry = getCachedMcpAppCatalogEntry(sessionId, toolName); + if (cachedEntry) { + return { + status: "ready", + entry: cachedEntry, + }; + } + + return { + status: "loading", + entry: null, + }; +} + +export function useMcpAppCatalogEntry( + sessionId: string | undefined, + toolName: string | undefined, + enabled = true, +): McpAppCatalogEntryState { + const initialState = useMemo( + () => getInitialEntryState(sessionId, toolName, enabled), + [enabled, sessionId, toolName], + ); + const [state, setState] = useState(initialState); + + useEffect(() => { + if (!enabled || !sessionId || !toolName) { + setState({ + status: "idle", + entry: null, + }); + return; + } + + const cachedEntry = getCachedMcpAppCatalogEntry(sessionId, toolName); + if (cachedEntry) { + setState({ + status: "ready", + entry: cachedEntry, + }); + return; + } + + let cancelled = false; + setState({ + status: "loading", + entry: null, + }); + + void resolveMcpAppCatalogEntry(sessionId, toolName) + .then((entry) => { + if (cancelled) { + return; + } + + setState({ + status: "ready", + entry, + }); + }) + .catch(() => { + if (!cancelled) { + setState({ + status: "ready", + entry: null, + }); + } + }); + + return () => { + cancelled = true; + }; + }, [enabled, sessionId, toolName]); + + return state; +} + +export function useHasMcpAppCatalogEntries( + sessionId: string | undefined, + toolNames: string[], +): boolean { + const normalizedToolNames = useMemo( + () => + toolNames + .map((toolName) => toolName.trim()) + .filter(Boolean) + .filter((toolName, index, values) => values.indexOf(toolName) === index), + [toolNames], + ); + const [hasEntry, setHasEntry] = useState(() => { + if (!sessionId) { + return false; + } + + return normalizedToolNames.some( + (toolName) => getCachedMcpAppCatalogEntry(sessionId, toolName) !== null, + ); + }); + + useEffect(() => { + if (!sessionId || normalizedToolNames.length === 0) { + setHasEntry(false); + return; + } + + const cachedMatch = normalizedToolNames.some( + (toolName) => getCachedMcpAppCatalogEntry(sessionId, toolName) !== null, + ); + if (cachedMatch) { + setHasEntry(true); + return; + } + + let cancelled = false; + setHasEntry(false); + + void Promise.all( + normalizedToolNames.map((toolName) => + resolveMcpAppCatalogEntry(sessionId, toolName), + ), + ) + .then((entries) => { + if (!cancelled) { + setHasEntry(entries.some((entry) => entry !== null)); + } + }) + .catch(() => { + if (!cancelled) { + setHasEntry(false); + } + }); + + return () => { + cancelled = true; + }; + }, [normalizedToolNames, sessionId]); + + return hasEntry; +}