Add a /mcp endpoint with a run_code tool so external MCP clients can trigger executor runs, and extend MCP source config/UI with transport and query parameter support for anon scoping.
Unified · Split
| @@ -49,7 +49,7 @@ The browser persists `sessionId` and reuses it to maintain the same anonymous wo · 48 unmodified lines | ||
| 49 | 49 | |
| 50 | 50 | Legacy unscoped task/approval reads are disabled. Requests must include workspace context. |
| 51 | 51 | |
| 52 | −## External Tool Sources (MCP + OpenAPI) | |
| 52 | +## External Tool Sources (MCP + OpenAPI + GraphQL) | |
| 53 | 53 | |
| 54 | 54 | You can load callable tools automatically from MCP servers and OpenAPI specs via env config: |
| 55 | 55 | |
| @@ -76,7 +76,26 @@ Example: · 20 unmodified lines | ||
| 76 | 76 | ] |
| 77 | 77 | ``` |
| 78 | 78 | |
| 79 | −OpenAPI tools are generated as namespaced callables (`tools.<name>.<tag>.<operation>`), and MCP tools are generated as (`tools.<name>.<tool>`). | |
| 79 | +OpenAPI tools are generated as namespaced callables (`tools.<name>.<tag>.<operation>`), GraphQL sources expose `tools.<name>.graphql` plus discoverable pseudo-tools, and MCP tools are generated as (`tools.<name>.<tool>`). | |
| 80 | + | |
| 81 | +For MCP sources, optional config keys: | |
| 82 | + | |
| 83 | +- `transport`: `"streamable-http"` or `"sse"` (default auto-detect) | |
| 84 | +- `queryParams`: object of query-string params appended to the MCP endpoint URL (useful for anonymous user scoping before OAuth auth provider integration) | |
| 85 | + | |
| 86 | +Example MCP config with query params: | |
| 87 | + | |
| 88 | +```json | |
| 89 | +{ | |
| 90 | + "type": "mcp", | |
| 91 | + "name": "my-mcp", | |
| 92 | + "url": "https://mcp.example.com/mcp", | |
| 93 | + "transport": "streamable-http", | |
| 94 | + "queryParams": { | |
| 95 | + "userId": "anon_123" | |
| 96 | + } | |
| 97 | +} | |
| 98 | +``` | |
| 80 | 99 | |
| 81 | 100 | OpenAPI auth modes in source config: |
| 82 | 101 | |
| @@ -94,6 +113,16 @@ Tasks should include `workspaceId`, `actorId`, and optional `clientId` so policy · 11 unmodified lines | ||
| 94 | 113 | |
| 95 | 114 | The web UI supports adding MCP/OpenAPI sources per workspace and viewing discovered workspace tool inventory. |
| 96 | 115 | |
| 116 | +## MCP Endpoint (`run_code`) | |
| 117 | + | |
| 118 | +The executor server also exposes an MCP endpoint at `/mcp` with a `run_code` tool. | |
| 119 | + | |
| 120 | +- tool name: `run_code` | |
| 121 | +- required input: `code` | |
| 122 | +- optional: `runtimeId`, `timeoutMs`, `workspaceId`, `actorId`, `clientId`, `sessionId`, `waitForResult`, `resultTimeoutMs`, `metadata` | |
| 123 | + | |
| 124 | +If `workspaceId` / `actorId` are omitted, executor bootstraps anonymous context (optionally keyed by `sessionId`). | |
| 125 | + | |
| 97 | 126 | ## Vercel Sandbox Runtime |
| 98 | 127 | |
| 99 | 128 | This repo includes a `vercel-sandbox` runtime that runs generated code in Vercel Sandbox VMs while keeping the same `await tools.*(...)` flow. |
| @@ -6,7 +6,8 @@ · 5 unmodified lines | ||
| 6 | 6 | "@apidevtools/swagger-parser": "^12.1.0", |
| 7 | 7 | "@modelcontextprotocol/sdk": "^1.26.0", |
| 8 | 8 | "@vercel/sandbox": "latest", |
| 9 | − "convex": "latest" | |
| 9 | + "convex": "latest", | |
| 10 | + "zod": "^4.3.6" | |
| 10 | 11 | }, |
| 11 | 12 | "scripts": { |
| 12 | 13 | "dev": "bun --hot src/index.ts", |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { ExecutorDatabase } from "./database"; |
| 2 | 2 | import { TaskEventHub } from "./events"; |
| 3 | +import { handleMcpRequest } from "./mcp-server"; | |
| 3 | 4 | import { LocalBunRuntime } from "./runtimes/local-bun-runtime"; |
| 4 | 5 | import { VercelSandboxRuntime } from "./runtimes/vercel-sandbox-runtime"; |
| 5 | 6 | import { ExecutorService, getTaskTerminalState } from "./service"; |
| @@ -181,6 +182,11 @@ async function createTaskEventsResponse(taskId: string): Promise<Response> { · 175 unmodified lines | ||
| 181 | 182 | const server = Bun.serve({ |
| 182 | 183 | port, |
| 183 | 184 | routes: { |
| 185 | + "/mcp": { | |
| 186 | + POST: async (request) => await handleMcpRequest(service, request), | |
| 187 | + GET: async (request) => await handleMcpRequest(service, request), | |
| 188 | + DELETE: async (request) => await handleMcpRequest(service, request), | |
| 189 | + }, | |
| 184 | 190 | "/api/health": { |
| 185 | 191 | GET: () => json({ ok: true, tools: service.getBaseToolCount() }), |
| 186 | 192 | }, |
| @@ -0,0 +1,152 @@ | ||
| 1 | +import { expect, test } from "bun:test"; | |
| 2 | +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | |
| 3 | +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | |
| 4 | +import { handleMcpRequest } from "./mcp-server"; | |
| 5 | +import type { AnonymousContext, CreateTaskInput, TaskRecord } from "./types"; | |
| 6 | + | |
| 7 | +class FakeMcpService { | |
| 8 | + private readonly tasks = new Map<string, TaskRecord>(); | |
| 9 | + private readonly sessions = new Map<string, AnonymousContext>(); | |
| 10 | + | |
| 11 | + async createTask(input: CreateTaskInput): Promise<{ task: TaskRecord }> { | |
| 12 | + const id = `task_${crypto.randomUUID()}`; | |
| 13 | + const now = Date.now(); | |
| 14 | + const queued: TaskRecord = { | |
| 15 | + id, | |
| 16 | + code: input.code, | |
| 17 | + runtimeId: input.runtimeId ?? "local-bun", | |
| 18 | + status: "queued", | |
| 19 | + timeoutMs: input.timeoutMs ?? 15_000, | |
| 20 | + metadata: input.metadata ?? {}, | |
| 21 | + workspaceId: input.workspaceId, | |
| 22 | + actorId: input.actorId, | |
| 23 | + clientId: input.clientId, | |
| 24 | + createdAt: now, | |
| 25 | + updatedAt: now, | |
| 26 | + }; | |
| 27 | + this.tasks.set(id, queued); | |
| 28 | + | |
| 29 | + queueMicrotask(() => { | |
| 30 | + const current = this.tasks.get(id); | |
| 31 | + if (!current) return; | |
| 32 | + this.tasks.set(id, { | |
| 33 | + ...current, | |
| 34 | + status: "completed", | |
| 35 | + startedAt: current.createdAt + 1, | |
| 36 | + completedAt: current.createdAt + 2, | |
| 37 | + updatedAt: current.createdAt + 2, | |
| 38 | + exitCode: 0, | |
| 39 | + stdout: `ran:${input.code.slice(0, 20)}`, | |
| 40 | + stderr: "", | |
| 41 | + }); | |
| 42 | + }); | |
| 43 | + | |
| 44 | + return { task: queued }; | |
| 45 | + } | |
| 46 | + | |
| 47 | + async getTask(taskId: string, workspaceId?: string): Promise<TaskRecord | null> { | |
| 48 | + const task = this.tasks.get(taskId) ?? null; | |
| 49 | + if (!task) return null; | |
| 50 | + if (workspaceId && task.workspaceId !== workspaceId) return null; | |
| 51 | + return task; | |
| 52 | + } | |
| 53 | + | |
| 54 | + async bootstrapAnonymousContext(sessionId?: string): Promise<AnonymousContext> { | |
| 55 | + if (sessionId && this.sessions.has(sessionId)) { | |
| 56 | + const existing = this.sessions.get(sessionId)!; | |
| 57 | + const updated = { ...existing, lastSeenAt: Date.now() }; | |
| 58 | + this.sessions.set(sessionId, updated); | |
| 59 | + return updated; | |
| 60 | + } | |
| 61 | + | |
| 62 | + const now = Date.now(); | |
| 63 | + const context: AnonymousContext = { | |
| 64 | + sessionId: sessionId ?? `anon_session_${crypto.randomUUID()}`, | |
| 65 | + workspaceId: `ws_${crypto.randomUUID()}`, | |
| 66 | + actorId: `anon_${crypto.randomUUID()}`, | |
| 67 | + clientId: "mcp", | |
| 68 | + createdAt: now, | |
| 69 | + lastSeenAt: now, | |
| 70 | + }; | |
| 71 | + this.sessions.set(context.sessionId, context); | |
| 72 | + return context; | |
| 73 | + } | |
| 74 | +} | |
| 75 | + | |
| 76 | +async function withMcpClient<T>( | |
| 77 | + service: FakeMcpService, | |
| 78 | + run: (client: Client) => Promise<T>, | |
| 79 | +): Promise<T> { | |
| 80 | + const server = Bun.serve({ | |
| 81 | + port: 0, | |
| 82 | + fetch: (request) => { | |
| 83 | + const url = new URL(request.url); | |
| 84 | + if (url.pathname === "/mcp") { | |
| 85 | + return handleMcpRequest(service, request); | |
| 86 | + } | |
| 87 | + return new Response("Not found", { status: 404 }); | |
| 88 | + }, | |
| 89 | + }); | |
| 90 | + | |
| 91 | + const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${server.port}/mcp`)); | |
| 92 | + const client = new Client({ name: "executor-mcp-test", version: "1.0.0" }, { capabilities: {} }); | |
| 93 | + | |
| 94 | + try { | |
| 95 | + await client.connect(transport); | |
| 96 | + return await run(client); | |
| 97 | + } finally { | |
| 98 | + await transport.close().catch(() => {}); | |
| 99 | + await client.close().catch(() => {}); | |
| 100 | + server.stop(true); | |
| 101 | + } | |
| 102 | +} | |
| 103 | + | |
| 104 | +test("run_code MCP tool returns terminal task result", async () => { | |
| 105 | + const service = new FakeMcpService(); | |
| 106 | + | |
| 107 | + await withMcpClient(service, async (client) => { | |
| 108 | + const result = (await client.callTool({ | |
| 109 | + name: "run_code", | |
| 110 | + arguments: { | |
| 111 | + code: "console.log('hello from mcp')", | |
| 112 | + workspaceId: "ws_test", | |
| 113 | + actorId: "actor_test", | |
| 114 | + clientId: "assistant", | |
| 115 | + }, | |
| 116 | + })) as { | |
| 117 | + isError?: boolean; | |
| 118 | + content: Array<{ type: string; text?: string }>; | |
| 119 | + }; | |
| 120 | + | |
| 121 | + expect(result.isError).toBeUndefined(); | |
| 122 | + const text = result.content.find((part) => part.type === "text"); | |
| 123 | + expect(text?.type).toBe("text"); | |
| 124 | + if (text?.type === "text") { | |
| 125 | + expect(text.text).toContain("status: completed"); | |
| 126 | + expect(text.text).toContain("ran:console.log('hello f"); | |
| 127 | + } | |
| 128 | + }); | |
| 129 | +}); | |
| 130 | + | |
| 131 | +test("run_code MCP tool bootstraps anonymous context when workspace is omitted", async () => { | |
| 132 | + const service = new FakeMcpService(); | |
| 133 | + | |
| 134 | + await withMcpClient(service, async (client) => { | |
| 135 | + const result = (await client.callTool({ | |
| 136 | + name: "run_code", | |
| 137 | + arguments: { | |
| 138 | + code: "console.log('anon')", | |
| 139 | + sessionId: "mcp_session_test", | |
| 140 | + }, | |
| 141 | + })) as { | |
| 142 | + isError?: boolean; | |
| 143 | + structuredContent?: Record<string, unknown>; | |
| 144 | + }; | |
| 145 | + | |
| 146 | + expect(result.isError).toBeUndefined(); | |
| 147 | + const structured = result.structuredContent; | |
| 148 | + expect(typeof structured?.workspaceId).toBe("string"); | |
| 149 | + expect(typeof structured?.actorId).toBe("string"); | |
| 150 | + expect(structured?.sessionId).toBe("mcp_session_test"); | |
| 151 | + }); | |
| 152 | +}); | |
| @@ -0,0 +1,230 @@ | ||
| 1 | +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | |
| 2 | +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; | |
| 3 | +import { z } from "zod"; | |
| 4 | +import { getTaskTerminalState } from "./service"; | |
| 5 | +import type { AnonymousContext, CreateTaskInput, TaskRecord } from "./types"; | |
| 6 | + | |
| 7 | +interface McpExecutorService { | |
| 8 | + createTask(input: CreateTaskInput): Promise<{ task: TaskRecord }>; | |
| 9 | + getTask(taskId: string, workspaceId?: string): Promise<TaskRecord | null>; | |
| 10 | + bootstrapAnonymousContext(sessionId?: string): Promise<AnonymousContext>; | |
| 11 | +} | |
| 12 | + | |
| 13 | +const RUN_CODE_INPUT = { | |
| 14 | + code: z.string().min(1), | |
| 15 | + timeoutMs: z.number().int().min(1).max(600_000).optional(), | |
| 16 | + runtimeId: z.string().optional(), | |
| 17 | + metadata: z.record(z.string(), z.any()).optional(), | |
| 18 | + workspaceId: z.string().optional(), | |
| 19 | + actorId: z.string().optional(), | |
| 20 | + clientId: z.string().optional(), | |
| 21 | + sessionId: z.string().optional(), | |
| 22 | + waitForResult: z.boolean().optional(), | |
| 23 | + resultTimeoutMs: z.number().int().min(100).max(900_000).optional(), | |
| 24 | +} as const; | |
| 25 | + | |
| 26 | +type RunCodeInput = { | |
| 27 | + code: string; | |
| 28 | + timeoutMs?: number; | |
| 29 | + runtimeId?: string; | |
| 30 | + metadata?: Record<string, unknown>; | |
| 31 | + workspaceId?: string; | |
| 32 | + actorId?: string; | |
| 33 | + clientId?: string; | |
| 34 | + sessionId?: string; | |
| 35 | + waitForResult?: boolean; | |
| 36 | + resultTimeoutMs?: number; | |
| 37 | +}; | |
| 38 | + | |
| 39 | +function asCodeBlock(language: string, value: string): string { | |
| 40 | + return `\n\n\`\`\`${language}\n${value}\n\`\`\``; | |
| 41 | +} | |
| 42 | + | |
| 43 | +function textContent(text: string): { type: "text"; text: string } { | |
| 44 | + return { type: "text", text }; | |
| 45 | +} | |
| 46 | + | |
| 47 | +function summarizeTask(task: TaskRecord): string { | |
| 48 | + const lines = [ | |
| 49 | + `taskId: ${task.id}`, | |
| 50 | + `status: ${task.status}`, | |
| 51 | + `runtimeId: ${task.runtimeId}`, | |
| 52 | + ]; | |
| 53 | + | |
| 54 | + if (task.exitCode !== undefined) { | |
| 55 | + lines.push(`exitCode: ${task.exitCode}`); | |
| 56 | + } | |
| 57 | + | |
| 58 | + if (task.error) { | |
| 59 | + lines.push(`error: ${task.error}`); | |
| 60 | + } | |
| 61 | + | |
| 62 | + let text = lines.join("\n"); | |
| 63 | + if (task.stdout && task.stdout.trim()) { | |
| 64 | + text += asCodeBlock("text", task.stdout); | |
| 65 | + } | |
| 66 | + if (task.stderr && task.stderr.trim()) { | |
| 67 | + text += asCodeBlock("text", task.stderr); | |
| 68 | + } | |
| 69 | + return text; | |
| 70 | +} | |
| 71 | + | |
| 72 | +async function waitForTerminalTask( | |
| 73 | + service: McpExecutorService, | |
| 74 | + taskId: string, | |
| 75 | + workspaceId: string, | |
| 76 | + waitTimeoutMs: number, | |
| 77 | +): Promise<TaskRecord | null> { | |
| 78 | + const deadline = Date.now() + waitTimeoutMs; | |
| 79 | + while (Date.now() < deadline) { | |
| 80 | + const task = await service.getTask(taskId, workspaceId); | |
| 81 | + if (!task) { | |
| 82 | + return null; | |
| 83 | + } | |
| 84 | + if (getTaskTerminalState(task.status)) { | |
| 85 | + return task; | |
| 86 | + } | |
| 87 | + await Bun.sleep(300); | |
| 88 | + } | |
| 89 | + return await service.getTask(taskId, workspaceId); | |
| 90 | +} | |
| 91 | + | |
| 92 | +async function resolveExecutionContext( | |
| 93 | + service: McpExecutorService, | |
| 94 | + input: RunCodeInput, | |
| 95 | + mcpSessionId?: string, | |
| 96 | +): Promise<{ workspaceId: string; actorId: string; clientId?: string; sessionId?: string }> { | |
| 97 | + if (input.workspaceId || input.actorId) { | |
| 98 | + if (!input.workspaceId || !input.actorId) { | |
| 99 | + throw new Error("workspaceId and actorId must be provided together"); | |
| 100 | + } | |
| 101 | + return { | |
| 102 | + workspaceId: input.workspaceId, | |
| 103 | + actorId: input.actorId, | |
| 104 | + clientId: input.clientId, | |
| 105 | + sessionId: input.sessionId, | |
| 106 | + }; | |
| 107 | + } | |
| 108 | + | |
| 109 | + const seededSessionId = input.sessionId ?? (mcpSessionId ? `mcp_${mcpSessionId}` : undefined); | |
| 110 | + const anonymous = await service.bootstrapAnonymousContext(seededSessionId); | |
| 111 | + return { | |
| 112 | + workspaceId: anonymous.workspaceId, | |
| 113 | + actorId: anonymous.actorId, | |
| 114 | + clientId: input.clientId ?? anonymous.clientId, | |
| 115 | + sessionId: anonymous.sessionId, | |
| 116 | + }; | |
| 117 | +} | |
| 118 | + | |
| 119 | +function createRunCodeTool(service: McpExecutorService) { | |
| 120 | + return async ( | |
| 121 | + input: RunCodeInput, | |
| 122 | + extra: { sessionId?: string }, | |
| 123 | + ): Promise<{ | |
| 124 | + content: Array<{ type: "text"; text: string }>; | |
| 125 | + structuredContent?: Record<string, unknown>; | |
| 126 | + isError?: boolean; | |
| 127 | + }> => { | |
| 128 | + const context = await resolveExecutionContext(service, input, extra.sessionId); | |
| 129 | + | |
| 130 | + const created = await service.createTask({ | |
| 131 | + code: input.code, | |
| 132 | + timeoutMs: input.timeoutMs, | |
| 133 | + runtimeId: input.runtimeId, | |
| 134 | + metadata: input.metadata, | |
| 135 | + workspaceId: context.workspaceId, | |
| 136 | + actorId: context.actorId, | |
| 137 | + clientId: context.clientId, | |
| 138 | + }); | |
| 139 | + | |
| 140 | + const waitForResult = input.waitForResult ?? true; | |
| 141 | + if (!waitForResult) { | |
| 142 | + return { | |
| 143 | + content: [textContent(`Queued task ${created.task.id}`)], | |
| 144 | + structuredContent: { | |
| 145 | + taskId: created.task.id, | |
| 146 | + status: created.task.status, | |
| 147 | + workspaceId: context.workspaceId, | |
| 148 | + actorId: context.actorId, | |
| 149 | + sessionId: context.sessionId, | |
| 150 | + }, | |
| 151 | + }; | |
| 152 | + } | |
| 153 | + | |
| 154 | + const waitTimeoutMs = input.resultTimeoutMs ?? Math.max((input.timeoutMs ?? created.task.timeoutMs) + 10_000, 15_000); | |
| 155 | + const task = await waitForTerminalTask(service, created.task.id, context.workspaceId, waitTimeoutMs); | |
| 156 | + | |
| 157 | + if (!task) { | |
| 158 | + return { | |
| 159 | + content: [textContent(`Task ${created.task.id} not found while waiting for result`)], | |
| 160 | + isError: true, | |
| 161 | + }; | |
| 162 | + } | |
| 163 | + | |
| 164 | + if (!getTaskTerminalState(task.status)) { | |
| 165 | + return { | |
| 166 | + content: [textContent(`Task ${task.id} is still ${task.status}`)], | |
| 167 | + structuredContent: { | |
| 168 | + taskId: task.id, | |
| 169 | + status: task.status, | |
| 170 | + workspaceId: context.workspaceId, | |
| 171 | + actorId: context.actorId, | |
| 172 | + sessionId: context.sessionId, | |
| 173 | + }, | |
| 174 | + }; | |
| 175 | + } | |
| 176 | + | |
| 177 | + const isError = task.status !== "completed"; | |
| 178 | + return { | |
| 179 | + content: [textContent(summarizeTask(task))], | |
| 180 | + structuredContent: { | |
| 181 | + taskId: task.id, | |
| 182 | + status: task.status, | |
| 183 | + runtimeId: task.runtimeId, | |
| 184 | + exitCode: task.exitCode, | |
| 185 | + error: task.error, | |
| 186 | + stdout: task.stdout, | |
| 187 | + stderr: task.stderr, | |
| 188 | + workspaceId: context.workspaceId, | |
| 189 | + actorId: context.actorId, | |
| 190 | + sessionId: context.sessionId, | |
| 191 | + }, | |
| 192 | + ...(isError ? { isError: true } : {}), | |
| 193 | + }; | |
| 194 | + }; | |
| 195 | +} | |
| 196 | + | |
| 197 | +function createMcpServer(service: McpExecutorService): McpServer { | |
| 198 | + const mcp = new McpServer( | |
| 199 | + { name: "executor", version: "0.1.0" }, | |
| 200 | + { capabilities: { tools: {} } }, | |
| 201 | + ); | |
| 202 | + | |
| 203 | + mcp.registerTool( | |
| 204 | + "run_code", | |
| 205 | + { | |
| 206 | + description: | |
| 207 | + "Run JavaScript/TypeScript code in the executor runtime. Defaults to waiting for terminal result and returns stdout/stderr.", | |
| 208 | + inputSchema: RUN_CODE_INPUT, | |
| 209 | + }, | |
| 210 | + createRunCodeTool(service), | |
| 211 | + ); | |
| 212 | + | |
| 213 | + return mcp; | |
| 214 | +} | |
| 215 | + | |
| 216 | +export async function handleMcpRequest(service: McpExecutorService, request: Request): Promise<Response> { | |
| 217 | + const transport = new WebStandardStreamableHTTPServerTransport({ | |
| 218 | + sessionIdGenerator: undefined, | |
| 219 | + enableJsonResponse: true, | |
| 220 | + }); | |
| 221 | + const mcp = createMcpServer(service); | |
| 222 | + | |
| 223 | + try { | |
| 224 | + await mcp.connect(transport); | |
| 225 | + return await transport.handleRequest(request); | |
| 226 | + } finally { | |
| 227 | + await transport.close().catch(() => {}); | |
| 228 | + await mcp.close().catch(() => {}); | |
| 229 | + } | |
| 230 | +} | |
| @@ -86,6 +86,31 @@ function normalizeExternalToolSource(raw: { · 85 unmodified lines | ||
| 86 | 86 | if (typeof merged.url !== "string" || merged.url.trim().length === 0) { |
| 87 | 87 | throw new Error(`MCP source '${raw.name}' missing url`); |
| 88 | 88 | } |
| 89 | + | |
| 90 | + if ( | |
| 91 | + merged.transport !== undefined && | |
| 92 | + merged.transport !== "sse" && | |
| 93 | + merged.transport !== "streamable-http" | |
| 94 | + ) { | |
| 95 | + throw new Error(`MCP source '${raw.name}' has invalid transport (expected 'sse' or 'streamable-http')`); | |
| 96 | + } | |
| 97 | + | |
| 98 | + if (merged.queryParams !== undefined) { | |
| 99 | + const queryParams = merged.queryParams; | |
| 100 | + if (!queryParams || typeof queryParams !== "object" || Array.isArray(queryParams)) { | |
| 101 | + throw new Error(`MCP source '${raw.name}' queryParams must be an object`); | |
| 102 | + } | |
| 103 | + | |
| 104 | + for (const [key, value] of Object.entries(queryParams as Record<string, unknown>)) { | |
| 105 | + if (typeof key !== "string" || key.trim().length === 0) { | |
| 106 | + throw new Error(`MCP source '${raw.name}' queryParams contains an invalid key`); | |
| 107 | + } | |
| 108 | + if (typeof value !== "string") { | |
| 109 | + throw new Error(`MCP source '${raw.name}' queryParams values must be strings`); | |
| 110 | + } | |
| 111 | + } | |
| 112 | + } | |
| 113 | + | |
| 89 | 114 | return merged as unknown as ExternalToolSourceConfig; |
| 90 | 115 | } |
| 91 | 116 | |
| @@ -11,6 +11,7 @@ export interface McpToolSourceConfig { · 10 unmodified lines | ||
| 11 | 11 | name: string; |
| 12 | 12 | url: string; |
| 13 | 13 | transport?: "sse" | "streamable-http"; |
| 14 | + queryParams?: Record<string, string>; | |
| 14 | 15 | defaultApproval?: ToolApprovalMode; |
| 15 | 16 | overrides?: Record<string, { approval?: ToolApprovalMode }>; |
| 16 | 17 | } |
| @@ -113,9 +114,16 @@ function jsonSchemaTypeHint(schema: unknown, depth = 0): string { · 96 unmodified lines | ||
| 113 | 114 | |
| 114 | 115 | async function connectMcp( |
| 115 | 116 | url: string, |
| 117 | + queryParams: Record<string, string> | undefined, | |
| 116 | 118 | preferredTransport?: "sse" | "streamable-http", |
| 117 | 119 | ): Promise<{ client: Client; close: () => Promise<void> }> { |
| 118 | 120 | const endpoint = new URL(url); |
| 121 | + if (queryParams) { | |
| 122 | + for (const [key, value] of Object.entries(queryParams)) { | |
| 123 | + if (!key.trim()) continue; | |
| 124 | + endpoint.searchParams.set(key, value); | |
| 125 | + } | |
| 126 | + } | |
| 119 | 127 | const client = new Client( |
| 120 | 128 | { name: "executor-tool-loader", version: "0.1.0" }, |
| 121 | 129 | { capabilities: {} }, |
| @@ -141,7 +149,13 @@ async function connectMcp( · 19 unmodified lines | ||
| 141 | 149 | } |
| 142 | 150 | |
| 143 | 151 | async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDefinition[]> { |
| 144 | − let connection = await connectMcp(config.url, config.transport); | |
| 152 | + const queryParams = config.queryParams | |
| 153 | + ? Object.fromEntries( | |
| 154 | + Object.entries(config.queryParams).map(([key, value]) => [key, String(value)]), | |
| 155 | + ) | |
| 156 | + : undefined; | |
| 157 | + | |
| 158 | + let connection = await connectMcp(config.url, queryParams, config.transport); | |
| 145 | 159 | |
| 146 | 160 | async function callToolWithReconnect(name: string, input: Record<string, unknown>): Promise<unknown> { |
| 147 | 161 | try { |
| @@ -158,7 +172,7 @@ async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDefinition · 10 unmodified lines | ||
| 158 | 172 | // ignore |
| 159 | 173 | } |
| 160 | 174 | |
| 161 | − connection = await connectMcp(config.url, config.transport); | |
| 175 | + connection = await connectMcp(config.url, queryParams, config.transport); | |
| 162 | 176 | return await connection.client.callTool({ name, arguments: input }); |
| 163 | 177 | } |
| 164 | 178 | } |
| @@ -266,6 +266,8 @@ function AddSourceDialog({ · 265 unmodified lines | ||
| 266 | 266 | const [nameManuallyEdited, setNameManuallyEdited] = useState(false); |
| 267 | 267 | const [endpoint, setEndpoint] = useState(""); |
| 268 | 268 | const [baseUrl, setBaseUrl] = useState(""); |
| 269 | + const [mcpTransport, setMcpTransport] = useState<"auto" | "streamable-http" | "sse">("auto"); | |
| 270 | + const [mcpActorQueryParamKey, setMcpActorQueryParamKey] = useState("userId"); | |
| 269 | 271 | const [submitting, setSubmitting] = useState(false); |
| 270 | 272 | const [addingPreset, setAddingPreset] = useState<string | null>(null); |
| 271 | 273 | |
| @@ -286,6 +288,8 @@ function AddSourceDialog({ · 14 unmodified lines | ||
| 286 | 288 | setName(""); |
| 287 | 289 | setEndpoint(""); |
| 288 | 290 | setBaseUrl(""); |
| 291 | + setMcpTransport("auto"); | |
| 292 | + setMcpActorQueryParamKey("userId"); | |
| 289 | 293 | setNameManuallyEdited(false); |
| 290 | 294 | setPresetsOpen(false); |
| 291 | 295 | setAddingPreset(null); |
| @@ -341,7 +345,13 @@ function AddSourceDialog({ · 49 unmodified lines | ||
| 341 | 345 | try { |
| 342 | 346 | const config: Record<string, unknown> = |
| 343 | 347 | type === "mcp" |
| 344 | − ? { url: endpoint } | |
| 348 | + ? { | |
| 349 | + url: endpoint, | |
| 350 | + ...(mcpTransport !== "auto" ? { transport: mcpTransport } : {}), | |
| 351 | + ...(mcpActorQueryParamKey.trim() && context.actorId | |
| 352 | + ? { queryParams: { [mcpActorQueryParamKey.trim()]: context.actorId } } | |
| 353 | + : {}), | |
| 354 | + } | |
| 345 | 355 | : type === "graphql" |
| 346 | 356 | ? { endpoint: endpoint } |
| 347 | 357 | : { spec: endpoint, ...(baseUrl ? { baseUrl } : {}) }; |
| @@ -378,7 +388,7 @@ function AddSourceDialog({ · 30 unmodified lines | ||
| 378 | 388 | <Label className="text-xs text-muted-foreground">Type</Label> |
| 379 | 389 | <Select |
| 380 | 390 | value={type} |
| 381 | − onValueChange={(v) => setType(v as "mcp" | "openapi")} | |
| 391 | + onValueChange={(v) => setType(v as "mcp" | "openapi" | "graphql")} | |
| 382 | 392 | > |
| 383 | 393 | <SelectTrigger className="h-8 text-xs bg-background"> |
| 384 | 394 | <SelectValue /> |
| @@ -435,6 +445,43 @@ function AddSourceDialog({ · 50 unmodified lines | ||
| 435 | 445 | /> |
| 436 | 446 | </div> |
| 437 | 447 | )} |
| 448 | + {type === "mcp" && ( | |
| 449 | + <> | |
| 450 | + <div className="space-y-1.5"> | |
| 451 | + <Label className="text-xs text-muted-foreground">Transport</Label> | |
| 452 | + <Select | |
| 453 | + value={mcpTransport} | |
| 454 | + onValueChange={(v) => setMcpTransport(v as "auto" | "streamable-http" | "sse")} | |
| 455 | + > | |
| 456 | + <SelectTrigger className="h-8 text-xs bg-background"> | |
| 457 | + <SelectValue /> | |
| 458 | + </SelectTrigger> | |
| 459 | + <SelectContent> | |
| 460 | + <SelectItem value="auto" className="text-xs"> | |
| 461 | + Auto (streamable, then SSE) | |
| 462 | + </SelectItem> | |
| 463 | + <SelectItem value="streamable-http" className="text-xs"> | |
| 464 | + Streamable HTTP | |
| 465 | + </SelectItem> | |
| 466 | + <SelectItem value="sse" className="text-xs"> | |
| 467 | + SSE | |
| 468 | + </SelectItem> | |
| 469 | + </SelectContent> | |
| 470 | + </Select> | |
| 471 | + </div> | |
| 472 | + <div className="space-y-1.5"> | |
| 473 | + <Label className="text-xs text-muted-foreground"> | |
| 474 | + Anon actor query key (optional) | |
| 475 | + </Label> | |
| 476 | + <Input | |
| 477 | + value={mcpActorQueryParamKey} | |
| 478 | + onChange={(e) => setMcpActorQueryParamKey(e.target.value)} | |
| 479 | + placeholder="userId" | |
| 480 | + className="h-8 text-xs font-mono bg-background" | |
| 481 | + /> | |
| 482 | + </div> | |
| 483 | + </> | |
| 484 | + )} | |
| 438 | 485 | <Button |
| 439 | 486 | onClick={handleCustomSubmit} |
| 440 | 487 | disabled={submitting || !name.trim() || !endpoint.trim()} |
| @@ -747,7 +794,7 @@ export function ToolsView() { · 306 unmodified lines | ||
| 747 | 794 | No external tool sources |
| 748 | 795 | </p> |
| 749 | 796 | <p className="text-[11px] text-muted-foreground/60"> |
| 750 | − Add MCP or OpenAPI sources to extend available tools | |
| 797 | + Add MCP, OpenAPI, or GraphQL sources to extend available tools | |
| 751 | 798 | </p> |
| 752 | 799 | </div> |
| 753 | 800 | ) : ( |
| @@ -20,6 +20,7 @@ · 19 unmodified lines | ||
| 20 | 20 | "@modelcontextprotocol/sdk": "^1.26.0", |
| 21 | 21 | "@vercel/sandbox": "latest", |
| 22 | 22 | "convex": "latest", |
| 23 | + "zod": "^4.3.6", | |
| 23 | 24 | }, |
| 24 | 25 | "devDependencies": { |
| 25 | 26 | "@types/bun": "latest", |
| @@ -132,7 +132,7 @@ export async function upsertToolSource( · 131 unmodified lines | ||
| 132 | 132 | id?: string; |
| 133 | 133 | workspaceId: string; |
| 134 | 134 | name: string; |
| 135 | − type: "mcp" | "openapi"; | |
| 135 | + type: "mcp" | "openapi" | "graphql"; | |
| 136 | 136 | config: Record<string, unknown>; |
| 137 | 137 | enabled?: boolean; |
| 138 | 138 | }, |