refactor(ui): align mcp app rendering with tools list metadata

Signed-off-by: Andrew Harvard <aharvard@squareup.com>
This commit is contained in:
Andrew Harvard
2026-04-16 10:29:47 -04:00
parent 31ccde3967
commit ffcdb6a9ee
9 changed files with 493 additions and 686 deletions
@@ -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);
}),
);
+1 -1
View File
@@ -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[] = [];
@@ -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<string, Promise<AcpToolInfo[]>>();
const resourceCache = new Map<string, Promise<AcpReadResourceResponse>>();
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<string, unknown> {
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<AcpToolInfo[]> {
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<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
async function resolveCatalogDescriptor(
sessionId: string,
descriptor: LookupMcpAppDescriptor,
): Promise<ResourceMcpAppDescriptor> {
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<string>();
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<ResourceMcpAppDescriptor | null> {
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<AcpToolInfo | null> {
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<string, unknown>;
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<HTMLDivElement>(null);
const sandboxFrameRef = useRef<HTMLIFrameElement>(null);
@@ -1420,16 +951,10 @@ export function McpAppToolOutput({
const pendingInitialFrameHeightRef = useRef<number | null>(null);
const appInitializedRef = useRef(false);
const appVisibleRef = useRef(false);
const [html, setHtml] = useState<string | null>(
descriptor.mode === "inline" ? descriptor.html : null,
);
const [html, setHtml] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(descriptor.mode !== "inline");
const [loading, setLoading] = useState(true);
const [dismissed, setDismissed] = useState(false);
const [catalogTool, setCatalogTool] = useState<AcpToolInfo | null>(null);
const [resolvedExtensionName, setResolvedExtensionName] = useState<
string | null
>(getDescriptorExtensionName(descriptor));
const [resourceCsp, setResourceCsp] = useState<AppSandboxCsp | undefined>();
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") {
@@ -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<string, string>();
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 ??
@@ -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;
@@ -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 (
<div className="w-full space-y-3">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
@@ -257,12 +253,10 @@ export function ToolCallAdapter({
</span>
)}
</div>
<McpAppToolOutput
<McpAppView
sessionId={sessionId}
toolName={name}
catalogToolName={catalogName}
catalogEntry={mcpAppCatalogEntry.entry}
status={status}
descriptor={baseMcpAppDescriptor}
toolInput={args}
rawOutput={rawOutput}
resultText={result}
@@ -2,7 +2,7 @@ import { useState } from "react";
import { ChevronRight } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { ToolCallAdapter } from "./ToolCallAdapter";
import type { McpAppMessageRequest } from "./McpAppToolOutput";
import type { McpAppMessageRequest } from "./McpAppView";
import type {
ToolRequestContent,
ToolResponseContent,
@@ -19,7 +19,6 @@ const mockPathExists = vi.fn<(path: string) => Promise<boolean>>();
const mockOpenResolvedPath = vi.fn<(path: string) => Promise<void>>();
const mockAcpReadResource = vi.fn();
const mockAcpGetTools = vi.fn();
const mockAcpListResources = vi.fn();
const { mockAppBridgeInstances } = vi.hoisted(() => ({
mockAppBridgeInstances: [] as Array<Record<string, unknown>>,
}));
@@ -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: "<html><body><h1>MCP App Bench</h1></body></html>",
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: "<html><body><h1>MCP App Bench</h1></body></html>",
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: "<html><body><h1>Restaurants</h1></body></html>",
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', () => {
@@ -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<string, Promise<AcpToolInfo[]>>();
const sessionToolsValueCache = new Map<string, AcpToolInfo[]>();
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<string, unknown> {
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<AcpToolInfo[]> {
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<McpAppCatalogEntry | null> {
const tools = await getSessionTools(sessionId);
return findMcpAppCatalogEntry(tools, toolName);
}
export async function resolveCatalogToolInfo(
sessionId: string,
toolName: string,
extensionName: string | null,
): Promise<AcpToolInfo | null> {
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<McpAppCatalogEntryState>(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;
}