History

Expose executor run_code over MCP

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.

Rhys Sullivan committed 9 months ago

Commit 7641ff91 parent fe78c8710 files · +514 −9Browse files

Unified · Split

10 files
  1. executor/README.md +31 −2
  2. executor/apps/server/package.json +2 −1
  3. executor/apps/server/src/index.ts +6 −0
  4. executor/apps/server/src/mcp-server.test.ts +152 −0
  5. executor/apps/server/src/mcp-server.ts +230 −0
  6. executor/apps/server/src/service.ts +25 −0
  7. executor/apps/server/src/tool-sources.ts +16 −2
  8. executor/apps/web/src/components/tools-view.tsx +50 −3
  9. executor/bun.lock +1 −0
  10. executor/packages/client/src/index.ts +1 −1
executor/README.md+31−2
@@ -49,7 +49,7 @@ The browser persists `sessionId` and reuses it to maintain the same anonymous wo · 48 unmodified lines
4949
5050 Legacy unscoped task/approval reads are disabled. Requests must include workspace context.
5151
52−## External Tool Sources (MCP + OpenAPI)
52+## External Tool Sources (MCP + OpenAPI + GraphQL)
5353
5454 You can load callable tools automatically from MCP servers and OpenAPI specs via env config:
5555
@@ -76,7 +76,26 @@ Example: · 20 unmodified lines
7676 ]
7777 ```
7878
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+```
8099
81100 OpenAPI auth modes in source config:
82101
@@ -94,6 +113,16 @@ Tasks should include `workspaceId`, `actorId`, and optional `clientId` so policy · 11 unmodified lines
94113
95114 The web UI supports adding MCP/OpenAPI sources per workspace and viewing discovered workspace tool inventory.
96115
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+
97126 ## Vercel Sandbox Runtime
98127
99128 This repo includes a `vercel-sandbox` runtime that runs generated code in Vercel Sandbox VMs while keeping the same `await tools.*(...)` flow.
executor/apps/server/package.json+2−1
@@ -6,7 +6,8 @@ · 5 unmodified lines
66 "@apidevtools/swagger-parser": "^12.1.0",
77 "@modelcontextprotocol/sdk": "^1.26.0",
88 "@vercel/sandbox": "latest",
9− "convex": "latest"
9+ "convex": "latest",
10+ "zod": "^4.3.6"
1011 },
1112 "scripts": {
1213 "dev": "bun --hot src/index.ts",
executor/apps/server/src/index.ts+6−0
@@ -1,5 +1,6 @@
11 import { ExecutorDatabase } from "./database";
22 import { TaskEventHub } from "./events";
3+import { handleMcpRequest } from "./mcp-server";
34 import { LocalBunRuntime } from "./runtimes/local-bun-runtime";
45 import { VercelSandboxRuntime } from "./runtimes/vercel-sandbox-runtime";
56 import { ExecutorService, getTaskTerminalState } from "./service";
@@ -181,6 +182,11 @@ async function createTaskEventsResponse(taskId: string): Promise<Response> { · 175 unmodified lines
181182 const server = Bun.serve({
182183 port,
183184 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+ },
184190 "/api/health": {
185191 GET: () => json({ ok: true, tools: service.getBaseToolCount() }),
186192 },
executor/apps/server/src/mcp-server.test.ts+152−0
@@ -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+});
executor/apps/server/src/mcp-server.ts+230−0
@@ -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+}
executor/apps/server/src/service.ts+25−0
@@ -86,6 +86,31 @@ function normalizeExternalToolSource(raw: { · 85 unmodified lines
8686 if (typeof merged.url !== "string" || merged.url.trim().length === 0) {
8787 throw new Error(`MCP source '${raw.name}' missing url`);
8888 }
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+
89114 return merged as unknown as ExternalToolSourceConfig;
90115 }
91116
executor/apps/server/src/tool-sources.ts+16−2
@@ -11,6 +11,7 @@ export interface McpToolSourceConfig { · 10 unmodified lines
1111 name: string;
1212 url: string;
1313 transport?: "sse" | "streamable-http";
14+ queryParams?: Record<string, string>;
1415 defaultApproval?: ToolApprovalMode;
1516 overrides?: Record<string, { approval?: ToolApprovalMode }>;
1617 }
@@ -113,9 +114,16 @@ function jsonSchemaTypeHint(schema: unknown, depth = 0): string { · 96 unmodified lines
113114
114115 async function connectMcp(
115116 url: string,
117+ queryParams: Record<string, string> | undefined,
116118 preferredTransport?: "sse" | "streamable-http",
117119 ): Promise<{ client: Client; close: () => Promise<void> }> {
118120 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+ }
119127 const client = new Client(
120128 { name: "executor-tool-loader", version: "0.1.0" },
121129 { capabilities: {} },
@@ -141,7 +149,13 @@ async function connectMcp( · 19 unmodified lines
141149 }
142150
143151 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);
145159
146160 async function callToolWithReconnect(name: string, input: Record<string, unknown>): Promise<unknown> {
147161 try {
@@ -158,7 +172,7 @@ async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDefinition · 10 unmodified lines
158172 // ignore
159173 }
160174
161− connection = await connectMcp(config.url, config.transport);
175+ connection = await connectMcp(config.url, queryParams, config.transport);
162176 return await connection.client.callTool({ name, arguments: input });
163177 }
164178 }
executor/apps/web/src/components/tools-view.tsx+50−3
@@ -266,6 +266,8 @@ function AddSourceDialog({ · 265 unmodified lines
266266 const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
267267 const [endpoint, setEndpoint] = useState("");
268268 const [baseUrl, setBaseUrl] = useState("");
269+ const [mcpTransport, setMcpTransport] = useState<"auto" | "streamable-http" | "sse">("auto");
270+ const [mcpActorQueryParamKey, setMcpActorQueryParamKey] = useState("userId");
269271 const [submitting, setSubmitting] = useState(false);
270272 const [addingPreset, setAddingPreset] = useState<string | null>(null);
271273
@@ -286,6 +288,8 @@ function AddSourceDialog({ · 14 unmodified lines
286288 setName("");
287289 setEndpoint("");
288290 setBaseUrl("");
291+ setMcpTransport("auto");
292+ setMcpActorQueryParamKey("userId");
289293 setNameManuallyEdited(false);
290294 setPresetsOpen(false);
291295 setAddingPreset(null);
@@ -341,7 +345,13 @@ function AddSourceDialog({ · 49 unmodified lines
341345 try {
342346 const config: Record<string, unknown> =
343347 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+ }
345355 : type === "graphql"
346356 ? { endpoint: endpoint }
347357 : { spec: endpoint, ...(baseUrl ? { baseUrl } : {}) };
@@ -378,7 +388,7 @@ function AddSourceDialog({ · 30 unmodified lines
378388 <Label className="text-xs text-muted-foreground">Type</Label>
379389 <Select
380390 value={type}
381− onValueChange={(v) => setType(v as "mcp" | "openapi")}
391+ onValueChange={(v) => setType(v as "mcp" | "openapi" | "graphql")}
382392 >
383393 <SelectTrigger className="h-8 text-xs bg-background">
384394 <SelectValue />
@@ -435,6 +445,43 @@ function AddSourceDialog({ · 50 unmodified lines
435445 />
436446 </div>
437447 )}
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+ )}
438485 <Button
439486 onClick={handleCustomSubmit}
440487 disabled={submitting || !name.trim() || !endpoint.trim()}
@@ -747,7 +794,7 @@ export function ToolsView() { · 306 unmodified lines
747794 No external tool sources
748795 </p>
749796 <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
751798 </p>
752799 </div>
753800 ) : (
executor/bun.lock+1−0
@@ -20,6 +20,7 @@ · 19 unmodified lines
2020 "@modelcontextprotocol/sdk": "^1.26.0",
2121 "@vercel/sandbox": "latest",
2222 "convex": "latest",
23+ "zod": "^4.3.6",
2324 },
2425 "devDependencies": {
2526 "@types/bun": "latest",
executor/packages/client/src/index.ts+1−1
@@ -132,7 +132,7 @@ export async function upsertToolSource( · 131 unmodified lines
132132 id?: string;
133133 workspaceId: string;
134134 name: string;
135− type: "mcp" | "openapi";
135+ type: "mcp" | "openapi" | "graphql";
136136 config: Record<string, unknown>;
137137 enabled?: boolean;
138138 },