import { CopilotRuntime, ExperimentalEmptyAdapter, copilotRuntimeNextJSAppRouterEndpoint, } from "@copilotkit/runtime"; import { BuiltInAgent, defineTool } from "@copilotkit/runtime/v2"; import { z } from "zod"; import { NextRequest } from "next/server"; import { MCPAppsMiddleware, getServerHash } from "@ag-ui/mcp-apps-middleware"; import { map } from "rxjs/operators"; import { E2BWorkspaceProvider } from "@/lib/workspace/e2b"; import { getDefaultMcpServers, type McpServerConfig } from "@/lib/mcp-defaults"; // Allow up to 5 minutes for long agent loops export const maxDuration = 300; /** * Fallback used only when no x-mcp-servers header is present * (e.g. direct API calls, curl tests). * In normal frontend usage the header is always sent by DynamicCopilotKitProvider. */ function readMcpServersFromHeader(req: NextRequest): McpServerConfig[] { try { const raw = req.headers.get("x-mcp-servers"); if (raw == null) return getDefaultMcpServers(); const parsed = JSON.parse(raw) as McpServerConfig[]; if (!Array.isArray(parsed)) return getDefaultMcpServers(); console.log( "[copilotkit] MCP servers from header:", parsed.map((s) => s.url), ); return parsed; } catch { console.warn( "[copilotkit] Failed to parse x-mcp-servers header, using defaults", ); return getDefaultMcpServers(); } } /** * Subclass that fixes widget HTML for CSP-safe rendering: * 1. Strips tags (blocked by CSP base-uri 'self' in SANDBOX_HTML) * 2. Rewrites internal origin refs to the external server origin * (for window.__mcpPublicUrl, window.__getFile, etc.) * * With --inline builds, all JS/CSS is inlined into the HTML, so no external * script/style loads are needed. Only image/fetch URLs need rewriting. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any class MCPAppsMiddlewareFixBase extends MCPAppsMiddleware { private _mcpServers: McpServerConfig[]; constructor(opts: { mcpServers: McpServerConfig[] }) { super(opts); this._mcpServers = opts.mcpServers || []; } // eslint-disable-next-line @typescript-eslint/no-explicit-any run(input: any, next: any) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const source = super.run(input, next) as any; // eslint-disable-next-line @typescript-eslint/no-explicit-any const proxiedReq = (input as any).forwardedProps?.__proxiedMCPRequest; const isResourceRead = proxiedReq?.method === "resources/read"; if (!isResourceRead) return source; // Find the server config to get the correct external origin URL let serverOrigin: string | null = null; if (proxiedReq) { const cfg = this._mcpServers.find((s) => { if (proxiedReq.serverId && s.serverId) return s.serverId === proxiedReq.serverId; if (proxiedReq.serverHash) return ( getServerHash({ type: s.type, url: s.url } as Parameters< typeof getServerHash >[0]) === proxiedReq.serverHash ); return false; }); if (cfg) { try { serverOrigin = new URL(cfg.url).origin; } catch { /* ignore */ } } } // eslint-disable-next-line @typescript-eslint/no-explicit-any return source.pipe( map((event: any) => { if ( event.type === "RUN_FINISHED" && Array.isArray(event.result?.contents) ) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const contents = event.result.contents.map((c: any) => { if (typeof c.text === "string") { let html = c.text; const baseTagMatch = html.match(/]*>/i); if (baseTagMatch) { try { const internalOrigin = new URL(baseTagMatch[1]).origin; // Strip tag (violates CSP base-uri 'self') html = html.replace(/]*>/gi, ""); // Rewrite all remaining internal origin references if (serverOrigin && internalOrigin !== serverOrigin) { html = html.replaceAll(internalOrigin, serverOrigin); } } catch { /* ignore */ } } return { ...c, text: html }; } return c; }); return { ...event, result: { ...event.result, contents } }; } return event; }), ); } } // ── E2B workspace provider (stateless — safe to reuse across requests) ────── const workspaceProvider = new E2BWorkspaceProvider(); // ── Backend tools — run server-side inside the agent loop ──────────────────── const workspaceTools = [ defineTool({ name: "provision_workspace", description: "Create an E2B sandbox from the pre-built mcp-use-server template. " + "With a template this takes ~3 s (deps + server are baked in). " + "Returns workspaceId and endpoint. " + "After success, ALWAYS call add_mcp_server(endpoint, serverId) " + "and set_active_workspace(workspaceId, endpoint) so the UI updates.", parameters: z.object({ name: z .string() .describe("Short identifier for this workspace, e.g. 'weather-widget'"), }), execute: async ({ name }) => { const info = await workspaceProvider.provision(name); return JSON.stringify({ workspaceId: info.workspaceId, endpoint: info.endpoint, status: info.status, nextSteps: [ `Call add_mcp_server("${info.endpoint}", "${name}") to connect the sandbox to the UI`, `Call set_active_workspace("${info.workspaceId}", "${info.endpoint}") to show the status badge`, ], }); }, }), defineTool({ name: "read_file", description: "Read a file from the active E2B workspace. Path is relative to workspace root " + "(/home/user/workspace). Use this to inspect existing code before editing.", parameters: z.object({ workspaceId: z .string() .describe("Sandbox ID returned by provision_workspace"), path: z .string() .describe("Relative file path, e.g. 'index.ts' or 'tools/my-tool.ts'"), }), execute: async ({ workspaceId, path }) => { return await workspaceProvider.readFile(workspaceId, path); }, }), defineTool({ name: "write_file", description: "Write (create or overwrite) a file in the active E2B workspace. " + "Parent directories are created automatically. Path is relative to workspace root.", parameters: z.object({ workspaceId: z.string().describe("Sandbox ID"), path: z .string() .describe( "Relative file path, e.g. 'resources/price-chart/widget.tsx'", ), content: z.string().describe("Full file content to write"), }), execute: async ({ workspaceId, path, content }) => { await workspaceProvider.writeFile(workspaceId, path, content); return `Wrote ${content.length} chars to "${path}"`; }, }), defineTool({ name: "edit_file", description: "Targeted search-and-replace in a workspace file. " + "The search string must match exactly (including whitespace/newlines). " + "Prefer this over write_file for small changes.", parameters: z.object({ workspaceId: z.string().describe("Sandbox ID"), path: z.string().describe("Relative file path"), search: z.string().describe("Exact string to find in the file"), replace: z.string().describe("String to replace it with"), }), execute: async ({ workspaceId, path, search, replace }) => { await workspaceProvider.editFile(workspaceId, path, search, replace); return `Edited "${path}" — replaced the target string.`; }, }), defineTool({ name: "exec", description: "Run a shell command in the workspace root of the active E2B sandbox. " + "Use background=true for long-running processes (e.g. starting the dev server). " + "Rebuild sequence after edits: " + "1) exec(\"ss -tlnp 'sport = :3109' | grep -oP 'pid=\\\\K[0-9]+' | head -1 | xargs -r kill; sleep 1\") " + "2) exec('npm run dev', background=true).", parameters: z.object({ workspaceId: z.string().describe("Sandbox ID"), cmd: z.string().describe("Shell command to run"), background: z .boolean() .optional() .describe( "Run in background and return immediately (for servers). Default: false.", ), timeoutMs: z .number() .optional() .describe( "Timeout in milliseconds for foreground commands. Default: 60000.", ), }), execute: async ({ workspaceId, cmd, background, timeoutMs }) => { const result = await workspaceProvider.exec(workspaceId, cmd, { background, timeoutMs, }); if (result.background) return `Started in background: ${cmd}`; const parts: string[] = []; if (result.stdout) parts.push(`stdout:\n${result.stdout}`); if (result.stderr) parts.push(`stderr:\n${result.stderr}`); parts.push(`exit code: ${result.exitCode}`); return parts.join("\n"); }, }), defineTool({ name: "get_workspace_info", description: "Get current status and endpoint of the active E2B sandbox.", parameters: z.object({ workspaceId: z.string().describe("Sandbox ID"), }), execute: async ({ workspaceId }) => { const info = await workspaceProvider.getInfo(workspaceId); return JSON.stringify(info); }, }), defineTool({ name: "download_workspace", description: "Package the current workspace as a .tar.gz archive (excludes node_modules/dist) and return a signed download URL. " + "Present the URL to the user so they can download their MCP server.", parameters: z.object({ workspaceId: z.string().describe("Sandbox ID"), }), execute: async ({ workspaceId }) => { const { downloadUrl } = await workspaceProvider.prepareDownload(workspaceId); return `Workspace packaged. Download URL (valid ~1 hour): ${downloadUrl}`; }, }), ]; // ── System prompt ───────────────────────────────────────────────────────────── const AGENT_SYSTEM_PROMPT = `You are the MCP UI Studio coding agent. You can USE existing MCP tools and BUILD new ones in live E2B sandboxes. CRITICAL — DO NOT STOP AFTER A TOOL CALL: After every tool result you MUST continue in the same run. Either: (a) Call the next tool in your plan, or (b) Send a short text message to the user (e.g. "Done. Next I'll…" or "That succeeded. …"). Never end your turn immediately after a tool returns without doing (a) or (b). The user sees nothing if you stop without a message. Keep going until the workflow is complete, then send a final summary. COMMUNICATION — follow these rules on every turn: - Before calling any tool, send a short message explaining what you are about to do. - After each tool returns, send a brief update: what succeeded and what comes next (or call the next tool). - When the task is done, send a plain-text summary to the user. - Keep messages concise (1–2 sentences). No filler phrases. ═══════════════════════════════════════════════════════════════ TEMPLATE KNOWLEDGE — mcp-use-server ═══════════════════════════════════════════════════════════════ The E2B sandbox runs a pre-built mcp-use-server template. Deps and build artifacts are baked in — provisioning takes ~3 s. The dev server starts automatically on port 3109. Key files in the workspace (/home/user/workspace): index.ts — Entry point. Has TWO marker comments: // ADD NEW TOOL IMPORTS HERE // ADD NEW TOOL REGISTRATIONS HERE tools/.ts — Tool backend: exports register(server). resources//widget.tsx — Widget UI: React component rendered in an iframe. resources//types.ts — Prop types: zod schema + TS type for the widget props.(Only needed for complex widgets) resources/styles.css — Shared Tailwind styles (already exists, import in widgets).(Only needed for complex widgets) package.json — Uses mcp-use framework + React + Tailwind. Every MCP UI tool has TWO parts that you MUST create: 1. A tool file (tools/.ts) — backend logic + widget binding 2. A widget folder (resources//) — React component the user sees ── TOOL FILE PATTERN (tools/.ts) ───────────────────────── \`\`\`ts import { MCPServer, text, widget } from "mcp-use/server"; import { z } from "zod"; export function register(server: MCPServer) { server.tool( { name: "get-weather", description: "Fetch weather for a city and display it in a widget", schema: z.object({ city: z.string().describe("City name"), }), widget: { name: "weather-widget", // must match the folder name under resources/ invoking: "Fetching weather…", invoked: "Weather loaded", }, _meta: { // REQUIRED for MCP UI Studio: preview shown in sidebar before any live call "ui/previewData": { city: "London", temperature: 22, conditions: "Sunny", }, }, }, async ({ city }) => { const temp = Math.round(15 + Math.random() * 20); const conditions = ["Sunny", "Cloudy", "Rainy", "Windy"][Math.floor(Math.random() * 4)]; return widget({ props: { city, temperature: temp, conditions }, output: text(\`Weather in \${city}: \${temp}°C, \${conditions}\`), }); } ); } \`\`\` Key points: • widget.name in the tool config MUST match the resources/ folder name. • For every widget tool you MUST add _meta["ui/previewData"] with sample data matching the widget props shape. This powers the MCP UI Studio sidebar preview (demo before any live call). Example: _meta: { "ui/previewData": { city: "London", temperature: 22, conditions: "Sunny" } } • The handler returns widget({ props, output }) — props go to the React component, output is the text summary the LLM sees. • For tools without UI, skip the widget config and _meta, and return text(...) instead. ── WIDGET FILE PATTERN (resources//types.ts) ───────────── \`\`\`ts import { z } from "zod"; export const propSchema = z.object({ city: z.string(), temperature: z.number(), conditions: z.string(), }); export type WeatherWidgetProps = z.infer; \`\`\` ── WIDGET FILE PATTERN (resources//widget.tsx) ─────────── \`\`\`tsx import { McpUseProvider, useWidget, type WidgetMetadata } from "mcp-use/react"; import React from "react"; import "../styles.css"; import { propSchema } from "./types"; import type { WeatherWidgetProps } from "./types"; export const widgetMetadata: WidgetMetadata = { description: "Displays weather information for a city", props: propSchema, metadata: { prefersBorder: false }, }; const WeatherWidget: React.FC = () => { const { props, isPending } = useWidget(); if (isPending) { return (
Loading weather…
); } const { city, temperature, conditions } = props; return (

{city}

{temperature}°C

{conditions}

); }; export default WeatherWidget; \`\`\` Key points: • MUST export widgetMetadata (with props: propSchema) and a default React component. • Wrap the entire component tree in . • useWidget() gives you { props, isPending } — show a loading state when isPending. • Import "../styles.css" for Tailwind classes. • The widget renders inside an iframe — it must be self-contained. ── REGISTERING IN index.ts ───────────────────────────────────── Use edit_file with these exact marker strings: 1. Add import BEFORE the marker: search: "// ADD NEW TOOL IMPORTS HERE" replace: 'import { register as registerMyTool } from "./tools/my-tool";\\n// ADD NEW TOOL IMPORTS HERE' 2. Add registration BEFORE the marker: search: "// ADD NEW TOOL REGISTRATIONS HERE" replace: "registerMyTool(server);\\n// ADD NEW TOOL REGISTRATIONS HERE" ═══════════════════════════════════════════════════════════════ WORKFLOWS ═══════════════════════════════════════════════════════════════ You handle three kinds of requests. Pick the right workflow. ── A. BUILD A NEW MCP UI TOOL (user has no sandbox yet) ─────── Goal: spin up a sandbox, create the tool + widget, and make it live. 1. Spin up the sandbox. provision_workspace("weather-app") → gives you workspaceId + endpoint. 2. Wire the sandbox into the UI so the user sees it. add_mcp_server(endpoint, "weather-app") set_active_workspace(workspaceId, endpoint) 3. Study the template so you understand the patterns. read_file(workspaceId, "index.ts") read_file(workspaceId, "tools/product-search.ts") read_file(workspaceId, "resources/product-search-result/widget.tsx") read_file(workspaceId, "resources/product-search-result/types.ts") 4. Create the widget (the UI the user will see). write_file(workspaceId, "resources/weather-widget/types.ts", ) write_file(workspaceId, "resources/weather-widget/widget.tsx", ) The folder name ("weather-widget") becomes the widget name you reference in the tool. 5. Create the tool (the backend that powers the widget). write_file(workspaceId, "tools/weather.ts", ) Make sure the tool config has widget: { name: "weather-widget" } matching the folder. 6. Register the tool in index.ts so the server knows about it. edit_file — add import at "// ADD NEW TOOL IMPORTS HERE" edit_file — add registration at "// ADD NEW TOOL REGISTRATIONS HERE" Skipping this step means the tool won't load. 7. Restart the dev server to pick up all changes. "npm run dev" first runs "mcp-use build" (compiles widget React code into the iframe bundle), then starts the server. Both steps must succeed. exec(workspaceId, "kill $(lsof -t -i:3109) 2>/dev/null; sleep 1") exec(workspaceId, "npm run dev", background=true) 8. Verify everything works. exec(workspaceId, "sleep 8") — give the build + server startup time to finish. refresh_mcp_tools() If the new tool does NOT appear, check for build errors: exec(workspaceId, "cat /tmp/dev.log") or re-run npm run dev in foreground to see the error output, fix the code, and restart again. Once confirmed, tell the user it's live. ── B. ADD / EDIT A TOOL (sandbox already running) ───────────── The sandbox and UI are already connected — skip steps 1–2. • To edit an existing tool → read_file the tool and/or widget, then edit_file. • To add a new tool → create the widget folder (types.ts + widget.tsx), create the tool file, register in index.ts (same as A.4–A.6). • After any change → restart the server (A.7) and verify (A.8). Remember: "npm run dev" rebuilds widgets before starting, so widget changes only take effect after a restart. ── C. USE AN EXISTING MCP TOOL ──────────────────────────────── The user wants to call a tool already registered on a connected server. No sandbox work — the tool is available to you via the middleware. Just call it. ═══════════════════════════════════════════════════════════════ EXAMPLE — "Build me a crypto price widget" ═══════════════════════════════════════════════════════════════ User: "Build a crypto price widget" → You say: "I'll spin up a sandbox and build that for you." → provision_workspace("crypto-price") → add_mcp_server(endpoint, "crypto-price") → set_active_workspace(workspaceId, endpoint) → You say: "Sandbox is ready. Let me study the template first." → read_file(workspaceId, "index.ts") → read_file(workspaceId, "tools/product-search.ts") → read_file(workspaceId, "resources/product-search-result/widget.tsx") → read_file(workspaceId, "resources/product-search-result/types.ts") → You say: "Got it. I'll create the widget UI first, then the tool." → write_file(workspaceId, "resources/crypto-price/types.ts", ) → write_file(workspaceId, "resources/crypto-price/widget.tsx", ) → You say: "Widget created. Now writing the tool that fetches prices." → write_file(workspaceId, "tools/crypto-price.ts", ) → You say: "Registering the tool in index.ts." → edit_file(workspaceId, "index.ts", ...) — add import → edit_file(workspaceId, "index.ts", ...) — add registration → You say: "All files written. Restarting the server — this will rebuild the widget and start fresh." → exec(workspaceId, "kill $(lsof -t -i:3109) 2>/dev/null; sleep 1") → exec(workspaceId, "npm run dev", background=true) → exec(workspaceId, "sleep 8") → refresh_mcp_tools() → You say: "Your crypto price widget is live! You should see get-crypto-price in the sidebar. Try asking me to look up the price of Bitcoin." ═══════════════════════════════════════════════════════════════ AVAILABLE TOOLS ═══════════════════════════════════════════════════════════════ BACKEND (run inside the E2B sandbox): provision_workspace — create a new sandbox (~3 s with template) read_file — read any file in the workspace write_file — create or overwrite a file edit_file — search-and-replace inside a file exec — run a shell command (use background=true for servers) get_workspace_info — check sandbox status download_workspace — archive the workspace and get a download URL FRONTEND ACTIONS (update UI state — no server-side effect): add_mcp_server(endpoint, serverId) — connect sandbox to the sidebar set_active_workspace(workspaceId, endpoint) — show the "Running" badge refresh_mcp_tools() — re-introspect servers after a restart Always pass the workspaceId from provision_workspace to every subsequent backend tool call.`; // ── Request handler ──────────────────────────────────────────────────────────── // Note: If the agent stops after one or two tool calls with no final message, the // LLM is likely ending the turn after tool results. The system prompt instructs it // to always continue (next tool or a user message). If it persists, see CopilotKit // issues e.g. #2416 (tool result / RunError handling) and runtime agent-loop options. export const POST = async (req: NextRequest) => { const mcpServers = readMcpServersFromHeader(req); const middleware = new MCPAppsMiddlewareFixBase({ mcpServers }); const agent = new BuiltInAgent({ model: "openai/gpt-4o", prompt: AGENT_SYSTEM_PROMPT, // Cast: defineTool() returns specific Zod types; BuiltInAgent expects ToolDefinition[] tools: workspaceTools as unknown as ConstructorParameters< typeof BuiltInAgent >[0]["tools"], }); agent.use(middleware); const serviceAdapter = new ExperimentalEmptyAdapter(); const runtime = new CopilotRuntime({ agents: { default: agent, }, }); const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({ runtime, serviceAdapter, endpoint: "/api/copilotkit", }); return handleRequest(req); };