import { computed, defineComponent, h, onBeforeUnmount, ref, watch } from "vue";
import type { PropType } from "vue";
import { z } from "zod";
import type { AbstractAgent, RunAgentResult } from "@ag-ui/client";
import { randomUUID } from "@copilotkit/shared";
import type { VueActivityMessageRendererProps } from "../types";
import { useCopilotKit } from "../providers/useCopilotKit";
const PROTOCOL_VERSION = "2025-06-18";
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 `
`;
}
class MCPAppsRequestQueue {
private queues = new Map<
string,
Array<{
execute: () => Promise;
resolve: (result: RunAgentResult) => void;
reject: (error: Error) => void;
}>
>();
private processing = new Map();
async enqueue(
agent: AbstractAgent,
request: () => Promise,
): Promise {
const threadId = agent.threadId || "default";
return new Promise((resolve, reject) => {
let queue = this.queues.get(threadId);
if (!queue) {
queue = [];
this.queues.set(threadId, queue);
}
queue.push({ execute: request, resolve, reject });
void this.processQueue(threadId, agent);
});
}
private async processQueue(
threadId: string,
agent: AbstractAgent,
): Promise {
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 {
await this.waitForAgentIdle(agent);
const result = await item.execute();
item.resolve(result);
} catch (error) {
item.reject(
error instanceof Error ? error : new Error(String(error)),
);
}
queue.shift();
}
} finally {
this.processing.set(threadId, false);
}
}
cancel(threadId: string): void {
const queue = this.queues.get(threadId);
if (queue) {
for (const item of queue) {
item.reject(new Error("MCPAppsRequestQueue cancelled on unmount"));
}
queue.length = 0;
}
this.queues.delete(threadId);
this.processing.delete(threadId);
}
private waitForAgentIdle(agent: AbstractAgent): Promise {
return new Promise((resolve, reject) => {
if (!agent.isRunning) {
resolve();
return;
}
let done = false;
const timeout = setTimeout(() => {
if (done) return;
done = true;
clearInterval(checkInterval);
sub.unsubscribe();
reject(
new Error("[CopilotKit] Timed out waiting for agent to become idle"),
);
}, 30_000);
const finish = () => {
if (done) return;
done = true;
clearTimeout(timeout);
clearInterval(checkInterval);
sub.unsubscribe();
resolve();
};
const sub = agent.subscribe({
onRunFinalized: finish,
onRunFailed: finish,
});
const checkInterval = setInterval(() => {
if (!agent.isRunning) {
finish();
}
}, 500);
});
}
}
const mcpAppsRequestQueue = new MCPAppsRequestQueue();
export const MCPAppsActivityType = "mcp-apps";
export const MCPAppsActivityContentSchema = z.object({
result: z.object({
content: z.array(z.any()).optional(),
structuredContent: z.any().optional(),
isError: z.boolean().optional(),
}),
resourceUri: z.string(),
serverHash: z.string(),
serverId: z.string().optional(),
toolInput: z.record(z.string(), z.unknown()).optional(),
});
export type MCPAppsActivityContent = z.infer<
typeof MCPAppsActivityContentSchema
>;
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(message: JSONRPCMessage): message is JSONRPCRequest {
return "id" in message && "method" in message;
}
function isNotification(
message: JSONRPCMessage,
): message is JSONRPCNotification {
return !("id" in message) && "method" in message;
}
export const MCPAppsActivityRenderer = defineComponent({
name: "MCPAppsActivityRenderer",
props: {
activityType: {
type: String,
required: true,
},
content: {
type: Object as PropType,
required: true,
},
message: {
type: Object as PropType<
VueActivityMessageRendererProps["message"]
>,
required: true,
},
agent: {
type: Object as PropType,
required: false,
default: undefined,
},
},
setup(props) {
const { copilotkit } = useCopilotKit();
const containerRef = ref(null);
const iframeRef = ref(null);
const iframeReady = ref(false);
const error = ref(null);
const isLoading = ref(true);
const iframeSize = ref<{ width?: number; height?: number }>({});
const fetchedResource = ref(null);
const fetchStateRef = ref<{
inProgress: boolean;
promise: Promise | null;
resourceUri: string | null;
}>({
inProgress: false,
promise: null,
resourceUri: null,
});
const sendToIframe = (message: JSONRPCMessage) => {
if (iframeRef.value?.contentWindow) {
iframeRef.value.contentWindow.postMessage(message, "*");
}
};
const sendResponse = (id: string | number, result: unknown) => {
sendToIframe({
jsonrpc: "2.0",
id,
result,
});
};
const sendErrorResponse = (
id: string | number,
code: number,
message: string,
) => {
sendToIframe({
jsonrpc: "2.0",
id,
error: { code, message },
});
};
const sendNotification = (
method: string,
params?: Record,
) => {
sendToIframe({
jsonrpc: "2.0",
method,
params: params || {},
});
};
watch(
[() => props.agent, () => props.content],
([agent, content]) => {
isLoading.value = true;
error.value = null;
iframeReady.value = false;
iframeSize.value = {};
fetchedResource.value = null;
const { resourceUri, serverHash, serverId } = content;
if (
fetchStateRef.value.inProgress &&
fetchStateRef.value.resourceUri === resourceUri
) {
void fetchStateRef.value.promise
?.then((resource) => {
if (resource) {
fetchedResource.value = resource;
isLoading.value = false;
}
})
.catch((err: unknown) => {
error.value = err instanceof Error ? err : new Error(String(err));
isLoading.value = false;
});
return;
}
if (!agent) {
error.value = new Error("No agent available to fetch resource");
isLoading.value = false;
return;
}
fetchStateRef.value.inProgress = true;
fetchStateRef.value.resourceUri = resourceUri;
const fetchPromise = (async (): Promise => {
try {
const runResult = await mcpAppsRequestQueue.enqueue(agent, () =>
agent.runAgent({
forwardedProps: {
__proxiedMCPRequest: {
serverHash,
serverId,
method: "resources/read",
params: { uri: resourceUri },
},
},
}),
);
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;
} finally {
fetchStateRef.value.inProgress = false;
}
})();
fetchStateRef.value.promise = fetchPromise;
void fetchPromise
.then((resource) => {
if (resource) {
fetchedResource.value = resource;
isLoading.value = false;
}
})
.catch((err: unknown) => {
error.value = err instanceof Error ? err : new Error(String(err));
isLoading.value = false;
});
},
{ immediate: true },
);
watch(
[isLoading, fetchedResource],
([loading, resource], _old, onCleanup) => {
if (loading || !resource) {
return;
}
const container = containerRef.value;
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 {
const iframe = document.createElement("iframe");
createdIframe = iframe;
iframe.style.width = "100%";
iframe.style.height = "100px";
iframe.style.border = "none";
iframe.style.backgroundColor = "transparent";
iframe.style.display = "block";
iframe.setAttribute(
"sandbox",
"allow-scripts allow-same-origin allow-forms",
);
const sandboxReady = new Promise((resolve) => {
initialListener = (event: MessageEvent) => {
if (
event.source === iframe.contentWindow &&
event.data?.method === "ui/notifications/sandbox-proxy-ready"
) {
if (initialListener) {
window.removeEventListener("message", initialListener);
initialListener = null;
}
resolve();
}
};
window.addEventListener("message", initialListener);
});
if (!mounted) {
if (initialListener) {
window.removeEventListener("message", initialListener);
initialListener = null;
}
return;
}
const cspDomains =
fetchedResource.value?._meta?.ui?.csp?.resourceDomains;
iframe.srcdoc = buildSandboxHTML(cspDomains);
iframeRef.value = iframe;
container.appendChild(iframe);
await sandboxReady;
if (!mounted) return;
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;
}
if (isRequest(msg)) {
switch (msg.method) {
case "ui/initialize": {
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": {
const currentAgent = props.agent;
if (!currentAgent) {
sendResponse(msg.id, { isError: false });
break;
}
try {
const params = msg.params as
| {
role?: string;
content?: Array<{ type: string; text?: string }>;
followUp?: boolean;
}
| undefined;
const role =
(params?.role as "user" | "assistant") || "user";
const textContent =
params?.content
?.filter((part) => part.type === "text" && part.text)
.map((part) => part.text)
.join("\n") || "";
if (textContent) {
currentAgent.addMessage({
id: randomUUID(),
role,
content: textContent,
});
}
sendResponse(msg.id, { isError: false });
const shouldFollowUp =
params?.followUp ?? role === "user";
if (shouldFollowUp && textContent) {
void mcpAppsRequestQueue
.enqueue(currentAgent, () =>
copilotkit.value.runAgent({ agent: currentAgent }),
)
.catch((err) => {
console.error(
"[MCPAppsRenderer] ui/message agent run failed:",
err,
);
});
}
} catch (err) {
console.error(
"[CopilotKit] MCPApps ui/message handler error:",
err,
);
sendResponse(msg.id, { isError: true });
}
break;
}
case "ui/open-link": {
const url = msg.params?.url as string | undefined;
if (!url) {
sendErrorResponse(
msg.id,
-32602,
"Missing url parameter",
);
break;
}
window.open(url, "_blank", "noopener,noreferrer");
sendResponse(msg.id, { isError: false });
break;
}
case "tools/call": {
const { serverHash, serverId } = props.content;
const currentAgent = props.agent;
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 {
const runResult = await mcpAppsRequestQueue.enqueue(
currentAgent,
() =>
currentAgent.runAgent({
forwardedProps: {
__proxiedMCPRequest: {
serverHash,
serverId,
method: "tools/call",
params: msg.params,
},
},
}),
);
sendResponse(msg.id, runResult.result || {});
} catch (err) {
sendErrorResponse(msg.id, -32603, String(err));
}
break;
}
default: {
sendErrorResponse(
msg.id,
-32601,
`Method not found: ${msg.method}`,
);
}
}
}
if (isNotification(msg)) {
switch (msg.method) {
case "ui/notifications/initialized":
if (mounted) {
iframeReady.value = true;
}
break;
case "ui/notifications/size-changed": {
const { width, height } = msg.params || {};
if (mounted) {
iframeSize.value = {
width: typeof width === "number" ? width : undefined,
height: typeof height === "number" ? height : undefined,
};
}
break;
}
}
}
};
window.addEventListener("message", messageHandler);
const html = resource.text
? resource.text
: resource.blob
? atob(resource.blob)
: null;
if (!html) {
throw new Error("Resource has no text or blob content");
}
sendNotification("ui/notifications/sandbox-resource-ready", {
html,
});
} catch (err) {
if (mounted) {
error.value = err instanceof Error ? err : new Error(String(err));
}
}
};
void setup();
onCleanup(() => {
mounted = false;
if (initialListener) {
window.removeEventListener("message", initialListener);
initialListener = null;
}
if (messageHandler) {
window.removeEventListener("message", messageHandler);
}
if (createdIframe) {
createdIframe.remove();
createdIframe = null;
}
iframeRef.value = null;
});
},
{ flush: "post" },
);
watch(
iframeSize,
(size) => {
if (!iframeRef.value) return;
if (size.width !== undefined) {
iframeRef.value.style.minWidth = `min(${size.width}px, 100%)`;
iframeRef.value.style.width = "100%";
}
if (size.height !== undefined) {
iframeRef.value.style.height = `${size.height}px`;
}
},
{ deep: true },
);
watch(
[iframeReady, () => props.content.toolInput],
([ready, toolInput]) => {
if (ready && toolInput) {
sendNotification("ui/notifications/tool-input", {
arguments: toolInput,
});
}
},
{ deep: true },
);
watch(
[iframeReady, () => props.content.result],
([ready, result]) => {
if (ready && result) {
sendNotification(
"ui/notifications/tool-result",
result as Record,
);
}
},
{ deep: true },
);
const borderStyle = computed(() => {
const prefersBorder = fetchedResource.value?._meta?.ui?.prefersBorder;
if (prefersBorder !== true) return {};
return {
borderRadius: "8px",
backgroundColor: "#f9f9f9",
border: "1px solid #e0e0e0",
};
});
onBeforeUnmount(() => {
const threadId = props.agent?.threadId || "default";
mcpAppsRequestQueue.cancel(threadId);
});
return () =>
h(
"div",
{
ref: containerRef,
style: {
width: "100%",
height:
iframeSize.value.height !== undefined
? `${iframeSize.value.height}px`
: "auto",
minHeight: "100px",
overflow: "hidden",
position: "relative",
...borderStyle.value,
},
},
[
isLoading.value
? h(
"div",
{ style: { padding: "1rem", color: "#666" } },
"Loading...",
)
: null,
error.value
? h(
"div",
{ style: { color: "red", padding: "1rem" } },
`Error: ${error.value.message}`,
)
: null,
],
);
},
});
export default MCPAppsActivityRenderer;