"use client";
import React, { useEffect, useRef, useState, useCallback } from "react";
import { z } from "zod";
import type { AbstractAgent, RunAgentResult } from "@ag-ui/client";
import { useCopilotKit } from "../providers/CopilotKitProvider";
// Protocol version supported
const PROTOCOL_VERSION = "2025-06-18";
// Build sandbox proxy HTML with optional extra CSP domains from resource metadata
function buildSandboxHTML(extraCspDomains?: string[]): string {
const baseScriptSrc =
"'self' 'wasm-unsafe-eval' 'unsafe-inline' 'unsafe-eval' blob: data: http://localhost:* https://localhost:*";
const baseFrameSrc = "* blob: data: http://localhost:* https://localhost:*";
const extra = extraCspDomains?.length ? " " + extraCspDomains.join(" ") : "";
const scriptSrc = baseScriptSrc + extra;
const frameSrc = baseFrameSrc + extra;
return `
`;
}
/**
* Queue for serializing MCP app requests to an agent.
* Ensures requests wait for the agent to stop running and are processed one at a time.
*/
class MCPAppsRequestQueue {
private queues = new Map<
string,
Array<{
execute: () => Promise;
resolve: (result: RunAgentResult) => void;
reject: (error: Error) => void;
}>
>();
private processing = new Map();
/**
* Add a request to the queue for a specific agent thread.
* Returns a promise that resolves when the request completes.
*/
async enqueue(
agent: AbstractAgent,
request: () => Promise,
): Promise {
const threadId = agent.threadId || "default";
return new Promise((resolve, reject) => {
// Get or create queue for this thread
let queue = this.queues.get(threadId);
if (!queue) {
queue = [];
this.queues.set(threadId, queue);
}
// Add request to queue
queue.push({ execute: request, resolve, reject });
// Start processing if not already running
this.processQueue(threadId, agent);
});
}
private async processQueue(
threadId: string,
agent: AbstractAgent,
): Promise {
// If already processing this queue, return
if (this.processing.get(threadId)) {
return;
}
this.processing.set(threadId, true);
try {
const queue = this.queues.get(threadId);
if (!queue) return;
while (queue.length > 0) {
const item = queue[0]!;
try {
// Wait for any active run to complete before processing
await this.waitForAgentIdle(agent);
// Execute the request
const result = await item.execute();
item.resolve(result);
} catch (error) {
item.reject(
error instanceof Error ? error : new Error(String(error)),
);
}
// Remove processed item
queue.shift();
}
} finally {
this.processing.set(threadId, false);
}
}
private waitForAgentIdle(agent: AbstractAgent): Promise {
return new Promise((resolve) => {
if (!agent.isRunning) {
resolve();
return;
}
let done = false;
const finish = () => {
if (done) return;
done = true;
clearInterval(checkInterval);
sub.unsubscribe();
resolve();
};
const sub = agent.subscribe({
onRunFinalized: finish,
onRunFailed: finish,
});
// Fallback for reconnect scenarios where events don't fire
const checkInterval = setInterval(() => {
if (!agent.isRunning) finish();
}, 500);
});
}
}
// Global queue instance for all MCP app requests
const mcpAppsRequestQueue = new MCPAppsRequestQueue();
/**
* Activity type for MCP Apps events - must match the middleware's MCPAppsActivityType
*/
export const MCPAppsActivityType = "mcp-apps";
// Zod schema for activity content validation (middleware 0.0.2 format)
export const MCPAppsActivityContentSchema = z.object({
result: z.object({
content: z.array(z.any()).optional(),
structuredContent: z.any().optional(),
isError: z.boolean().optional(),
}),
// Resource URI to fetch (e.g., "ui://server/dashboard")
resourceUri: z.string(),
// MD5 hash of server config (renamed from serverId in 0.0.1)
serverHash: z.string(),
// Optional stable server ID from config (takes precedence over serverHash)
serverId: z.string().optional(),
// Original tool input arguments
toolInput: z.record(z.unknown()).optional(),
});
export type MCPAppsActivityContent = z.infer<
typeof MCPAppsActivityContentSchema
>;
// Type for the resource fetched from the server
interface FetchedResource {
uri: string;
mimeType?: string;
text?: string;
blob?: string;
_meta?: {
ui?: {
prefersBorder?: boolean;
csp?: {
connectDomains?: string[];
resourceDomains?: string[];
};
};
};
}
interface JSONRPCRequest {
jsonrpc: "2.0";
id: string | number;
method: string;
params?: Record;
}
interface JSONRPCResponse {
jsonrpc: "2.0";
id: string | number;
result?: unknown;
error?: { code: number; message: string };
}
interface JSONRPCNotification {
jsonrpc: "2.0";
method: string;
params?: Record;
}
type JSONRPCMessage = JSONRPCRequest | JSONRPCResponse | JSONRPCNotification;
function isRequest(msg: JSONRPCMessage): msg is JSONRPCRequest {
return "id" in msg && "method" in msg;
}
function isNotification(msg: JSONRPCMessage): msg is JSONRPCNotification {
return !("id" in msg) && "method" in msg;
}
/**
* Props for the activity renderer component
*/
interface MCPAppsActivityRendererProps {
activityType: string;
content: MCPAppsActivityContent;
message: unknown; // ActivityMessage from @ag-ui/core
agent: AbstractAgent | undefined;
}
/**
* MCP Apps Extension Activity Renderer
*
* Renders MCP Apps UI in a sandboxed iframe with full protocol support.
* Fetches resource content on-demand via proxied MCP requests.
*/
export const MCPAppsActivityRenderer: React.FC =
function MCPAppsActivityRenderer({ content, agent }) {
const { copilotkit } = useCopilotKit();
const containerRef = useRef(null);
const iframeRef = useRef(null);
const [iframeReady, setIframeReady] = useState(false);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [iframeSize, setIframeSize] = useState<{
width?: number;
height?: number;
}>({});
const [fetchedResource, setFetchedResource] =
useState(null);
// Use refs for values that shouldn't trigger re-renders but need latest values
const contentRef = useRef(content);
contentRef.current = content;
// Store agent in a ref for use in async handlers
const agentRef = useRef(agent);
agentRef.current = agent;
// Ref to track fetch state - survives StrictMode remounts
const fetchStateRef = useRef<{
inProgress: boolean;
promise: Promise | null;
resourceUri: string | null;
}>({ inProgress: false, promise: null, resourceUri: null });
// Callback to send a message to the iframe
const sendToIframe = useCallback((msg: JSONRPCMessage) => {
if (iframeRef.current?.contentWindow) {
console.log("[MCPAppsRenderer] Sending to iframe:", msg);
iframeRef.current.contentWindow.postMessage(msg, "*");
}
}, []);
// Callback to send a JSON-RPC response
const sendResponse = useCallback(
(id: string | number, result: unknown) => {
sendToIframe({
jsonrpc: "2.0",
id,
result,
});
},
[sendToIframe],
);
// Callback to send a JSON-RPC error response
const sendErrorResponse = useCallback(
(id: string | number, code: number, message: string) => {
sendToIframe({
jsonrpc: "2.0",
id,
error: { code, message },
});
},
[sendToIframe],
);
// Callback to send a notification
const sendNotification = useCallback(
(method: string, params?: Record) => {
sendToIframe({
jsonrpc: "2.0",
method,
params: params || {},
});
},
[sendToIframe],
);
// Effect 0: Fetch the resource content on mount
// Uses ref-based deduplication to handle React StrictMode double-mounting
useEffect(() => {
const { resourceUri, serverHash, serverId } = content;
// Check if we already have a fetch in progress for this resource
// This handles StrictMode double-mounting - second mount reuses first mount's promise
if (
fetchStateRef.current.inProgress &&
fetchStateRef.current.resourceUri === resourceUri
) {
// Reuse the existing promise
fetchStateRef.current.promise
?.then((resource) => {
if (resource) {
setFetchedResource(resource);
setIsLoading(false);
}
})
.catch((err) => {
setError(err instanceof Error ? err : new Error(String(err)));
setIsLoading(false);
});
return;
}
if (!agent) {
setError(new Error("No agent available to fetch resource"));
setIsLoading(false);
return;
}
// Mark fetch as in progress
fetchStateRef.current.inProgress = true;
fetchStateRef.current.resourceUri = resourceUri;
// Create the fetch promise using the queue to serialize requests
const fetchPromise = (async (): Promise => {
try {
// Use queue to wait for agent to be idle and serialize requests
const runResult = await mcpAppsRequestQueue.enqueue(agent, () =>
agent.runAgent({
forwardedProps: {
__proxiedMCPRequest: {
serverHash,
serverId, // optional, takes precedence if provided
method: "resources/read",
params: { uri: resourceUri },
},
},
}),
);
// Extract resource from result
// The response format is: { contents: [{ uri, mimeType, text?, blob?, _meta? }] }
const resultData = runResult.result as
| { contents?: FetchedResource[] }
| undefined;
const resource = resultData?.contents?.[0];
if (!resource) {
throw new Error("No resource content in response");
}
return resource;
} catch (err) {
console.error("[MCPAppsRenderer] Failed to fetch resource:", err);
throw err;
} finally {
// Mark fetch as complete
fetchStateRef.current.inProgress = false;
}
})();
// Store the promise for potential reuse
fetchStateRef.current.promise = fetchPromise;
// Handle the result
fetchPromise
.then((resource) => {
if (resource) {
setFetchedResource(resource);
setIsLoading(false);
}
})
.catch((err) => {
setError(err instanceof Error ? err : new Error(String(err)));
setIsLoading(false);
});
// No cleanup needed - we want the fetch to complete even if StrictMode unmounts
}, [agent, content]);
// Effect 1: Setup sandbox proxy iframe and communication (after resource is fetched)
useEffect(() => {
// Wait for resource to be fetched
if (isLoading || !fetchedResource) {
return;
}
// Capture container reference at effect start (refs are cleared during unmount)
const container = containerRef.current;
if (!container) {
return;
}
let mounted = true;
let messageHandler: ((event: MessageEvent) => void) | null = null;
let initialListener: ((event: MessageEvent) => void) | null = null;
let createdIframe: HTMLIFrameElement | null = null;
const setup = async () => {
try {
// Create sandbox proxy iframe
const iframe = document.createElement("iframe");
createdIframe = iframe; // Track for cleanup
iframe.style.width = "100%";
iframe.style.height = "100px"; // Start small, will be resized by size-changed notification
iframe.style.border = "none";
iframe.style.backgroundColor = "transparent";
iframe.style.display = "block";
iframe.setAttribute(
"sandbox",
"allow-scripts allow-same-origin allow-forms",
);
// Wait for sandbox proxy to be ready
const sandboxReady = new Promise((resolve) => {
initialListener = (event: MessageEvent) => {
if (event.source === iframe.contentWindow) {
if (
event.data?.method === "ui/notifications/sandbox-proxy-ready"
) {
if (initialListener) {
window.removeEventListener("message", initialListener);
initialListener = null;
}
resolve();
}
}
};
window.addEventListener("message", initialListener);
});
// Check mounted before adding to DOM (handles StrictMode double-mount)
if (!mounted) {
if (initialListener) {
window.removeEventListener("message", initialListener);
initialListener = null;
}
return;
}
// Build sandbox HTML with CSP domains from resource metadata
const cspDomains = fetchedResource._meta?.ui?.csp?.resourceDomains;
iframe.srcdoc = buildSandboxHTML(cspDomains);
iframeRef.current = iframe;
container.appendChild(iframe);
// Wait for sandbox proxy to signal ready
await sandboxReady;
if (!mounted) return;
console.log("[MCPAppsRenderer] Sandbox proxy ready");
// Setup message handler for JSON-RPC messages from the inner iframe
messageHandler = async (event: MessageEvent) => {
if (event.source !== iframe.contentWindow) return;
const msg = event.data as JSONRPCMessage;
if (!msg || typeof msg !== "object" || msg.jsonrpc !== "2.0")
return;
console.log("[MCPAppsRenderer] Received from iframe:", msg);
// Handle requests (need response)
if (isRequest(msg)) {
switch (msg.method) {
case "ui/initialize": {
// Respond with host capabilities
sendResponse(msg.id, {
protocolVersion: PROTOCOL_VERSION,
hostInfo: {
name: "CopilotKit MCP Apps Host",
version: "1.0.0",
},
hostCapabilities: {
openLinks: {},
logging: {},
},
hostContext: {
theme: "light",
platform: "web",
},
});
break;
}
case "ui/message": {
// Add message to CopilotKit chat and optionally invoke agent
const currentAgent = agentRef.current;
if (!currentAgent) {
console.warn(
"[MCPAppsRenderer] ui/message: No agent available",
);
sendResponse(msg.id, { isError: false });
break;
}
try {
const params = msg.params as {
role?: string;
content?: Array<{ type: string; text?: string }>;
followUp?: boolean;
};
const role =
(params.role as "user" | "assistant") || "user";
// Extract text content from the message
const textContent =
params.content
?.filter((c) => c.type === "text" && c.text)
.map((c) => c.text)
.join("\n") || "";
if (textContent) {
currentAgent.addMessage({
id: crypto.randomUUID(),
role,
content: textContent,
});
}
// Acknowledge the message immediately — don't block on agent run
sendResponse(msg.id, { isError: false });
// Determine whether to invoke the agent after adding message.
// followUp: true → always invoke agent
// followUp: false → display-only, skip agent
// not specified → invoke for user messages, skip for assistant
const shouldFollowUp = params.followUp ?? role === "user";
if (shouldFollowUp && textContent) {
// Use copilotkit.runAgent to go through RunHandler — provides
// frontend tools, context, tool execution, and abort support.
// Fire-and-forget: errors are handled by RunHandler's error emission.
mcpAppsRequestQueue
.enqueue(currentAgent, () =>
copilotkit.runAgent({ agent: currentAgent }),
)
.catch((err) =>
console.error(
"[MCPAppsRenderer] ui/message agent run failed:",
err,
),
);
}
} catch (err) {
console.error("[MCPAppsRenderer] ui/message error:", err);
sendResponse(msg.id, { isError: true });
}
break;
}
case "ui/open-link": {
// Open URL in new tab
const url = msg.params?.url as string | undefined;
if (url) {
window.open(url, "_blank", "noopener,noreferrer");
sendResponse(msg.id, { isError: false });
} else {
sendErrorResponse(msg.id, -32602, "Missing url parameter");
}
break;
}
case "tools/call": {
// Proxy tool call to MCP server via agent.runAgent()
const { serverHash, serverId } = contentRef.current;
const currentAgent = agentRef.current;
if (!serverHash) {
sendErrorResponse(
msg.id,
-32603,
"No server hash available for proxying",
);
break;
}
if (!currentAgent) {
sendErrorResponse(
msg.id,
-32603,
"No agent available for proxying",
);
break;
}
try {
// Use queue to wait for agent to be idle and serialize requests
const runResult = await mcpAppsRequestQueue.enqueue(
currentAgent,
() =>
currentAgent.runAgent({
forwardedProps: {
__proxiedMCPRequest: {
serverHash,
serverId, // optional, takes precedence if provided
method: "tools/call",
params: msg.params,
},
},
}),
);
// The result from runAgent contains the MCP response
sendResponse(msg.id, runResult.result || {});
} catch (err) {
console.error("[MCPAppsRenderer] tools/call error:", err);
sendErrorResponse(msg.id, -32603, String(err));
}
break;
}
default:
sendErrorResponse(
msg.id,
-32601,
`Method not found: ${msg.method}`,
);
}
}
// Handle notifications (no response needed)
if (isNotification(msg)) {
switch (msg.method) {
case "ui/notifications/initialized": {
console.log("[MCPAppsRenderer] Inner iframe initialized");
if (mounted) {
setIframeReady(true);
}
break;
}
case "ui/notifications/size-changed": {
const { width, height } = msg.params || {};
console.log("[MCPAppsRenderer] Size change:", {
width,
height,
});
if (mounted) {
setIframeSize({
width: typeof width === "number" ? width : undefined,
height: typeof height === "number" ? height : undefined,
});
}
break;
}
case "notifications/message": {
// Logging notification from the app
console.log("[MCPAppsRenderer] App log:", msg.params);
break;
}
}
}
};
window.addEventListener("message", messageHandler);
// Extract HTML content from fetched resource
let html: string;
if (fetchedResource.text) {
html = fetchedResource.text;
} else if (fetchedResource.blob) {
html = atob(fetchedResource.blob);
} else {
throw new Error("Resource has no text or blob content");
}
// Send the resource content to the sandbox proxy
sendNotification("ui/notifications/sandbox-resource-ready", { html });
} catch (err) {
console.error("[MCPAppsRenderer] Setup error:", err);
if (mounted) {
setError(err instanceof Error ? err : new Error(String(err)));
}
}
};
setup();
return () => {
mounted = false;
// Clean up initial listener if still active
if (initialListener) {
window.removeEventListener("message", initialListener);
initialListener = null;
}
if (messageHandler) {
window.removeEventListener("message", messageHandler);
}
// Remove the iframe we created (using tracked reference, not DOM query)
// This works even if containerRef.current is null during unmount
if (createdIframe) {
createdIframe.remove();
createdIframe = null;
}
iframeRef.current = null;
};
}, [
isLoading,
fetchedResource,
sendNotification,
sendResponse,
sendErrorResponse,
]);
// Effect 2: Update iframe size when it changes
useEffect(() => {
if (iframeRef.current) {
if (iframeSize.width !== undefined) {
// Use minWidth with min() to allow expansion but cap at 100%
iframeRef.current.style.minWidth = `min(${iframeSize.width}px, 100%)`;
iframeRef.current.style.width = "100%";
}
if (iframeSize.height !== undefined) {
iframeRef.current.style.height = `${iframeSize.height}px`;
}
}
}, [iframeSize]);
// Effect 3: Send tool input when iframe ready
useEffect(() => {
if (iframeReady && content.toolInput) {
console.log("[MCPAppsRenderer] Sending tool input:", content.toolInput);
sendNotification("ui/notifications/tool-input", {
arguments: content.toolInput,
});
}
}, [iframeReady, content.toolInput, sendNotification]);
// Effect 4: Send tool result when iframe ready
useEffect(() => {
if (iframeReady && content.result) {
console.log("[MCPAppsRenderer] Sending tool result:", content.result);
sendNotification("ui/notifications/tool-result", content.result);
}
}, [iframeReady, content.result, sendNotification]);
// Determine border styling based on prefersBorder metadata from fetched resource
// true = show border/background, false = none, undefined = host decides (we default to none)
const prefersBorder = fetchedResource?._meta?.ui?.prefersBorder;
const borderStyle =
prefersBorder === true
? {
borderRadius: "8px",
backgroundColor: "#f9f9f9",
border: "1px solid #e0e0e0",
}
: {};
return (
{isLoading && (
Loading...
)}
{error && (
Error: {error.message}
)}
);
};