History

types and add api

Rhys Sullivan committed 8 months ago

Commit 53347ce1 parent 9bdab3613 files · +1461 −237Browse files

Unified · Split

13 files
  1. apps/executor/src/cli/main.ts +160 −1
  2. packages/codemode-core/src/tool-map.ts +9 −0
  3. packages/codemode-mcp/src/index.ts +1 −0
  4. packages/codemode-mcp/src/mcp-tools.test.ts +74 −2
  5. packages/codemode-mcp/src/mcp-tools.ts +97 −11
  6. packages/codemode-openapi/package.json +1 −1
  7. packages/codemode-openapi/src/openapi-tools.ts +101 −26
  8. packages/control-plane/src/runtime/executor-tools.ts +258 −26
  9. packages/control-plane/src/runtime/index.ts +2 −0
  10. packages/control-plane/src/runtime/schema-type-signature.ts +114 −0
  11. packages/control-plane/src/runtime/source-auth-service.ts +337 −158
  12. packages/control-plane/src/runtime/tool-artifacts.ts +46 −10
  13. packages/control-plane/src/runtime/workspace-execution-environment.test.ts +261 −2
apps/executor/src/cli/main.ts+160−1
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
99 NodeRuntime,
1010 } from "@effect/platform-node";
1111 import {
12+ EXECUTOR_SOURCES_ADD_HELP_LINES,
1213 ExecutionIdSchema,
1314 RuntimeExecutionResolverService,
1415 createControlPlaneClient,
@@ -146,7 +147,8 @@ const buildWorkflowText = (namespaces: readonly string[] = []): string => · 131 unmodified lines
146147 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',
147148 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",
148149 "3) Call selected tools.<path>(input).",
149− '4) To connect a new MCP source, call tools.executor.sources.add({ endpoint, name, namespace }).',
150+ '4) To connect a source, call tools.executor.sources.add(...) for MCP or OpenAPI APIs.',
151+ ...EXECUTOR_SOURCES_ADD_HELP_LINES,
150152 "5) If execution pauses for interaction, resume it with `executor resume --execution-id ...`.",
151153 "Do not use fetch; use tools.* only.",
152154 ].join("\n");
@@ -305,6 +307,7 @@ const printCallHelp = (workflow: string) => · 152 unmodified lines
305307 ' executor call \'const matches = await tools.discover({ query: "github issues", limit: 5 }); return matches;\'',
306308 ' executor call \'const matches = await tools.discover({ query: "repo details", limit: 1 }); const path = matches.bestPath; return await tools.describe.tool({ path, includeSchemas: true });\'',
307309 ' executor call \'return await tools.executor.sources.add({ endpoint: "https://example.com/mcp", name: "Example", namespace: "example" });\'',
310+ ' executor call \'return await tools.executor.sources.add({ kind: "openapi", endpoint: "https://api.github.com", specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json", name: "GitHub", namespace: "github", auth: { kind: "bearer" } });\'',
308311 " cat script.ts | executor call --stdin",
309312 " executor call --file script.ts",
310313 " executor resume --execution-id exec_123",
@@ -389,6 +392,7 @@ const parseInteractionPayload = (interaction: ExecutionInteraction): { · 78 unmodified lines
389392 message: string;
390393 mode: "form" | "url";
391394 url?: string;
395+ requestedSchema?: Record<string, unknown>;
392396 } | null => {
393397 try {
394398 const parsed = JSON.parse(interaction.payloadJson) as {
@@ -396,6 +400,7 @@ const parseInteractionPayload = (interaction: ExecutionInteraction): { · 1 unmodified line
396400 message?: string;
397401 mode?: "form" | "url";
398402 url?: string;
403+ requestedSchema?: Record<string, unknown>;
399404 };
400405 };
401406
@@ -407,12 +412,158 @@ const parseInteractionPayload = (interaction: ExecutionInteraction): { · 5 unmodified lines
407412 message: parsed.elicitation.message,
408413 mode: parsed.elicitation.mode === "url" ? "url" : "form",
409414 url: parsed.elicitation.url,
415+ requestedSchema:
416+ typeof parsed.elicitation.requestedSchema === "object"
417+ && parsed.elicitation.requestedSchema !== null
418+ && !Array.isArray(parsed.elicitation.requestedSchema)
419+ ? parsed.elicitation.requestedSchema
420+ : undefined,
410421 };
411422 } catch {
412423 return null;
413424 }
414425 };
415426
427+const isRecord = (value: unknown): value is Record<string, unknown> =>
428+ typeof value === "object" && value !== null && !Array.isArray(value);
429+
430+type PromptField = {
431+ name: string;
432+ label: string;
433+ description?: string;
434+ type: string;
435+ required: boolean;
436+ enumValues?: readonly unknown[];
437+};
438+
439+const getPromptFields = (requestedSchema: Record<string, unknown> | undefined): PromptField[] => {
440+ if (!requestedSchema || !isRecord(requestedSchema.properties)) {
441+ return [];
442+ }
443+
444+ const required = new Set(
445+ Array.isArray(requestedSchema.required)
446+ ? requestedSchema.required.filter((value): value is string => typeof value === "string")
447+ : [],
448+ );
449+
450+ return Object.entries(requestedSchema.properties).flatMap(([name, property]) => {
451+ if (!isRecord(property)) {
452+ return [];
453+ }
454+
455+ return [{
456+ name,
457+ label:
458+ typeof property.title === "string" && property.title.trim().length > 0
459+ ? property.title.trim()
460+ : name,
461+ description:
462+ typeof property.description === "string" && property.description.trim().length > 0
463+ ? property.description.trim()
464+ : undefined,
465+ type: typeof property.type === "string" ? property.type : "string",
466+ required: required.has(name),
467+ enumValues: Array.isArray(property.enum) ? property.enum : undefined,
468+ }];
469+ });
470+};
471+
472+const parsePromptValue = (field: PromptField, raw: string):
473+ | { ok: true; value: unknown }
474+ | { ok: false; message: string } => {
475+ if (field.enumValues && field.enumValues.length > 0) {
476+ const normalized = field.enumValues.map((value) => String(value));
477+ if (!normalized.includes(raw)) {
478+ return {
479+ ok: false,
480+ message: `Enter one of: ${normalized.join(", ")}`,
481+ };
482+ }
483+ }
484+
485+ if (field.type === "boolean") {
486+ const normalized = raw.trim().toLowerCase();
487+ if (["y", "yes", "true"].includes(normalized)) {
488+ return { ok: true, value: true };
489+ }
490+ if (["n", "no", "false"].includes(normalized)) {
491+ return { ok: true, value: false };
492+ }
493+ return { ok: false, message: "Enter yes or no" };
494+ }
495+
496+ if (field.type === "number" || field.type === "integer") {
497+ const value = Number(raw);
498+ if (!Number.isFinite(value)) {
499+ return { ok: false, message: "Enter a number" };
500+ }
501+ if (field.type === "integer" && !Number.isInteger(value)) {
502+ return { ok: false, message: "Enter an integer" };
503+ }
504+ return { ok: true, value };
505+ }
506+
507+ if (field.type === "object" || field.type === "array") {
508+ try {
509+ return { ok: true, value: JSON.parse(raw) };
510+ } catch {
511+ return { ok: false, message: "Enter valid JSON" };
512+ }
513+ }
514+
515+ return { ok: true, value: raw };
516+};
517+
518+const promptStructuredInteraction = (parsed: {
519+ message: string;
520+ requestedSchema?: Record<string, unknown>;
521+}) =>
522+ Effect.gen(function* () {
523+ const fields = getPromptFields(parsed.requestedSchema);
524+ if (fields.length === 0) {
525+ return null as Record<string, unknown> | null;
526+ }
527+
528+ yield* Effect.sync(() => {
529+ process.stdout.write(`${parsed.message}\n`);
530+ });
531+
532+ const content: Record<string, unknown> = {};
533+ for (const field of fields) {
534+ if (field.description) {
535+ yield* Effect.sync(() => {
536+ process.stdout.write(`${field.description}\n`);
537+ });
538+ }
539+
540+ while (true) {
541+ const raw = yield* promptLine(
542+ `${field.label}${field.required ? "" : " (optional)"}: `,
543+ );
544+ const trimmed = raw.trim();
545+ if (trimmed.length === 0) {
546+ if (field.required) {
547+ return null;
548+ }
549+ break;
550+ }
551+
552+ const parsedValue = parsePromptValue(field, trimmed);
553+ if (parsedValue.ok) {
554+ content[field.name] = parsedValue.value;
555+ break;
556+ }
557+
558+ yield* Effect.sync(() => {
559+ process.stdout.write(`${parsedValue.message}\n`);
560+ });
561+ }
562+ }
563+
564+ return content;
565+ });
566+
416567 const promptInteraction = (interaction: ExecutionInteraction) =>
417568 Effect.gen(function* () {
418569 const parsed = parseInteractionPayload(interaction);
@@ -428,6 +579,14 @@ const promptInteraction = (interaction: ExecutionInteraction) => · 9 unmodified lines
428579 return null;
429580 }
430581
582+ const structured = yield* promptStructuredInteraction(parsed);
583+ if (structured !== null) {
584+ return JSON.stringify({
585+ action: "accept",
586+ content: structured,
587+ });
588+ }
589+
431590 const line = yield* promptLine(`${parsed.message} [y/N] `);
432591 const normalized = line.trim().toLowerCase();
433592 if (normalized.length === 0) {
packages/codemode-core/src/tool-map.ts+9−0
@@ -1,6 +1,7 @@
11 import type { StandardSchemaV1 } from "@standard-schema/spec";
22 import * as Data from "effect/Data";
33 import * as Effect from "effect/Effect";
4+import * as JSONSchema from "effect/JSONSchema";
45
56 import type {
67 ElicitationRequest,
@@ -340,6 +341,14 @@ const stringifySchema = (value: unknown): string | undefined => { · 333 unmodified lines
340341 }
341342
342343 try {
344+ if (
345+ (typeof value === "object" || typeof value === "function")
346+ && value !== null
347+ && "~standard" in value
348+ ) {
349+ return JSON.stringify(JSONSchema.make(value as any));
350+ }
351+
343352 return JSON.stringify(value);
344353 } catch {
345354 return undefined;
packages/codemode-mcp/src/index.ts+1−0
@@ -13,6 +13,7 @@ export { · 12 unmodified lines
1313 type McpClientLike,
1414 type McpConnection,
1515 type McpConnector,
16+ type McpDiscoveryElicitationContext,
1617 type McpToolManifest,
1718 type McpToolManifestEntry,
1819 } from "./mcp-tools";
packages/codemode-mcp/src/mcp-tools.test.ts+74−2
@@ -1,12 +1,19 @@
11 import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
22 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
33 import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4+import { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types.js";
45 import { describe, expect, it } from "@effect/vitest";
56 import { assertInstanceOf, assertTrue } from "@effect/vitest/utils";
7+import * as Either from "effect/Either";
68 import * as Effect from "effect/Effect";
79 import { z } from "zod/v4";
810
9−import type { ToolDefinition, ToolInput, ToolMap } from "@executor-v3/codemode-core";
11+import type {
12+ ToolDefinition,
13+ ToolInput,
14+ ToolMap,
15+ ToolPath,
16+} from "@executor-v3/codemode-core";
1017
1118 import { createSdkMcpConnector } from "./mcp-connection";
1219 import {
@@ -290,9 +297,74 @@ describe("codemode-mcp", () => { · 277 unmodified lines
290297 }),
291298 );
292299
293− assertTrue(outcome._tag === "Left");
300+ assertTrue(Either.isLeft(outcome));
294301 assertInstanceOf(outcome.left, McpToolsError);
295302 expect(outcome.left.stage).toBe("list_tools");
296303 }),
297304 );
305+
306+ it.effect("supports URL elicitation while discovering MCP tools", () =>
307+ Effect.gen(function* () {
308+ let attempts = 0;
309+ const elicitationMessages: Array<string> = [];
310+
311+ const discovered = yield* discoverMcpToolsFromConnector({
312+ connect: async () => ({
313+ client: {
314+ listTools: async () => {
315+ attempts += 1;
316+
317+ if (attempts === 1) {
318+ throw new UrlElicitationRequiredError([
319+ {
320+ mode: "url",
321+ message: "Authorize tool discovery",
322+ url: "https://example.com/authorize-discovery",
323+ elicitationId: "discover-auth",
324+ },
325+ ]);
326+ }
327+
328+ return {
329+ tools: [
330+ {
331+ name: "Echo",
332+ description: "Echo payload",
333+ inputSchema: {
334+ type: "object",
335+ properties: {
336+ value: { type: "string" },
337+ },
338+ },
339+ },
340+ ],
341+ };
342+ },
343+ callTool: async () => ({ ok: true }),
344+ },
345+ close: async () => undefined,
346+ }),
347+ namespace: "source.mcp",
348+ sourceKey: "mcp.discovery",
349+ mcpDiscoveryElicitation: {
350+ onElicitation: ({ elicitation }) =>
351+ Effect.sync(() => {
352+ elicitationMessages.push(elicitation.message);
353+ return {
354+ action: "accept" as const,
355+ };
356+ }),
357+ path: "executor.sources.add" as ToolPath,
358+ sourceKey: "executor",
359+ args: {
360+ endpoint: "https://example.com/mcp",
361+ },
362+ },
363+ });
364+
365+ expect(attempts).toBe(2);
366+ expect(elicitationMessages).toEqual(["Authorize tool discovery"]);
367+ expect(Object.keys(discovered.tools)).toEqual(["source.mcp.echo"]);
368+ }),
369+ );
298370 });
packages/codemode-mcp/src/mcp-tools.ts+97−11
@@ -1,5 +1,6 @@
11 import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
22 import * as Data from "effect/Data";
3+import * as Either from "effect/Either";
34 import * as Effect from "effect/Effect";
45 import * as PartitionedSemaphore from "effect/PartitionedSemaphore";
56
@@ -8,8 +9,10 @@ import { · 2 unmodified lines
89 toTool,
910 type ElicitationRequest,
1011 type ElicitationResponse,
12+ type ToolInvocationContext,
1113 type ToolExecutionContext,
1214 type ToolMap,
15+ type ToolMetadata,
1316 type ToolPath,
1417 unknownInputSchema,
1518 } from "@executor-v3/codemode-core";
@@ -47,6 +50,15 @@ export type McpConnection = { · 31 unmodified lines
4750
4851 export type McpConnector = () => Promise<McpConnection>;
4952
53+export type McpDiscoveryElicitationContext = {
54+ onElicitation: NonNullable<ToolExecutionContext["onElicitation"]>;
55+ path: ToolPath;
56+ sourceKey: string;
57+ args: Record<string, unknown>;
58+ metadata?: ToolMetadata;
59+ invocation?: ToolInvocationContext;
60+};
61+
5062 type McpDiscoveryStage = "connect" | "list_tools" | "call_tool";
5163
5264 export class McpToolsError extends Data.TaggedError("McpToolsError")<{
@@ -252,6 +264,79 @@ const callMcpTool = (input: { · 199 unmodified lines
252264 catch: (cause) => cause,
253265 });
254266
267+const toToolExecutionContext = (
268+ input: McpDiscoveryElicitationContext | undefined,
269+): ToolExecutionContext | undefined =>
270+ input
271+ ? {
272+ path: input.path,
273+ sourceKey: input.sourceKey,
274+ metadata: input.metadata,
275+ invocation: input.invocation,
276+ onElicitation: input.onElicitation,
277+ }
278+ : undefined;
279+
280+const runMcpListToolsEffect = (input: {
281+ connection: McpConnection;
282+ mcpDiscoveryElicitation?: McpDiscoveryElicitationContext;
283+}): Effect.Effect<unknown, McpToolsError> =>
284+ Effect.gen(function* () {
285+ const executionContext = toToolExecutionContext(input.mcpDiscoveryElicitation);
286+
287+ if (input.mcpDiscoveryElicitation) {
288+ yield* installMcpElicitationHandler({
289+ client: input.connection.client,
290+ toolName: "tools/list",
291+ onElicitation: input.mcpDiscoveryElicitation.onElicitation,
292+ path: input.mcpDiscoveryElicitation.path,
293+ sourceKey: input.mcpDiscoveryElicitation.sourceKey,
294+ args: input.mcpDiscoveryElicitation.args,
295+ executionContext,
296+ });
297+ }
298+
299+ let retries = 0;
300+ while (true) {
301+ const attempt = yield* Effect.either(
302+ Effect.tryPromise({
303+ try: () => input.connection.client.listTools(),
304+ catch: (cause) => cause,
305+ }),
306+ );
307+
308+ if (Either.isRight(attempt)) {
309+ return attempt.right;
310+ }
311+
312+ if (
313+ input.mcpDiscoveryElicitation
314+ && isUrlElicitationRequiredError(attempt.left)
315+ && retries < 2
316+ ) {
317+ yield* resolveUrlElicitations({
318+ cause: attempt.left,
319+ toolName: "tools/list",
320+ onElicitation: input.mcpDiscoveryElicitation.onElicitation,
321+ path: input.mcpDiscoveryElicitation.path,
322+ sourceKey: input.mcpDiscoveryElicitation.sourceKey,
323+ args: input.mcpDiscoveryElicitation.args,
324+ executionContext,
325+ });
326+ retries += 1;
327+ continue;
328+ }
329+
330+ return yield* Effect.fail(
331+ new McpToolsError({
332+ stage: "list_tools",
333+ message: "Failed listing MCP tools",
334+ details: toDetails(attempt.left),
335+ }),
336+ );
337+ }
338+ });
339+
255340 const runMcpToolCallEffect = (input: {
256341 connection: McpConnection;
257342 toolName: string;
@@ -284,7 +369,7 @@ const runMcpToolCallEffect = (input: { · 26 unmodified lines
284369 }),
285370 );
286371
287− if (attempt._tag === "Right") {
372+ if (Either.isRight(attempt)) {
288373 return attempt.right;
289374 }
290375
@@ -385,6 +470,7 @@ export const discoverMcpToolsFromConnector = (input: { · 94 unmodified lines
385470 connect: McpConnector;
386471 namespace?: string;
387472 sourceKey?: string;
473+ mcpDiscoveryElicitation?: McpDiscoveryElicitationContext;
388474 }): Effect.Effect<{ manifest: McpToolManifest; tools: ToolMap }, McpToolsError> =>
389475 Effect.gen(function* () {
390476 const listed = yield* withConnectionEffect({
@@ -395,16 +481,16 @@ export const discoverMcpToolsFromConnector = (input: { · 4 unmodified lines
395481 message: "Failed connecting to MCP server",
396482 details: toDetails(cause),
397483 }),
398− run: (connection) =>
399− Effect.tryPromise({
400− try: () => connection.client.listTools(),
401− catch: (cause) =>
402− new McpToolsError({
403− stage: "list_tools",
404− message: "Failed listing MCP tools",
405− details: toDetails(cause),
406− }),
407− }),
484+ run: (connection) => {
485+ const listEffect = runMcpListToolsEffect({
486+ connection,
487+ mcpDiscoveryElicitation: input.mcpDiscoveryElicitation,
488+ });
489+
490+ return input.mcpDiscoveryElicitation
491+ ? withElicitationClientLock(connection.client, listEffect)
492+ : listEffect;
493+ },
408494 });
409495
410496 const manifest = extractMcpToolManifestFromListToolsResult(listed);
packages/codemode-openapi/package.json+1−1
@@ -14,12 +14,12 @@ · 13 unmodified lines
1414 "test:watch": "vitest"
1515 },
1616 "dependencies": {
17+ "@effect/platform": "catalog:",
1718 "@executor-v3/codemode-core": "workspace:*",
1819 "effect": "catalog:",
1920 "yaml": "^2.8.1"
2021 },
2122 "devDependencies": {
22− "@effect/platform": "catalog:",
2323 "@effect/platform-node": "^0.104.1",
2424 "@effect/vitest": "catalog:",
2525 "@types/node": "catalog:",
packages/codemode-openapi/src/openapi-tools.ts+101−26
@@ -1,5 +1,11 @@
1+import {
2+ FetchHttpClient,
3+ HttpClient,
4+ HttpClientRequest,
5+} from "@effect/platform";
16 import * as Data from "effect/Data";
27 import * as Effect from "effect/Effect";
8+import * as Layer from "effect/Layer";
39
410 import {
511 standardSchemaFromJsonSchema,
@@ -195,13 +201,30 @@ const normalizeHttpUrl = (value: string): string => { · 189 unmodified lines
195201 }
196202 };
197203
198−const decodeFetchResponseBody = async (response: Response): Promise<unknown> => {
199− const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
204+const decodeHttpClientResponseBody = (
205+ response: Awaited<ReturnType<HttpClient.HttpClient["execute"]>> extends Effect.Effect<
206+ infer A,
207+ infer _E,
208+ infer _R
209+ >
210+ ? A
211+ : never,
212+): Effect.Effect<unknown, Error, never> => {
213+ const contentType = response.headers["content-type"]?.toLowerCase() ?? "";
214+
200215 if (contentType.includes("application/json")) {
201− return response.json();
216+ return response.json.pipe(
217+ Effect.mapError((cause) =>
218+ cause instanceof Error ? cause : new Error(String(cause)),
219+ ),
220+ );
202221 }
203222
204− return response.text();
223+ return response.text.pipe(
224+ Effect.mapError((cause) =>
225+ cause instanceof Error ? cause : new Error(String(cause)),
226+ ),
227+ );
205228 };
206229
207230 const inputSchemaFromTypingJson = (inputSchemaJson: string | undefined) => {
@@ -227,6 +250,28 @@ type CreateOpenApiToolsFromManifestInput = { · 19 unmodified lines
227250 sourceKey?: string;
228251 defaultHeaders?: Readonly<Record<string, string>>;
229252 credentialHeaders?: Readonly<Record<string, string>>;
253+ httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
254+};
255+
256+const resolveRequestUrl = (baseUrl: string, resolvedPath: string): URL => {
257+ try {
258+ return new URL(resolvedPath);
259+ } catch {
260+ const base = new URL(baseUrl);
261+ const basePath =
262+ base.pathname === "/"
263+ ? ""
264+ : base.pathname.endsWith("/")
265+ ? base.pathname.slice(0, -1)
266+ : base.pathname;
267+ const pathPart = resolvedPath.startsWith("/") ? resolvedPath : `/${resolvedPath}`;
268+
269+ base.pathname = `${basePath}${pathPart}`.replace(/\/{2,}/g, "/");
270+ base.search = "";
271+ base.hash = "";
272+
273+ return base;
274+ }
230275 };
231276
232277 const buildFetchRequest = (input: {
@@ -237,16 +282,20 @@ const buildFetchRequest = (input: { · 4 unmodified lines
237282 credentialHeaders: Readonly<Record<string, string>>;
238283 }): {
239284 url: URL;
240− init: RequestInit;
285+ method: string;
286+ headers: Record<string, string>;
287+ body?: string;
241288 } => {
242289 const resolvedPath = replacePathTemplate(
243290 input.payload.pathTemplate,
244291 input.args,
245292 input.payload,
246293 );
247− const url = new URL(resolvedPath, input.baseUrl);
294+ const url = resolveRequestUrl(input.baseUrl, resolvedPath);
248295
249− const headers = new Headers(input.defaultHeaders);
296+ const headers: Record<string, string> = {
297+ ...input.defaultHeaders,
298+ };
250299 const cookieParts: Array<string> = [];
251300
252301 for (const parameter of input.payload.parameters) {
@@ -271,14 +320,14 @@ const buildFetchRequest = (input: { · 18 unmodified lines
271320 if (parameter.location === "query") {
272321 url.searchParams.set(parameter.name, encoded);
273322 } else if (parameter.location === "header") {
274− headers.set(parameter.name, encoded);
323+ headers[parameter.name] = encoded;
275324 } else if (parameter.location === "cookie") {
276325 cookieParts.push(`${parameter.name}=${encodeURIComponent(encoded)}`);
277326 }
278327 }
279328
280329 if (cookieParts.length > 0) {
281− headers.set("cookie", cookieParts.join("; "));
330+ headers.cookie = cookieParts.join("; ");
282331 }
283332
284333 let body: string | undefined;
@@ -297,24 +346,22 @@ const buildFetchRequest = (input: { · 12 unmodified lines
297346
298347 const preferredContentType = input.payload.requestBody.contentTypes[0];
299348 if (preferredContentType) {
300− headers.set("content-type", preferredContentType);
301− } else if (!headers.has("content-type")) {
302− headers.set("content-type", "application/json");
349+ headers["content-type"] = preferredContentType;
350+ } else if (!("content-type" in headers)) {
351+ headers["content-type"] = "application/json";
303352 }
304353 }
305354 }
306355
307356 for (const [key, value] of Object.entries(input.credentialHeaders)) {
308− headers.set(key, value);
357+ headers[key] = value;
309358 }
310359
311360 return {
312361 url,
313− init: {
314− method: input.payload.method.toUpperCase(),
315− headers,
316− body,
317− },
362+ method: input.payload.method.toUpperCase(),
363+ headers,
364+ body,
318365 };
319366 };
320367
@@ -326,6 +373,7 @@ export const createOpenApiToolsFromManifest = ( · 5 unmodified lines
326373 const namespace = input.namespace;
327374 const defaultHeaders = input.defaultHeaders ?? {};
328375 const credentialHeaders = input.credentialHeaders ?? {};
376+ const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer;
329377
330378 const result: ToolMap = {};
331379
@@ -349,14 +397,39 @@ export const createOpenApiToolsFromManifest = ( · 17 unmodified lines
349397 credentialHeaders,
350398 });
351399
352− const response = await fetch(request.url, request.init);
353− const body = await decodeFetchResponseBody(response);
354−
355− return {
356− status: response.status,
357− headers: Object.fromEntries(response.headers.entries()),
358− body,
359− };
400+ return Effect.runPromise(
401+ Effect.gen(function* () {
402+ const client = yield* HttpClient.HttpClient;
403+ let clientRequest = HttpClientRequest.make(
404+ request.method as Parameters<typeof HttpClientRequest.make>[0],
405+ )(request.url, {
406+ headers: request.headers,
407+ });
408+
409+ if (request.body !== undefined) {
410+ clientRequest = HttpClientRequest.bodyText(
411+ clientRequest,
412+ request.body,
413+ request.headers["content-type"],
414+ );
415+ }
416+
417+ const response = yield* client.execute(clientRequest).pipe(
418+ Effect.mapError((cause) =>
419+ cause instanceof Error ? cause : new Error(String(cause)),
420+ ),
421+ );
422+ const body = yield* decodeHttpClientResponseBody(response);
423+
424+ return {
425+ status: response.status,
426+ headers: { ...response.headers },
427+ body,
428+ };
429+ }).pipe(
430+ Effect.provide(httpClientLayer),
431+ ),
432+ );
360433 },
361434 },
362435 metadata: {
@@ -379,6 +452,7 @@ export const createOpenApiToolsFromSpec = (input: { · 16 unmodified lines
379452 sourceKey?: string;
380453 defaultHeaders?: Readonly<Record<string, string>>;
381454 credentialHeaders?: Readonly<Record<string, string>>;
455+ httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
382456 }): Effect.Effect<
383457 { manifest: OpenApiToolManifest; tools: ToolMap },
384458 OpenApiExtractionError
@@ -394,5 +468,6 @@ export const createOpenApiToolsFromSpec = (input: { · 9 unmodified lines
394468 sourceKey: input.sourceKey,
395469 defaultHeaders: input.defaultHeaders,
396470 credentialHeaders: input.credentialHeaders,
471+ httpClientLayer: input.httpClientLayer,
397472 }),
398473 }));
packages/control-plane/src/runtime/executor-tools.ts+258−26
@@ -1,5 +1,8 @@
11 import {
22 type ElicitationResponse,
3+ type OnElicitation,
4+ type ToolInvocationContext,
5+ type ToolMetadata,
36 toTool,
47 type ToolMap,
58 type ToolPath,
@@ -7,23 +10,104 @@ import { · 1 unmodified line
710 import {
811 ExecutionIdSchema,
912 ExecutionInteractionIdSchema,
13+ SourceSchema,
1014 type Source,
1115 type WorkspaceId,
1216 } from "#schema";
1317 import * as Effect from "effect/Effect";
1418 import * as Schema from "effect/Schema";
1519
16−import { type RuntimeSourceAuthService } from "./source-auth-service";
20+import {
21+ type ExecutorAddSourceInput,
22+ type RuntimeSourceAuthService,
23+} from "./source-auth-service";
24+import {
25+ deriveSchemaJson,
26+ deriveSchemaTypeSignature,
27+} from "./schema-type-signature";
1728
18−const ExecutorSourcesAddInputSchema = Schema.standardSchemaV1(
29+const ExecutorOpenApiSourceAuthInputSchema = Schema.Union(
1930 Schema.Struct({
20− endpoint: Schema.String,
21− name: Schema.optional(Schema.NullOr(Schema.String)),
22− namespace: Schema.optional(Schema.NullOr(Schema.String)),
31+ kind: Schema.Literal("none"),
2332 }),
33+ Schema.Struct({
34+ kind: Schema.Literal("bearer"),
35+ headerName: Schema.optional(Schema.NullOr(Schema.String)),
36+ prefix: Schema.optional(Schema.NullOr(Schema.String)),
37+ token: Schema.optional(Schema.NullOr(Schema.String)),
38+ tokenEnvVar: Schema.optional(Schema.NullOr(Schema.String)),
39+ }),
40+);
41+
42+const ExecutorMcpSourceAddInputSchema = Schema.Struct({
43+ kind: Schema.optional(Schema.Literal("mcp")),
44+ endpoint: Schema.String,
45+ name: Schema.optional(Schema.NullOr(Schema.String)),
46+ namespace: Schema.optional(Schema.NullOr(Schema.String)),
47+});
48+
49+const ExecutorOpenApiSourceAddInputSchema = Schema.Struct({
50+ kind: Schema.Literal("openapi"),
51+ endpoint: Schema.String,
52+ specUrl: Schema.String,
53+ name: Schema.optional(Schema.NullOr(Schema.String)),
54+ namespace: Schema.optional(Schema.NullOr(Schema.String)),
55+ auth: Schema.optional(Schema.NullOr(ExecutorOpenApiSourceAuthInputSchema)),
56+});
57+
58+const ExecutorSourcesAddSchema = Schema.Union(
59+ ExecutorMcpSourceAddInputSchema,
60+ ExecutorOpenApiSourceAddInputSchema,
61+);
62+
63+const ExecutorSourcesAddInputSchema = Schema.standardSchemaV1(
64+ ExecutorSourcesAddSchema,
65+);
66+
67+const ExecutorSourcesAddOutputSchema = Schema.standardSchemaV1(SourceSchema);
68+
69+export const EXECUTOR_SOURCES_ADD_MCP_INPUT_SIGNATURE = deriveSchemaTypeSignature(
70+ ExecutorMcpSourceAddInputSchema,
71+ 240,
72+);
73+
74+export const EXECUTOR_SOURCES_ADD_OPENAPI_INPUT_SIGNATURE = deriveSchemaTypeSignature(
75+ ExecutorOpenApiSourceAddInputSchema,
76+ 420,
77+);
78+
79+export const EXECUTOR_SOURCES_ADD_INPUT_HINT = deriveSchemaTypeSignature(
80+ ExecutorSourcesAddInputSchema,
81+ 320,
82+);
83+
84+export const EXECUTOR_SOURCES_ADD_OUTPUT_SIGNATURE = deriveSchemaTypeSignature(
85+ SourceSchema,
86+ 260,
87+);
88+
89+export const EXECUTOR_SOURCES_ADD_INPUT_SCHEMA_JSON = JSON.stringify(
90+ deriveSchemaJson(ExecutorSourcesAddSchema) ?? {},
2491 );
2592
26−const ExecutorSourcesAddOutputSchema = Schema.standardSchemaV1(Schema.Unknown);
93+export const EXECUTOR_SOURCES_ADD_OUTPUT_SCHEMA_JSON = JSON.stringify(
94+ deriveSchemaJson(SourceSchema) ?? {},
95+);
96+
97+export const EXECUTOR_SOURCES_ADD_HELP_LINES = [
98+ "Source add input shapes:",
99+ `- MCP: ${EXECUTOR_SOURCES_ADD_MCP_INPUT_SIGNATURE}`,
100+ ' Omit kind or set kind: "mcp". endpoint is the MCP server URL.',
101+ `- OpenAPI: ${EXECUTOR_SOURCES_ADD_OPENAPI_INPUT_SIGNATURE}`,
102+ " endpoint is the base API URL. specUrl is the OpenAPI document URL.",
103+ " If credentials are needed, executor prompts with form or URL interaction.",
104+] as const;
105+
106+export const buildExecutorSourcesAddDescription = (): string =>
107+ [
108+ "Add an MCP or OpenAPI source to the current workspace.",
109+ ...EXECUTOR_SOURCES_ADD_HELP_LINES,
110+ ].join("\n");
27111
28112 const toExecutionId = (value: unknown) => {
29113 if (typeof value !== "string" || value.trim().length === 0) {
@@ -35,36 +119,180 @@ const toExecutionId = (value: unknown) => { · 5 unmodified lines
35119
36120 const asToolPath = (value: string): ToolPath => value as ToolPath;
37121
122+const trimOrNull = (value: string | null | undefined): string | null => {
123+ if (value === null || value === undefined) {
124+ return null;
125+ }
126+
127+ const trimmed = value.trim();
128+ return trimmed.length > 0 ? trimmed : null;
129+};
130+
131+const resolveOpenApiSourceLabel = (input: {
132+ name?: string | null;
133+ endpoint: string;
134+}): string => trimOrNull(input.name) ?? input.endpoint;
135+
136+const promptForBearerToken = (input: {
137+ args: Extract<ExecutorAddSourceInput, { kind: "openapi" }>;
138+ interactionId: string;
139+ path: ToolPath;
140+ sourceKey: string;
141+ metadata?: ToolMetadata;
142+ invocation?: ToolInvocationContext;
143+ onElicitation?: OnElicitation;
144+}) =>
145+ Effect.gen(function* () {
146+ if (!input.onElicitation) {
147+ return yield* Effect.fail(
148+ new Error("executor.sources.add requires an elicitation-capable host"),
149+ );
150+ }
151+
152+ const response: ElicitationResponse = yield* input.onElicitation({
153+ interactionId: input.interactionId,
154+ path: input.path,
155+ sourceKey: input.sourceKey,
156+ args: input.args,
157+ metadata: input.metadata,
158+ context: input.invocation,
159+ elicitation: {
160+ mode: "form",
161+ message: `Enter the API token to connect ${resolveOpenApiSourceLabel(input.args)}`,
162+ requestedSchema: {
163+ type: "object",
164+ properties: {
165+ token: {
166+ type: "string",
167+ title: "API token",
168+ },
169+ },
170+ required: ["token"],
171+ additionalProperties: false,
172+ },
173+ },
174+ }).pipe(Effect.mapError((cause) => cause instanceof Error ? cause : new Error(String(cause))));
175+
176+ if (response.action !== "accept") {
177+ return yield* Effect.fail(
178+ new Error(`Source add was not completed for ${resolveOpenApiSourceLabel(input.args)}`),
179+ );
180+ }
181+
182+ const token =
183+ response.content && typeof response.content.token === "string"
184+ ? response.content.token.trim()
185+ : "";
186+
187+ if (token.length === 0) {
188+ return yield* Effect.fail(
189+ new Error("API token was not provided for executor.sources.add"),
190+ );
191+ }
192+
193+ return token;
194+ });
195+
38196 export const createExecutorToolMap = (input: {
39197 workspaceId: WorkspaceId;
40198 sourceAuthService: RuntimeSourceAuthService;
41199 }): ToolMap => ({
42200 "executor.sources.add": toTool({
43201 tool: {
44− description: "Add an MCP source to the current workspace, starting OAuth if the source requires it",
202+ description: buildExecutorSourcesAddDescription(),
45203 inputSchema: ExecutorSourcesAddInputSchema,
46204 outputSchema: ExecutorSourcesAddOutputSchema,
47205 execute: async (
48− args: {
49− endpoint: string;
50− name?: string | null;
51− namespace?: string | null;
52− },
206+ args:
207+ | {
208+ kind?: "mcp";
209+ endpoint: string;
210+ name?: string | null;
211+ namespace?: string | null;
212+ }
213+ | {
214+ kind: "openapi";
215+ endpoint: string;
216+ specUrl: string;
217+ name?: string | null;
218+ namespace?: string | null;
219+ auth?:
220+ | {
221+ kind: "none";
222+ }
223+ | {
224+ kind: "bearer";
225+ headerName?: string | null;
226+ prefix?: string | null;
227+ token?: string | null;
228+ tokenEnvVar?: string | null;
229+ }
230+ | null;
231+ },
53232 context,
54233 ): Promise<Source> => {
55234 const executionId = toExecutionId(context?.invocation?.runId);
56235 const interactionId = ExecutionInteractionIdSchema.make(
57236 `executor.sources.add:${crypto.randomUUID()}`,
58237 );
238+ const preparedArgs: ExecutorAddSourceInput =
239+ args.kind === "openapi"
240+ ? {
241+ ...args,
242+ workspaceId: input.workspaceId,
243+ executionId,
244+ interactionId,
245+ auth:
246+ args.auth?.kind === "bearer"
247+ && !trimOrNull(args.auth.token)
248+ && !trimOrNull(args.auth.tokenEnvVar)
249+ ? {
250+ ...args.auth,
251+ token: await Effect.runPromise(
252+ promptForBearerToken({
253+ args: {
254+ ...args,
255+ auth: args.auth,
256+ workspaceId: input.workspaceId,
257+ executionId,
258+ interactionId,
259+ },
260+ interactionId,
261+ path: context.path ?? asToolPath("executor.sources.add"),
262+ sourceKey: context.sourceKey,
263+ metadata: context.metadata,
264+ invocation: context.invocation,
265+ onElicitation: context.onElicitation,
266+ }),
267+ ),
268+ }
269+ : args.auth ?? null,
270+ }
271+ : {
272+ kind: args.kind,
273+ endpoint: args.endpoint,
274+ name: args.name ?? null,
275+ namespace: args.namespace ?? null,
276+ workspaceId: input.workspaceId,
277+ executionId,
278+ interactionId,
279+ };
59280 const result = await Effect.runPromise(
60− input.sourceAuthService.addExecutorMcpSource({
61− workspaceId: input.workspaceId,
62− executionId,
63− interactionId,
64− endpoint: args.endpoint,
65− name: args.name ?? null,
66− namespace: args.namespace ?? null,
67− }),
281+ input.sourceAuthService.addExecutorSource(
282+ preparedArgs,
283+ context?.onElicitation
284+ ? {
285+ mcpDiscoveryElicitation: {
286+ onElicitation: context.onElicitation,
287+ path: context.path ?? asToolPath("executor.sources.add"),
288+ sourceKey: context.sourceKey,
289+ args,
290+ metadata: context.metadata,
291+ invocation: context.invocation,
292+ },
293+ }
294+ : undefined,
295+ ),
68296 );
69297
70298 if (result.kind === "connected") {
@@ -80,7 +308,7 @@ export const createExecutorToolMap = (input: { · 9 unmodified lines
80308 interactionId,
81309 path: context.path ?? asToolPath("executor.sources.add"),
82310 sourceKey: context.sourceKey,
83− args,
311+ args: preparedArgs,
84312 metadata: context.metadata,
85313 context: context.invocation,
86314 elicitation: {
@@ -104,9 +332,13 @@ export const createExecutorToolMap = (input: { · 17 unmodified lines
104332 );
105333 },
106334 },
107− metadata: {
108− sourceKey: "executor",
109− interaction: "auto",
110− },
111− }),
335+ metadata: {
336+ inputHint: EXECUTOR_SOURCES_ADD_INPUT_HINT,
337+ outputHint: EXECUTOR_SOURCES_ADD_OUTPUT_SIGNATURE,
338+ inputSchemaJson: EXECUTOR_SOURCES_ADD_INPUT_SCHEMA_JSON,
339+ outputSchemaJson: EXECUTOR_SOURCES_ADD_OUTPUT_SCHEMA_JSON,
340+ sourceKey: "executor",
341+ interaction: "auto",
342+ },
343+ }),
112344 });
packages/control-plane/src/runtime/index.ts+2−0
@@ -50,8 +50,10 @@ export { · 49 unmodified lines
5050 };
5151
5252 export * from "./execution-state";
53+export * from "./executor-tools";
5354 export * from "./live-execution";
5455 export * from "./local-installation";
56+export * from "./schema-type-signature";
5557 export * from "./source-auth-service";
5658 export * from "./store";
5759 export * from "./workspace-execution-environment";
packages/control-plane/src/runtime/schema-type-signature.ts+114−0
@@ -0,0 +1,114 @@
1+import * as JSONSchema from "effect/JSONSchema";
2+
3+type JsonSchemaRecord = Record<string, unknown>;
4+
5+const asRecord = (value: unknown): JsonSchemaRecord =>
6+ typeof value === "object" && value !== null && !Array.isArray(value)
7+ ? value as JsonSchemaRecord
8+ : {};
9+
10+const asStringArray = (value: unknown): Array<string> =>
11+ Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
12+
13+const truncate = (value: string, maxLength: number): string =>
14+ value.length <= maxLength ? value : `${value.slice(0, Math.max(0, maxLength - 4))} ...`;
15+
16+const propertyLabel = (name: string, schema: JsonSchemaRecord, optional: boolean): string =>
17+ `${name}${optional ? "?" : ""}: ${schemaToTypeSignature(schema)}`;
18+
19+const compositeLabel = (
20+ key: "oneOf" | "anyOf" | "allOf",
21+ schema: JsonSchemaRecord,
22+): string | null => {
23+ const items = Array.isArray(schema[key]) ? schema[key].map(asRecord) : [];
24+ if (items.length === 0) {
25+ return null;
26+ }
27+
28+ const labels = items
29+ .map((item) => schemaToTypeSignature(item))
30+ .filter((label) => label.length > 0);
31+
32+ if (labels.length === 0) {
33+ return null;
34+ }
35+
36+ return labels.join(key === "allOf" ? " & " : " | ");
37+};
38+
39+export const schemaToTypeSignature = (
40+ input: unknown,
41+ maxLength: number = 220,
42+): string => {
43+ const schema = asRecord(input);
44+
45+ if (typeof schema.$ref === "string") {
46+ const ref = schema.$ref.trim();
47+ return ref.length > 0 ? ref.split("/").at(-1) ?? ref : "unknown";
48+ }
49+
50+ if ("const" in schema) {
51+ return JSON.stringify(schema.const);
52+ }
53+
54+ const enumValues = Array.isArray(schema.enum) ? schema.enum : [];
55+ if (enumValues.length > 0) {
56+ return truncate(enumValues.map((value) => JSON.stringify(value)).join(" | "), maxLength);
57+ }
58+
59+ const composite =
60+ compositeLabel("oneOf", schema)
61+ ?? compositeLabel("anyOf", schema)
62+ ?? compositeLabel("allOf", schema);
63+ if (composite) {
64+ return truncate(composite, maxLength);
65+ }
66+
67+ if (schema.type === "array") {
68+ const itemLabel = schema.items ? schemaToTypeSignature(schema.items, maxLength) : "unknown";
69+ return truncate(`${itemLabel}[]`, maxLength);
70+ }
71+
72+ if (schema.type === "object" || schema.properties) {
73+ const properties = asRecord(schema.properties);
74+ const keys = Object.keys(properties);
75+ if (keys.length === 0) {
76+ return schema.additionalProperties ? "Record<string, unknown>" : "object";
77+ }
78+
79+ const required = new Set(asStringArray(schema.required));
80+ const parts = keys.map((key) =>
81+ propertyLabel(key, asRecord(properties[key]), !required.has(key))
82+ );
83+
84+ return truncate(`{ ${parts.join(", ")} }`, maxLength);
85+ }
86+
87+ if (Array.isArray(schema.type)) {
88+ return truncate(schema.type.join(" | "), maxLength);
89+ }
90+
91+ if (typeof schema.type === "string") {
92+ return schema.type;
93+ }
94+
95+ return "unknown";
96+};
97+
98+export const deriveSchemaTypeSignature = (
99+ schema: unknown,
100+ maxLength?: number,
101+): string => {
102+ const schemaJson = deriveSchemaJson(schema);
103+ return schemaJson ? schemaToTypeSignature(schemaJson, maxLength) : "unknown";
104+};
105+
106+export const deriveSchemaJson = (
107+ schema: unknown,
108+): JsonSchemaRecord | null => {
109+ try {
110+ return asRecord(JSONSchema.make(schema as any));
111+ } catch {
112+ return null;
113+ }
114+};
packages/control-plane/src/runtime/source-auth-service.ts+337−158
@@ -10,6 +10,7 @@ import type { · 9 unmodified lines
1010 import {
1111 createSdkMcpConnector,
1212 discoverMcpToolsFromConnector,
13+ type McpDiscoveryElicitationContext,
1314 } from "@executor-v3/codemode-mcp";
1415 import {
1516 SqlControlPlaneRowsService,
@@ -173,7 +174,10 @@ const persistSource = (rows: SqlControlPlaneRows, source: Source) => · 157 unmodified lines
173174 return source;
174175 });
175176
176−const probeMcpSourceWithoutAuth = (source: Source) =>
177+const probeMcpSourceWithoutAuth = (
178+ source: Source,
179+ mcpDiscoveryElicitation?: McpDiscoveryElicitationContext,
180+) =>
177181 Effect.gen(function* () {
178182 if (source.kind !== "mcp") {
179183 return yield* Effect.fail(new Error(`Expected MCP source, received ${source.kind}`));
@@ -195,6 +199,7 @@ const probeMcpSourceWithoutAuth = (source: Source) => · 15 unmodified lines
195199 connect: connector,
196200 namespace: source.namespace ?? defaultNamespaceFromName(source.name),
197201 sourceKey: source.id,
202+ mcpDiscoveryElicitation,
198203 });
199204 });
200205
@@ -428,7 +433,7 @@ const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { · 227 unmodified lines
428433 });
429434
430435 const upsertSecretMaterial = (rows: SqlControlPlaneRows, input: {
431− purpose: "oauth_access_token" | "oauth_refresh_token";
436+ purpose: "auth_material" | "oauth_access_token" | "oauth_refresh_token";
432437 value: string;
433438 }) =>
434439 Effect.gen(function* () {
@@ -457,19 +462,98 @@ export type ExecutorSourceAddResult = · 22 unmodified lines
457462 authorizationUrl: string;
458463 };
459464
465+export type ExecutorOpenApiSourceAuthInput =
466+ | {
467+ kind: "none";
468+ }
469+ | {
470+ kind: "bearer";
471+ headerName?: string | null;
472+ prefix?: string | null;
473+ token?: string | null;
474+ tokenEnvVar?: string | null;
475+ };
476+
477+export type ExecutorAddSourceInput =
478+ | {
479+ kind?: "mcp";
480+ workspaceId: WorkspaceId;
481+ executionId: SourceAuthSession["executionId"];
482+ interactionId: SourceAuthSession["interactionId"];
483+ endpoint: string;
484+ name?: string | null;
485+ namespace?: string | null;
486+ }
487+ | {
488+ kind: "openapi";
489+ workspaceId: WorkspaceId;
490+ executionId: SourceAuthSession["executionId"];
491+ interactionId: SourceAuthSession["interactionId"];
492+ endpoint: string;
493+ specUrl: string;
494+ name?: string | null;
495+ namespace?: string | null;
496+ auth?: ExecutorOpenApiSourceAuthInput | null;
497+ };
498+
499+const materializeExecutorOpenApiAuth = (input: {
500+ rows: SqlControlPlaneRows;
501+ existing?: Source;
502+ auth?: ExecutorOpenApiSourceAuthInput | null;
503+}): Effect.Effect<Source["auth"], Error, never> =>
504+ Effect.gen(function* () {
505+ if (input.auth === undefined && input.existing?.kind === "openapi") {
506+ return input.existing.auth;
507+ }
508+
509+ const auth = input.auth ?? { kind: "none" } satisfies ExecutorOpenApiSourceAuthInput;
510+ if (auth.kind === "none") {
511+ return { kind: "none" } satisfies Source["auth"];
512+ }
513+
514+ const headerName = trimOrNull(auth.headerName) ?? "Authorization";
515+ const prefix = auth.prefix ?? "Bearer ";
516+ const token = trimOrNull(auth.token);
517+ const tokenEnvVar = trimOrNull(auth.tokenEnvVar);
518+
519+ if (token === null && tokenEnvVar === null) {
520+ return yield* Effect.fail(
521+ new Error("Bearer auth requires either token or tokenEnvVar"),
522+ );
523+ }
524+
525+ const tokenRef: SecretRef = token !== null
526+ ? {
527+ providerId: CONTROL_PLANE_SECRET_PROVIDER_ID,
528+ handle: yield* upsertSecretMaterial(input.rows, {
529+ purpose: "auth_material",
530+ value: token,
531+ }),
532+ }
533+ : {
534+ providerId: "env",
535+ handle: tokenEnvVar!,
536+ };
537+
538+ return {
539+ kind: "bearer",
540+ headerName,
541+ prefix,
542+ token: tokenRef,
543+ } satisfies Source["auth"];
544+ });
545+
460546 type RuntimeSourceAuthServiceShape = {
461547 getSourceById: (input: {
462548 workspaceId: WorkspaceId;
463549 sourceId: Source["id"];
464550 }) => Effect.Effect<Source, Error, never>;
465− addExecutorMcpSource: (input: {
466− workspaceId: WorkspaceId;
467− executionId: SourceAuthSession["executionId"];
468− interactionId: SourceAuthSession["interactionId"];
469− endpoint: string;
470− name?: string | null;
471− namespace?: string | null;
472− }) => Effect.Effect<ExecutorSourceAddResult, Error, never>;
551+ addExecutorSource: (
552+ input: ExecutorAddSourceInput,
553+ options?: {
554+ mcpDiscoveryElicitation?: McpDiscoveryElicitationContext;
555+ },
556+ ) => Effect.Effect<ExecutorSourceAddResult, Error, never>;
473557 completeSourceAuthCallback: (input: {
474558 state: string;
475559 code?: string | null;
@@ -524,166 +608,261 @@ export const createRuntimeSourceAuthService = (input: { · 48 unmodified lines
524608 sourceId,
525609 }),
526610
527− addExecutorMcpSource: ({
528− workspaceId,
529− executionId,
530− interactionId,
531− endpoint,
532− name,
533− namespace,
534− }) =>
535− Effect.gen(function* () {
536− const normalizedEndpoint = normalizeEndpoint(endpoint);
537− const existingSources = yield* loadSourcesInWorkspace(input.rows, workspaceId);
538− const existing = existingSources.find(
539− (source) => source.kind === "mcp" && normalizeEndpoint(source.endpoint) === normalizedEndpoint,
540− );
611+ addExecutorSource: (sourceInput, options) =>
612+ sourceInput.kind === "openapi"
613+ ? Effect.gen(function* () {
614+ const normalizedEndpoint = normalizeEndpoint(sourceInput.endpoint);
615+ const normalizedSpecUrl = normalizeEndpoint(sourceInput.specUrl);
616+ const existingSources = yield* loadSourcesInWorkspace(
617+ input.rows,
618+ sourceInput.workspaceId,
619+ );
620+ const existing = existingSources.find(
621+ (source) =>
622+ source.kind === "openapi"
623+ && normalizeEndpoint(source.endpoint) === normalizedEndpoint
624+ && trimOrNull(source.specUrl) === normalizedSpecUrl,
625+ );
541626
542− const chosenName =
543− trimOrNull(name)
544− ?? existing?.name
545− ?? defaultSourceNameFromEndpoint(normalizedEndpoint);
546− const chosenNamespace =
547− trimOrNull(namespace)
548− ?? existing?.namespace
549− ?? defaultNamespaceFromName(chosenName);
550− const now = Date.now();
627+ const chosenName =
628+ trimOrNull(sourceInput.name)
629+ ?? existing?.name
630+ ?? defaultSourceNameFromEndpoint(normalizedEndpoint);
631+ const chosenNamespace =
632+ trimOrNull(sourceInput.namespace)
633+ ?? existing?.namespace
634+ ?? defaultNamespaceFromName(chosenName);
635+ const auth = yield* materializeExecutorOpenApiAuth({
636+ rows: input.rows,
637+ existing,
638+ auth: sourceInput.auth,
639+ });
640+ const now = Date.now();
641+
642+ const draftSource = existing
643+ ? yield* updateSourceFromPayload({
644+ source: existing,
645+ payload: {
646+ name: chosenName,
647+ endpoint: normalizedEndpoint,
648+ namespace: chosenNamespace,
649+ kind: "openapi",
650+ status: "probing",
651+ enabled: true,
652+ specUrl: normalizedSpecUrl,
653+ auth,
654+ lastError: null,
655+ },
656+ now,
657+ })
658+ : yield* createSourceFromPayload({
659+ workspaceId: sourceInput.workspaceId,
660+ sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`),
661+ payload: {
662+ name: chosenName,
663+ kind: "openapi",
664+ endpoint: normalizedEndpoint,
665+ namespace: chosenNamespace,
666+ status: "probing",
667+ enabled: true,
668+ specUrl: normalizedSpecUrl,
669+ auth,
670+ },
671+ now,
672+ });
673+
674+ const persistedDraft = yield* persistSource(input.rows, draftSource);
675+ const synced = yield* Effect.either(
676+ syncSourceToolArtifacts({
677+ rows: input.rows,
678+ source: {
679+ ...persistedDraft,
680+ status: "connected",
681+ },
682+ resolveSecretMaterial,
683+ }),
684+ );
551685
552− const draftSource = existing
553− ? yield* updateSourceFromPayload({
554− source: existing,
555− payload: {
556− name: chosenName,
557− endpoint: normalizedEndpoint,
558− namespace: chosenNamespace,
559− kind: "mcp",
560− status: "probing",
561− enabled: true,
562− transport: existing.transport ?? "auto",
563− auth: { kind: "none" },
564− lastError: null,
565− },
566− now,
567− })
568− : yield* createSourceFromPayload({
569− workspaceId,
570− sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`),
571− payload: {
572− name: chosenName,
573− kind: "mcp",
574− endpoint: normalizedEndpoint,
575− namespace: chosenNamespace,
576− status: "probing",
577− enabled: true,
578− transport: "auto",
579− auth: { kind: "none" },
580− },
581− now,
686+ return yield* Either.match(synced, {
687+ onLeft: (error) =>
688+ updateSourceStatus(input.rows, persistedDraft, {
689+ status: "error",
690+ lastError: error.message,
691+ }).pipe(
692+ Effect.zipRight(Effect.fail(error)),
693+ ),
694+ onRight: () =>
695+ updateSourceStatus(input.rows, persistedDraft, {
696+ status: "connected",
697+ lastError: null,
698+ }).pipe(
699+ Effect.map((source) =>
700+ ({
701+ kind: "connected",
702+ source,
703+ } satisfies ExecutorSourceAddResult)
704+ ),
705+ ),
582706 });
707+ })
708+ : Effect.gen(function* () {
709+ const normalizedEndpoint = normalizeEndpoint(sourceInput.endpoint);
710+ const existingSources = yield* loadSourcesInWorkspace(
711+ input.rows,
712+ sourceInput.workspaceId,
713+ );
714+ const existing = existingSources.find(
715+ (source) => source.kind === "mcp" && normalizeEndpoint(source.endpoint) === normalizedEndpoint,
716+ );
583717
584− const persistedDraft = yield* persistSource(input.rows, draftSource);
585− yield* syncSourceToolArtifacts({
586− rows: input.rows,
587− source: persistedDraft,
588− resolveSecretMaterial,
589− });
718+ const chosenName =
719+ trimOrNull(sourceInput.name)
720+ ?? existing?.name
721+ ?? defaultSourceNameFromEndpoint(normalizedEndpoint);
722+ const chosenNamespace =
723+ trimOrNull(sourceInput.namespace)
724+ ?? existing?.namespace
725+ ?? defaultNamespaceFromName(chosenName);
726+ const now = Date.now();
727+
728+ const draftSource = existing
729+ ? yield* updateSourceFromPayload({
730+ source: existing,
731+ payload: {
732+ name: chosenName,
733+ endpoint: normalizedEndpoint,
734+ namespace: chosenNamespace,
735+ kind: "mcp",
736+ status: "probing",
737+ enabled: true,
738+ transport: existing.transport ?? "auto",
739+ auth: { kind: "none" },
740+ lastError: null,
741+ },
742+ now,
743+ })
744+ : yield* createSourceFromPayload({
745+ workspaceId: sourceInput.workspaceId,
746+ sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`),
747+ payload: {
748+ name: chosenName,
749+ kind: "mcp",
750+ endpoint: normalizedEndpoint,
751+ namespace: chosenNamespace,
752+ status: "probing",
753+ enabled: true,
754+ transport: "auto",
755+ auth: { kind: "none" },
756+ },
757+ now,
758+ });
759+
760+ const persistedDraft = yield* persistSource(input.rows, draftSource);
761+ yield* syncSourceToolArtifacts({
762+ rows: input.rows,
763+ source: persistedDraft,
764+ resolveSecretMaterial,
765+ });
590766
591− const discovered = yield* Effect.either(
592− probeMcpSourceWithoutAuth(persistedDraft),
593− );
767+ const discovered = yield* Effect.either(
768+ probeMcpSourceWithoutAuth(
769+ persistedDraft,
770+ options?.mcpDiscoveryElicitation,
771+ ),
772+ );
594773
595− const connectedResult = yield* Either.match(discovered, {
596− onLeft: () => Effect.succeed(null),
597− onRight: (result) =>
598− Effect.gen(function* () {
599− const connected = yield* updateSourceStatus(input.rows, persistedDraft, {
600− status: "connected",
601− lastError: null,
602− auth: { kind: "none" },
603− });
604− const indexed = yield* Effect.either(
605− persistMcpToolArtifactsFromManifest({
606− rows: input.rows,
607− source: connected,
608− manifestEntries: result.manifest.tools,
774+ const connectedResult = yield* Either.match(discovered, {
775+ onLeft: () => Effect.succeed(null),
776+ onRight: (result) =>
777+ Effect.gen(function* () {
778+ const connected = yield* updateSourceStatus(input.rows, persistedDraft, {
779+ status: "connected",
780+ lastError: null,
781+ auth: { kind: "none" },
782+ });
783+ const indexed = yield* Effect.either(
784+ persistMcpToolArtifactsFromManifest({
785+ rows: input.rows,
786+ source: connected,
787+ manifestEntries: result.manifest.tools,
788+ }),
789+ );
790+
791+ return yield* Either.match(indexed, {
792+ onLeft: (error) =>
793+ updateSourceStatus(input.rows, connected, {
794+ status: "error",
795+ lastError: error.message,
796+ }).pipe(
797+ Effect.zipRight(Effect.fail(error)),
798+ ),
799+ onRight: () =>
800+ Effect.succeed({
801+ kind: "connected",
802+ source: connected,
803+ } satisfies ExecutorSourceAddResult),
804+ });
609805 }),
610− );
611−
612− return yield* Either.match(indexed, {
613− onLeft: (error) =>
614− updateSourceStatus(input.rows, connected, {
615− status: "error",
616− lastError: error.message,
617− }).pipe(
618− Effect.zipRight(Effect.fail(error)),
619− ),
620− onRight: () =>
621− Effect.succeed({
622− kind: "connected",
623− source: connected,
624− } satisfies ExecutorSourceAddResult),
625− });
626− }),
627− });
628−
629− if (connectedResult) {
630− return connectedResult;
631− }
806+ });
632807
633− const localServerBaseUrl = input.getLocalServerBaseUrl?.();
634− if (!localServerBaseUrl) {
635− return yield* Effect.fail(
636− new Error("Local executor server base URL is unavailable for OAuth callback handling"),
637− );
638− }
808+ if (connectedResult) {
809+ return connectedResult;
810+ }
639811
640− const sessionId = SourceAuthSessionIdSchema.make(`src_auth_${crypto.randomUUID()}`);
641− const state = crypto.randomUUID();
642− const redirectUrl = resolveRedirectUrl(localServerBaseUrl);
643− const oauthStart = yield* startOAuthAuthorization({
644− endpoint: normalizedEndpoint,
645− redirectUrl,
646− state,
647− });
812+ const localServerBaseUrl = input.getLocalServerBaseUrl?.();
813+ if (!localServerBaseUrl) {
814+ return yield* Effect.fail(
815+ new Error("Local executor server base URL is unavailable for OAuth callback handling"),
816+ );
817+ }
818+
819+ const sessionId = SourceAuthSessionIdSchema.make(`src_auth_${crypto.randomUUID()}`);
820+ const state = crypto.randomUUID();
821+ const redirectUrl = resolveRedirectUrl(localServerBaseUrl);
822+ const oauthStart = yield* startOAuthAuthorization({
823+ endpoint: normalizedEndpoint,
824+ redirectUrl,
825+ state,
826+ });
648827
649− const authRequiredSource = yield* updateSourceStatus(input.rows, persistedDraft, {
650− status: "auth_required",
651− lastError: null,
652− });
828+ const authRequiredSource = yield* updateSourceStatus(input.rows, persistedDraft, {
829+ status: "auth_required",
830+ lastError: null,
831+ });
653832
654− const sessionNow = Date.now();
655− yield* input.rows.sourceAuthSessions.upsert({
656− id: sessionId,
657− workspaceId,
658− sourceId: authRequiredSource.id,
659− executionId,
660− interactionId,
661− strategy: "oauth2_authorization_code",
662− status: "pending",
663− endpoint: normalizedEndpoint,
664− state,
665− redirectUri: redirectUrl,
666− scope: null,
667− resourceMetadataUrl: oauthStart.resourceMetadataUrl,
668− authorizationServerUrl: oauthStart.authorizationServerUrl,
669− resourceMetadataJson: oauthStart.resourceMetadataJson,
670− authorizationServerMetadataJson: oauthStart.authorizationServerMetadataJson,
671− clientInformationJson: oauthStart.clientInformationJson,
672− codeVerifier: oauthStart.codeVerifier,
673− authorizationUrl: oauthStart.authorizationUrl,
674− errorText: null,
675− completedAt: null,
676− createdAt: sessionNow,
677− updatedAt: sessionNow,
678− });
833+ const sessionNow = Date.now();
834+ yield* input.rows.sourceAuthSessions.upsert({
835+ id: sessionId,
836+ workspaceId: sourceInput.workspaceId,
837+ sourceId: authRequiredSource.id,
838+ executionId: sourceInput.executionId,
839+ interactionId: sourceInput.interactionId,
840+ strategy: "oauth2_authorization_code",
841+ status: "pending",
842+ endpoint: normalizedEndpoint,
843+ state,
844+ redirectUri: redirectUrl,
845+ scope: null,
846+ resourceMetadataUrl: oauthStart.resourceMetadataUrl,
847+ authorizationServerUrl: oauthStart.authorizationServerUrl,
848+ resourceMetadataJson: oauthStart.resourceMetadataJson,
849+ authorizationServerMetadataJson: oauthStart.authorizationServerMetadataJson,
850+ clientInformationJson: oauthStart.clientInformationJson,
851+ codeVerifier: oauthStart.codeVerifier,
852+ authorizationUrl: oauthStart.authorizationUrl,
853+ errorText: null,
854+ completedAt: null,
855+ createdAt: sessionNow,
856+ updatedAt: sessionNow,
857+ });
679858
680− return {
681− kind: "oauth_required",
682− source: authRequiredSource,
683− sessionId,
684− authorizationUrl: oauthStart.authorizationUrl,
685− } satisfies ExecutorSourceAddResult;
686− }),
859+ return {
860+ kind: "oauth_required",
861+ source: authRequiredSource,
862+ sessionId,
863+ authorizationUrl: oauthStart.authorizationUrl,
864+ } satisfies ExecutorSourceAddResult;
865+ }),
687866
688867 completeSourceAuthCallback: ({ state, code, error, errorDescription }) =>
689868 Effect.gen(function* () {
packages/control-plane/src/runtime/tool-artifacts.ts+46−10
@@ -1,3 +1,8 @@
1+import {
2+ FetchHttpClient,
3+ HttpClient,
4+ HttpClientRequest,
5+} from "@effect/platform";
16 import {
27 createSdkMcpConnector,
38 discoverMcpToolsFromConnector,
@@ -5,7 +10,6 @@ import { · 1 unmodified line
510 } from "@executor-v3/codemode-mcp";
611 import {
712 extractOpenApiManifest,
8− fetchOpenApiDocument,
913 type OpenApiExtractedTool,
1014 } from "@executor-v3/codemode-openapi";
1115 import type { SqlControlPlaneRows } from "#persistence";
@@ -224,6 +228,32 @@ export const resolveSourceAuthMaterial = (input: { · 212 unmodified lines
224228 } satisfies ResolvedSourceAuthMaterial;
225229 });
226230
231+const fetchOpenApiDocumentWithHeaders = (input: {
232+ url: string;
233+ headers?: Readonly<Record<string, string>>;
234+}): Effect.Effect<string, Error, never> =>
235+ Effect.gen(function* () {
236+ const client = (yield* HttpClient.HttpClient).pipe(
237+ HttpClient.filterStatusOk,
238+ );
239+ const request = HttpClientRequest.get(input.url).pipe(
240+ HttpClientRequest.setHeaders(input.headers ?? {}),
241+ );
242+ const response = yield* client.execute(request).pipe(
243+ Effect.mapError((cause) =>
244+ cause instanceof Error ? cause : new Error(String(cause)),
245+ ),
246+ );
247+
248+ return yield* response.text.pipe(
249+ Effect.mapError((cause) =>
250+ cause instanceof Error ? cause : new Error(String(cause)),
251+ ),
252+ );
253+ }).pipe(
254+ Effect.provide(FetchHttpClient.layer),
255+ );
256+
227257 const indexMcpSourceToolArtifacts = (input: {
228258 rows: SqlControlPlaneRows;
229259 source: Source;
@@ -293,6 +323,7 @@ const discoverAndIndexMcpSourceToolArtifacts = (input: { · 63 unmodified lines
293323 const indexOpenApiSourceToolArtifacts = (input: {
294324 rows: SqlControlPlaneRows;
295325 source: Source;
326+ auth: ResolvedSourceAuthMaterial;
296327 }): Effect.Effect<void, Error, never> =>
297328 Effect.gen(function* () {
298329 if (!input.source.specUrl) {
@@ -301,15 +332,19 @@ const indexOpenApiSourceToolArtifacts = (input: { · 2 unmodified lines
301332 );
302333 }
303334
304− const openApiDocument = yield* Effect.tryPromise({
305− try: () => fetchOpenApiDocument(input.source.specUrl!),
306− catch: (cause) =>
307− cause instanceof Error
308− ? new Error(
309− `Failed fetching OpenAPI spec for ${input.source.id}: ${cause.message}`,
310− )
311− : new Error(`Failed fetching OpenAPI spec for ${input.source.id}: ${String(cause)}`),
312− });
335+ const openApiDocument = yield* fetchOpenApiDocumentWithHeaders({
336+ url: input.source.specUrl,
337+ headers: {
338+ ...(input.source.defaultHeaders ?? {}),
339+ ...input.auth.headers,
340+ },
341+ }).pipe(
342+ Effect.mapError((cause) =>
343+ new Error(
344+ `Failed fetching OpenAPI spec for ${input.source.id}: ${cause.message}`,
345+ ),
346+ ),
347+ );
313348
314349 const manifest = yield* extractOpenApiManifest(
315350 input.source.name,
@@ -376,6 +411,7 @@ export const syncSourceToolArtifacts = (input: { · 60 unmodified lines
376411 return yield* indexOpenApiSourceToolArtifacts({
377412 rows: input.rows,
378413 source: input.source,
414+ auth,
379415 });
380416 }
381417
packages/control-plane/src/runtime/workspace-execution-environment.test.ts+261−2
@@ -1,5 +1,14 @@
11 import { randomUUID } from "node:crypto";
22
3+import {
4+ HttpApi,
5+ HttpApiBuilder,
6+ HttpApiEndpoint,
7+ HttpApiGroup,
8+ HttpApiSchema,
9+ HttpServer,
10+} from "@effect/platform";
11+import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
312 import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
413 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
514 import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
@@ -10,7 +19,12 @@ import { · 4 unmodified lines
1019 SourceIdSchema,
1120 WorkspaceIdSchema,
1221 } from "#schema";
22+import * as Context from "effect/Context";
1323 import * as Effect from "effect/Effect";
24+import * as Exit from "effect/Exit";
25+import * as Layer from "effect/Layer";
26+import * as Scope from "effect/Scope";
27+import { Schema } from "effect";
1428 import { z } from "zod/v4";
1529
1630 import {
@@ -19,7 +33,11 @@ import { · 2 unmodified lines
1933 } from "#persistence";
2034 import type { SourceId, WorkspaceId } from "#schema";
2135
22−import type { RuntimeSourceAuthService } from "./source-auth-service";
36+import {
37+ createRuntimeSourceAuthService,
38+ type RuntimeSourceAuthService,
39+} from "./source-auth-service";
40+import { createLiveExecutionManager } from "./live-execution";
2341 import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment";
2442
2543 type CountedMcpServer = {
@@ -31,6 +49,16 @@ type CountedMcpServer = { · 5 unmodified lines
3149 close: () => Promise<void>;
3250 };
3351
52+type OpenApiSpecServer = {
53+ baseUrl: string;
54+ specUrl: string;
55+ seenAuthHeaders: Array<string | null>;
56+ close: () => Promise<void>;
57+};
58+
59+const closeScope = (scope: Scope.CloseableScope) =>
60+ Scope.close(scope, Exit.void).pipe(Effect.orDie);
61+
3462 const makePersistence = Effect.acquireRelease(
3563 createSqlControlPlanePersistence({
3664 localDataDir: ":memory:",
@@ -249,6 +277,73 @@ const makeCountedMcpServer = Effect.acquireRelease( · 212 unmodified lines
249277 }).pipe(Effect.orDie),
250278 );
251279
280+const ownerParam = HttpApiSchema.param("owner", Schema.String);
281+const repoParam = HttpApiSchema.param("repo", Schema.String);
282+
283+class GeneratedReposApi extends HttpApiGroup.make("repos")
284+ .add(
285+ HttpApiEndpoint.get("getRepo")`/repos/${ownerParam}/${repoParam}`
286+ .addSuccess(
287+ Schema.Struct({
288+ ok: Schema.Boolean,
289+ full_name: Schema.String,
290+ }),
291+ ),
292+ ) {}
293+
294+class GeneratedApi extends HttpApi.make("generated").add(GeneratedReposApi) {}
295+
296+const makeOpenApiSpecServer = Effect.acquireRelease(
297+ Effect.gen(function* () {
298+ const seenAuthHeaders: OpenApiSpecServer["seenAuthHeaders"] = [];
299+ const handlersLayer = HttpApiBuilder.group(GeneratedApi, "repos", (handlers) =>
300+ handlers.handle("getRepo", ({ path, request }) =>
301+ Effect.sync(() => {
302+ seenAuthHeaders.push(
303+ typeof request.headers.authorization === "string"
304+ ? request.headers.authorization
305+ : null,
306+ );
307+
308+ return {
309+ ok: true,
310+ full_name: `${path.owner}/${path.repo}`,
311+ };
312+ })
313+ )
314+ );
315+ const apiLayer = HttpApiBuilder.api(GeneratedApi).pipe(
316+ Layer.provide(handlersLayer),
317+ );
318+ const serverLayer = HttpApiBuilder.serve().pipe(
319+ Layer.provide(HttpApiBuilder.middlewareOpenApi()),
320+ Layer.provide(apiLayer),
321+ Layer.provideMerge(NodeHttpServer.layerTest),
322+ );
323+ const scope = yield* Scope.make();
324+ const context = yield* Layer.buildWithScope(serverLayer, scope).pipe(
325+ Effect.catchAll((error) =>
326+ closeScope(scope).pipe(
327+ Effect.zipRight(Effect.fail(error)),
328+ )),
329+ );
330+ const server = Context.get(context, HttpServer.HttpServer);
331+ const baseUrl = HttpServer.formatAddress(server.address);
332+
333+ return {
334+ baseUrl,
335+ specUrl: new URL("/openapi.json", baseUrl).toString(),
336+ seenAuthHeaders,
337+ close: () => Effect.runPromise(closeScope(scope)),
338+ } satisfies OpenApiSpecServer;
339+ }),
340+ (server) =>
341+ Effect.tryPromise({
342+ try: () => server.close(),
343+ catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
344+ }).pipe(Effect.orDie),
345+);
346+
252347 const persistConnectedEchoTool = (input: {
253348 persistence: SqlControlPlanePersistence;
254349 endpoint: string;
@@ -323,11 +418,20 @@ const makeResolver = (persistence: SqlControlPlanePersistence) => · 68 unmodified lines
323418 rows: persistence.rows,
324419 sourceAuthService: {
325420 getSourceById: () => Effect.fail(new Error("not implemented in test")),
326− addExecutorMcpSource: () => Effect.fail(new Error("not implemented in test")),
421+ addExecutorSource: () => Effect.fail(new Error("not implemented in test")),
327422 completeSourceAuthCallback: () => Effect.fail(new Error("not implemented in test")),
328423 } as RuntimeSourceAuthService,
329424 });
330425
426+const makeResolverWithLiveSourceAuth = (persistence: SqlControlPlanePersistence) =>
427+ createWorkspaceExecutionEnvironmentResolver({
428+ rows: persistence.rows,
429+ sourceAuthService: createRuntimeSourceAuthService({
430+ rows: persistence.rows,
431+ liveExecutionManager: createLiveExecutionManager(),
432+ }),
433+ });
434+
331435 describe("workspace-execution-environment", () => {
332436 it.scoped("discovers persisted tools without live MCP listing", () =>
333437 Effect.gen(function* () {
@@ -407,4 +511,159 @@ describe("workspace-execution-environment", () => { · 73 unmodified lines
407511 expect(result.content?.[0]?.text).toBe("echo:hello");
408512 }),
409513 );
514+
515+ it.scoped("adds an OpenAPI source through executor.sources.add with elicited credentials", () =>
516+ Effect.gen(function* () {
517+ const specServer = yield* makeOpenApiSpecServer;
518+ const persistence = yield* makePersistence;
519+ const workspaceId = WorkspaceIdSchema.make("ws_add_openapi");
520+ const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence);
521+ let capturedElicitation:
522+ | {
523+ mode?: string;
524+ requestedSchema?: Record<string, unknown>;
525+ }
526+ | null = null;
527+
528+ const environment = yield* resolveEnvironment({
529+ workspaceId,
530+ accountId: AccountIdSchema.make("acc_add_openapi"),
531+ executionId: ExecutionIdSchema.make("exec_add_openapi"),
532+ onElicitation: (request) =>
533+ Effect.sync(() => {
534+ capturedElicitation = request.elicitation as {
535+ mode?: string;
536+ requestedSchema?: Record<string, unknown>;
537+ };
538+
539+ return {
540+ action: "accept" as const,
541+ content: {
542+ token: "ghp_test_token",
543+ },
544+ };
545+ }),
546+ });
547+
548+ const added = (yield* environment.toolInvoker.invoke({
549+ path: "executor.sources.add",
550+ args: {
551+ kind: "openapi",
552+ endpoint: specServer.baseUrl,
553+ specUrl: specServer.specUrl,
554+ name: "GitHub",
555+ namespace: "github",
556+ auth: {
557+ kind: "bearer",
558+ },
559+ },
560+ context: {
561+ runId: "exec_add_openapi",
562+ },
563+ })) as {
564+ kind: string;
565+ status: string;
566+ auth: {
567+ kind: string;
568+ token?: {
569+ providerId: string;
570+ };
571+ };
572+ };
573+
574+ expect(added.kind).toBe("openapi");
575+ expect(added.status).toBe("connected");
576+ expect(added.auth.kind).toBe("bearer");
577+ expect(added.auth.token?.providerId).toBe("control-plane");
578+ expect(capturedElicitation).toMatchObject({
579+ mode: "form",
580+ requestedSchema: {
581+ type: "object",
582+ },
583+ });
584+
585+ const storedArtifacts = yield* persistence.rows.toolArtifacts.listByWorkspaceId(
586+ workspaceId,
587+ );
588+ expect(storedArtifacts.length).toBeGreaterThan(0);
589+ const repoToolPath = storedArtifacts[0]!.path;
590+
591+ const freshEnvironment = yield* resolveEnvironment({
592+ workspaceId,
593+ accountId: AccountIdSchema.make("acc_add_openapi_fresh"),
594+ executionId: ExecutionIdSchema.make("exec_add_openapi_fresh"),
595+ });
596+
597+ const discovered = (yield* freshEnvironment.toolInvoker.invoke({
598+ path: "discover",
599+ args: {
600+ query: "github repo",
601+ limit: 5,
602+ },
603+ })) as {
604+ bestPath: string | null;
605+ };
606+
607+ expect(discovered.bestPath).toBe(repoToolPath);
608+
609+ const invoked = (yield* freshEnvironment.toolInvoker.invoke({
610+ path: repoToolPath,
611+ args: {
612+ owner: "vercel",
613+ repo: "ai",
614+ },
615+ })) as {
616+ status: number;
617+ body: {
618+ ok: boolean;
619+ full_name: string;
620+ };
621+ };
622+
623+ expect(invoked.status).toBe(200);
624+ expect(invoked.body).toEqual({
625+ ok: true,
626+ full_name: "vercel/ai",
627+ });
628+ expect(specServer.seenAuthHeaders).toEqual(["Bearer ghp_test_token"]);
629+ }),
630+ );
631+
632+ it.scoped("describes executor.sources.add with derived type info and schemas", () =>
633+ Effect.gen(function* () {
634+ const persistence = yield* makePersistence;
635+ const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence);
636+
637+ const environment = yield* resolveEnvironment({
638+ workspaceId: WorkspaceIdSchema.make("ws_describe_add_source"),
639+ accountId: AccountIdSchema.make("acc_describe_add_source"),
640+ executionId: ExecutionIdSchema.make("exec_describe_add_source"),
641+ });
642+
643+ const described = (yield* environment.toolInvoker.invoke({
644+ path: "describe.tool",
645+ args: {
646+ path: "executor.sources.add",
647+ includeSchemas: true,
648+ },
649+ })) as {
650+ path: string;
651+ description?: string;
652+ inputHint?: string;
653+ inputSchemaJson?: string;
654+ } | null;
655+
656+ expect(described?.path).toBe("executor.sources.add");
657+ expect(described?.description).toContain("Source add input shapes:");
658+ expect(described?.description).toContain("specUrl");
659+ expect(described?.inputHint).toContain("endpoint");
660+
661+ const inputSchema = described?.inputSchemaJson
662+ ? JSON.parse(described.inputSchemaJson) as Record<string, unknown>
663+ : null;
664+ expect(inputSchema).not.toBeNull();
665+ expect(JSON.stringify(inputSchema)).toContain("specUrl");
666+ expect(JSON.stringify(inputSchema)).toContain("endpoint");
667+ }),
668+ );
410669 });