diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts new file mode 100644 index 000000000..5e0c2a655 --- /dev/null +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -0,0 +1,263 @@ +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { describe, expect, test } from "vitest"; +import { experimental_createMCPClient } from "ai"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js"; + +type McpToolResult = { + structuredContent?: Record; + content?: Array<{ structuredContent?: Record } | Record>; +}; + +type McpClient = { + callTool: (input: { name: string; args?: Record }) => Promise; + close: () => Promise; +}; + +type PermissionPayload = { + id: string; +}; + +async function getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to acquire port"))); + return; + } + server.close(() => resolve(address.port)); + }); + }); +} + +function getStructuredContent(result: McpToolResult): Record | null { + if (result.structuredContent && typeof result.structuredContent === "object") { + return result.structuredContent; + } + const content = result.content?.[0]; + if (content && "structuredContent" in content && content.structuredContent) { + return content.structuredContent; + } + if (content && typeof content === "object") { + return content as Record; + } + return null; +} + +async function waitForFile(filePath: string, timeoutMs = 30000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + try { + return await readFile(filePath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${filePath}`); +} + +async function settleAgentRun(client: McpClient, agentId: string): Promise { + for (let attempt = 0; attempt < 6; attempt += 1) { + const waitResult = (await client.callTool({ + name: "wait_for_agent", + args: { agentId }, + })) as McpToolResult; + const payload = getStructuredContent(waitResult); + const permission = payload?.permission as PermissionPayload | null | undefined; + if (permission?.id) { + await client.callTool({ + name: "respond_to_permission", + args: { + agentId, + requestId: permission.id, + response: { behavior: "allow" }, + }, + }); + continue; + } + + const status = payload?.status; + if (status && status !== "running" && status !== "initializing") { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 500)); + } +} + +const hasCodexCredentials = Boolean( + process.env.OPENAI_API_KEY || + process.env.CODEX_API_KEY || + process.env.OPENROUTER_API_KEY +); + +describe("agent MCP end-to-end", () => { + const runTest = hasCodexCredentials ? test : test.skip; + runTest( + "creates a codex agent and writes a file", + async () => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-")); + const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); + const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-")); + const port = await getAvailablePort(); + const basicUsers = { test: "pass" }; + const [agentMcpUser, agentMcpPassword] = + Object.entries(basicUsers)[0] ?? []; + const agentMcpAuthHeader = + agentMcpUser && agentMcpPassword + ? `Basic ${Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")}` + : undefined; + const agentMcpBearerToken = + agentMcpUser && agentMcpPassword + ? Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64") + : undefined; + + const daemonConfig: PaseoDaemonConfig = { + port, + paseoHome, + agentMcpRoute: "/mcp/agents", + agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`], + auth: { + basicUsers, + agentMcpAuthHeader, + agentMcpBearerToken, + realm: "Voice Assistant", + }, + staticDir, + mcpDebug: false, + agentClients: {}, + agentRegistryPath: path.join(paseoHome, "agents.json"), + agentControlMcp: { + url: `http://127.0.0.1:${port}/mcp/agents`, + ...(agentMcpAuthHeader + ? { headers: { Authorization: agentMcpAuthHeader } } + : {}), + }, + }; + + const previousCodexSessionDir = process.env.CODEX_SESSION_DIR; + const previousCodexHome = process.env.CODEX_HOME; + const codexSessionDir = await mkdtemp( + path.join(os.tmpdir(), "codex-session-") + ); + const codexHome = await mkdtemp(path.join(os.tmpdir(), "codex-home-")); + process.env.CODEX_SESSION_DIR = codexSessionDir; + process.env.CODEX_HOME = codexHome; + + const daemon = await createPaseoDaemon(daemonConfig); + await new Promise((resolve) => { + daemon.httpServer.listen(port, () => resolve()); + }); + + const transport = new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${port}/mcp/agents`), + agentMcpAuthHeader + ? { requestInit: { headers: { Authorization: agentMcpAuthHeader } } } + : undefined + ); + const client = (await experimental_createMCPClient({ + transport, + })) as McpClient; + + let agentId: string | null = null; + + try { + const result = (await client.callTool({ + name: "create_agent", + args: { + cwd: agentCwd, + title: "MCP e2e smoke", + agentType: "codex", + initialMode: "full-access", + background: false, + }, + })) as McpToolResult; + + const payload = getStructuredContent(result); + expect(payload).toBeTruthy(); + agentId = payload?.agentId as string | null; + expect(agentId).toBeTruthy(); + + const prompt = [ + "Use a shell command to create ./mcp-smoke.txt.", + "Write exactly ok (no quotes) into the file.", + "Then reply with done and stop.", + ].join("\n"); + + const promptResult = (await client.callTool({ + name: "send_agent_prompt", + args: { + agentId, + prompt, + sessionMode: "full-access", + background: false, + }, + })) as McpToolResult; + + const promptPayload = getStructuredContent(promptResult); + if (promptPayload?.permission) { + const permission = promptPayload.permission as PermissionPayload; + await client.callTool({ + name: "respond_to_permission", + args: { + agentId, + requestId: permission.id, + response: { behavior: "allow" }, + }, + }); + } + await settleAgentRun(client, agentId); + + const filePath = path.join(agentCwd, "mcp-smoke.txt"); + let contents: string; + try { + contents = await waitForFile(filePath); + } catch (error) { + const activityResult = (await client.callTool({ + name: "get_agent_activity", + args: { agentId, limit: 25 }, + })) as McpToolResult; + const activityPayload = getStructuredContent(activityResult); + const activitySummary = activityPayload?.content; + const details = activitySummary + ? `Agent activity:\n${activitySummary}` + : "Agent activity unavailable"; + throw new Error(`${(error as Error).message}\n${details}`); + } + expect(contents.trim()).toBe("ok"); + } finally { + if (agentId) { + await client.callTool({ name: "kill_agent", args: { agentId } }); + } + await client.close(); + await daemon.close().catch(() => undefined); + if (previousCodexSessionDir === undefined) { + delete process.env.CODEX_SESSION_DIR; + } else { + process.env.CODEX_SESSION_DIR = previousCodexSessionDir; + } + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = previousCodexHome; + } + await rm(paseoHome, { recursive: true, force: true }); + await rm(staticDir, { recursive: true, force: true }); + await rm(agentCwd, { recursive: true, force: true }); + await rm(codexSessionDir, { recursive: true, force: true }); + await rm(codexHome, { recursive: true, force: true }); + } + }, + 180_000 + ); +});