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 | Legacy unscoped task/approval reads are disabled. Requests must include workspace context. | 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 | You can load callable tools automatically from MCP servers and OpenAPI specs via env config: | 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 | OpenAPI auth modes in source config: | 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 | The web UI supports adding MCP/OpenAPI sources per workspace and viewing discovered workspace tool inventory. | 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 | ## Vercel Sandbox Runtime | 126 | ## Vercel Sandbox Runtime |
| 98 | 127 | ||
| 99 | This repo includes a `vercel-sandbox` runtime that runs generated code in Vercel Sandbox VMs while keeping the same `await tools.*(...)` flow. | 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 | "@apidevtools/swagger-parser": "^12.1.0", | 6 | "@apidevtools/swagger-parser": "^12.1.0", |
| 7 | "@modelcontextprotocol/sdk": "^1.26.0", | 7 | "@modelcontextprotocol/sdk": "^1.26.0", |
| 8 | "@vercel/sandbox": "latest", | 8 | "@vercel/sandbox": "latest", |
| 9 | "convex": "latest" | 9 | "convex": "latest", |
| 10 | "zod": "^4.3.6" | ||
| 10 | }, | 11 | }, |
| 11 | "scripts": { | 12 | "scripts": { |
| 12 | "dev": "bun --hot src/index.ts", | 13 | "dev": "bun --hot src/index.ts", |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { ExecutorDatabase } from "./database"; | 1 | import { ExecutorDatabase } from "./database"; |
| 2 | import { TaskEventHub } from "./events"; | 2 | import { TaskEventHub } from "./events"; |
| 3 | import { handleMcpRequest } from "./mcp-server"; | ||
| 3 | import { LocalBunRuntime } from "./runtimes/local-bun-runtime"; | 4 | import { LocalBunRuntime } from "./runtimes/local-bun-runtime"; |
| 4 | import { VercelSandboxRuntime } from "./runtimes/vercel-sandbox-runtime"; | 5 | import { VercelSandboxRuntime } from "./runtimes/vercel-sandbox-runtime"; |
| 5 | import { ExecutorService, getTaskTerminalState } from "./service"; | 6 | import { ExecutorService, getTaskTerminalState } from "./service"; |
| @@ -181,6 +182,11 @@ async function createTaskEventsResponse(taskId: string): Promise<Response> { · 175 unmodified lines | |||
| 181 | const server = Bun.serve({ | 182 | const server = Bun.serve({ |
| 182 | port, | 183 | port, |
| 183 | routes: { | 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 | "/api/health": { | 190 | "/api/health": { |
| 185 | GET: () => json({ ok: true, tools: service.getBaseToolCount() }), | 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 | if (typeof merged.url !== "string" || merged.url.trim().length === 0) { | 86 | if (typeof merged.url !== "string" || merged.url.trim().length === 0) { |
| 87 | throw new Error(`MCP source '${raw.name}' missing url`); | 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 | return merged as unknown as ExternalToolSourceConfig; | 114 | return merged as unknown as ExternalToolSourceConfig; |
| 90 | } | 115 | } |
| 91 | 116 | ||
| @@ -11,6 +11,7 @@ export interface McpToolSourceConfig { · 10 unmodified lines | |||
| 11 | name: string; | 11 | name: string; |
| 12 | url: string; | 12 | url: string; |
| 13 | transport?: "sse" | "streamable-http"; | 13 | transport?: "sse" | "streamable-http"; |
| 14 | queryParams?: Record<string, string>; | ||
| 14 | defaultApproval?: ToolApprovalMode; | 15 | defaultApproval?: ToolApprovalMode; |
| 15 | overrides?: Record<string, { approval?: ToolApprovalMode }>; | 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 | async function connectMcp( | 115 | async function connectMcp( |
| 115 | url: string, | 116 | url: string, |
| 117 | queryParams: Record<string, string> | undefined, | ||
| 116 | preferredTransport?: "sse" | "streamable-http", | 118 | preferredTransport?: "sse" | "streamable-http", |
| 117 | ): Promise<{ client: Client; close: () => Promise<void> }> { | 119 | ): Promise<{ client: Client; close: () => Promise<void> }> { |
| 118 | const endpoint = new URL(url); | 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 | const client = new Client( | 127 | const client = new Client( |
| 120 | { name: "executor-tool-loader", version: "0.1.0" }, | 128 | { name: "executor-tool-loader", version: "0.1.0" }, |
| 121 | { capabilities: {} }, | 129 | { capabilities: {} }, |
| @@ -141,7 +149,13 @@ async function connectMcp( · 19 unmodified lines | |||
| 141 | } | 149 | } |
| 142 | 150 | ||
| 143 | async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDefinition[]> { | 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 | async function callToolWithReconnect(name: string, input: Record<string, unknown>): Promise<unknown> { | 160 | async function callToolWithReconnect(name: string, input: Record<string, unknown>): Promise<unknown> { |
| 147 | try { | 161 | try { |
| @@ -158,7 +172,7 @@ async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDefinition · 10 unmodified lines | |||
| 158 | // ignore | 172 | // ignore |
| 159 | } | 173 | } |
| 160 | 174 | ||
| 161 | connection = await connectMcp(config.url, config.transport); | 175 | connection = await connectMcp(config.url, queryParams, config.transport); |
| 162 | return await connection.client.callTool({ name, arguments: input }); | 176 | return await connection.client.callTool({ name, arguments: input }); |
| 163 | } | 177 | } |
| 164 | } | 178 | } |
| @@ -266,6 +266,8 @@ function AddSourceDialog({ · 265 unmodified lines | |||
| 266 | const [nameManuallyEdited, setNameManuallyEdited] = useState(false); | 266 | const [nameManuallyEdited, setNameManuallyEdited] = useState(false); |
| 267 | const [endpoint, setEndpoint] = useState(""); | 267 | const [endpoint, setEndpoint] = useState(""); |
| 268 | const [baseUrl, setBaseUrl] = useState(""); | 268 | const [baseUrl, setBaseUrl] = useState(""); |
| 269 | const [mcpTransport, setMcpTransport] = useState<"auto" | "streamable-http" | "sse">("auto"); | ||
| 270 | const [mcpActorQueryParamKey, setMcpActorQueryParamKey] = useState("userId"); | ||
| 269 | const [submitting, setSubmitting] = useState(false); | 271 | const [submitting, setSubmitting] = useState(false); |
| 270 | const [addingPreset, setAddingPreset] = useState<string | null>(null); | 272 | const [addingPreset, setAddingPreset] = useState<string | null>(null); |
| 271 | 273 | ||
| @@ -286,6 +288,8 @@ function AddSourceDialog({ · 14 unmodified lines | |||
| 286 | setName(""); | 288 | setName(""); |
| 287 | setEndpoint(""); | 289 | setEndpoint(""); |
| 288 | setBaseUrl(""); | 290 | setBaseUrl(""); |
| 291 | setMcpTransport("auto"); | ||
| 292 | setMcpActorQueryParamKey("userId"); | ||
| 289 | setNameManuallyEdited(false); | 293 | setNameManuallyEdited(false); |
| 290 | setPresetsOpen(false); | 294 | setPresetsOpen(false); |
| 291 | setAddingPreset(null); | 295 | setAddingPreset(null); |
| @@ -341,7 +345,13 @@ function AddSourceDialog({ · 49 unmodified lines | |||
| 341 | try { | 345 | try { |
| 342 | const config: Record<string, unknown> = | 346 | const config: Record<string, unknown> = |
| 343 | type === "mcp" | 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 | : type === "graphql" | 355 | : type === "graphql" |
| 346 | ? { endpoint: endpoint } | 356 | ? { endpoint: endpoint } |
| 347 | : { spec: endpoint, ...(baseUrl ? { baseUrl } : {}) }; | 357 | : { spec: endpoint, ...(baseUrl ? { baseUrl } : {}) }; |
| @@ -378,7 +388,7 @@ function AddSourceDialog({ · 30 unmodified lines | |||
| 378 | <Label className="text-xs text-muted-foreground">Type</Label> | 388 | <Label className="text-xs text-muted-foreground">Type</Label> |
| 379 | <Select | 389 | <Select |
| 380 | value={type} | 390 | value={type} |
| 381 | onValueChange={(v) => setType(v as "mcp" | "openapi")} | 391 | onValueChange={(v) => setType(v as "mcp" | "openapi" | "graphql")} |
| 382 | > | 392 | > |
| 383 | <SelectTrigger className="h-8 text-xs bg-background"> | 393 | <SelectTrigger className="h-8 text-xs bg-background"> |
| 384 | <SelectValue /> | 394 | <SelectValue /> |
| @@ -435,6 +445,43 @@ function AddSourceDialog({ · 50 unmodified lines | |||
| 435 | /> | 445 | /> |
| 436 | </div> | 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 | <Button | 485 | <Button |
| 439 | onClick={handleCustomSubmit} | 486 | onClick={handleCustomSubmit} |
| 440 | disabled={submitting || !name.trim() || !endpoint.trim()} | 487 | disabled={submitting || !name.trim() || !endpoint.trim()} |
| @@ -747,7 +794,7 @@ export function ToolsView() { · 306 unmodified lines | |||
| 747 | No external tool sources | 794 | No external tool sources |
| 748 | </p> | 795 | </p> |
| 749 | <p className="text-[11px] text-muted-foreground/60"> | 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 | </p> | 798 | </p> |
| 752 | </div> | 799 | </div> |
| 753 | ) : ( | 800 | ) : ( |
| @@ -20,6 +20,7 @@ · 19 unmodified lines | |||
| 20 | "@modelcontextprotocol/sdk": "^1.26.0", | 20 | "@modelcontextprotocol/sdk": "^1.26.0", |
| 21 | "@vercel/sandbox": "latest", | 21 | "@vercel/sandbox": "latest", |
| 22 | "convex": "latest", | 22 | "convex": "latest", |
| 23 | "zod": "^4.3.6", | ||
| 23 | }, | 24 | }, |
| 24 | "devDependencies": { | 25 | "devDependencies": { |
| 25 | "@types/bun": "latest", | 26 | "@types/bun": "latest", |
| @@ -132,7 +132,7 @@ export async function upsertToolSource( · 131 unmodified lines | |||
| 132 | id?: string; | 132 | id?: string; |
| 133 | workspaceId: string; | 133 | workspaceId: string; |
| 134 | name: string; | 134 | name: string; |
| 135 | type: "mcp" | "openapi"; | 135 | type: "mcp" | "openapi" | "graphql"; |
| 136 | config: Record<string, unknown>; | 136 | config: Record<string, unknown>; |
| 137 | enabled?: boolean; | 137 | enabled?: boolean; |
| 138 | }, | 138 | }, |