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
49 49
50Legacy unscoped task/approval reads are disabled. Requests must include workspace context.50Legacy 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
54You can load callable tools automatically from MCP servers and OpenAPI specs via env config:54You 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
79OpenAPI tools are generated as namespaced callables (`tools.<name>.<tag>.<operation>`), and MCP tools are generated as (`tools.<name>.<tool>`).79OpenAPI 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
81For 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
86Example 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
81OpenAPI auth modes in source config:100OpenAPI 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
95The web UI supports adding MCP/OpenAPI sources per workspace and viewing discovered workspace tool inventory.114The web UI supports adding MCP/OpenAPI sources per workspace and viewing discovered workspace tool inventory.
96 115
116## MCP Endpoint (`run_code`)
117
118The 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
124If `workspaceId` / `actorId` are omitted, executor bootstraps anonymous context (optionally keyed by `sessionId`).
125
97## Vercel Sandbox Runtime126## Vercel Sandbox Runtime
98 127
99This repo includes a `vercel-sandbox` runtime that runs generated code in Vercel Sandbox VMs while keeping the same `await tools.*(...)` flow.128This 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
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",
executor/apps/server/src/index.ts+6−0
@@ -1,5 +1,6 @@
1import { ExecutorDatabase } from "./database";1import { ExecutorDatabase } from "./database";
2import { TaskEventHub } from "./events";2import { TaskEventHub } from "./events";
3import { handleMcpRequest } from "./mcp-server";
3import { LocalBunRuntime } from "./runtimes/local-bun-runtime";4import { LocalBunRuntime } from "./runtimes/local-bun-runtime";
4import { VercelSandboxRuntime } from "./runtimes/vercel-sandbox-runtime";5import { VercelSandboxRuntime } from "./runtimes/vercel-sandbox-runtime";
5import { ExecutorService, getTaskTerminalState } from "./service";6import { ExecutorService, getTaskTerminalState } from "./service";
@@ -181,6 +182,11 @@ async function createTaskEventsResponse(taskId: string): Promise<Response> { · 175 unmodified lines
181const server = Bun.serve({182const 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 },
executor/apps/server/src/mcp-server.test.ts+152−0
@@ -0,0 +1,152 @@
1import { expect, test } from "bun:test";
2import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4import { handleMcpRequest } from "./mcp-server";
5import type { AnonymousContext, CreateTaskInput, TaskRecord } from "./types";
6
7class 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
76async 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
104test("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
131test("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 @@
1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
3import { z } from "zod";
4import { getTaskTerminalState } from "./service";
5import type { AnonymousContext, CreateTaskInput, TaskRecord } from "./types";
6
7interface 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
13const 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
26type 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
39function asCodeBlock(language: string, value: string): string {
40 return `\n\n\`\`\`${language}\n${value}\n\`\`\``;
41}
42
43function textContent(text: string): { type: "text"; text: string } {
44 return { type: "text", text };
45}
46
47function 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
72async 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
92async 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
119function 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
197function 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
216export 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
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
executor/apps/server/src/tool-sources.ts+16−2
@@ -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
114async function connectMcp(115async 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
143async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDefinition[]> {151async 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 // ignore172 // 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 }
executor/apps/web/src/components/tools-view.tsx+50−3
@@ -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 <Select389 <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 <Button485 <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 sources794 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 tools797 Add MCP, OpenAPI, or GraphQL sources to extend available tools
751 </p>798 </p>
752 </div>799 </div>
753 ) : (800 ) : (
executor/bun.lock+1−0
@@ -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",
executor/packages/client/src/index.ts+1−1
@@ -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 },