mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
refactor(ui): align mcp app rendering with tools list metadata
Signed-off-by: Andrew Harvard <aharvard@squareup.com>
This commit is contained in:
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
+34
-542
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user