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
9 NodeRuntime,9 NodeRuntime,
10} from "@effect/platform-node";10} from "@effect/platform-node";
11import {11import {
12 EXECUTOR_SOURCES_ADD_HELP_LINES,
12 ExecutionIdSchema,13 ExecutionIdSchema,
13 RuntimeExecutionResolverService,14 RuntimeExecutionResolverService,
14 createControlPlaneClient,15 createControlPlaneClient,
@@ -146,7 +147,8 @@ const buildWorkflowText = (namespaces: readonly string[] = []): string => · 131 unmodified lines
146 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',147 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',
147 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",148 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",
148 "3) Call selected tools.<path>(input).",149 "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,
150 "5) If execution pauses for interaction, resume it with `executor resume --execution-id ...`.",152 "5) If execution pauses for interaction, resume it with `executor resume --execution-id ...`.",
151 "Do not use fetch; use tools.* only.",153 "Do not use fetch; use tools.* only.",
152 ].join("\n");154 ].join("\n");
@@ -305,6 +307,7 @@ const printCallHelp = (workflow: string) => · 152 unmodified lines
305 ' executor call \'const matches = await tools.discover({ query: "github issues", limit: 5 }); return matches;\'',307 ' executor call \'const matches = await tools.discover({ query: "github issues", limit: 5 }); return matches;\'',
306 ' executor call \'const matches = await tools.discover({ query: "repo details", limit: 1 }); const path = matches.bestPath; return await tools.describe.tool({ path, includeSchemas: true });\'',308 ' executor call \'const matches = await tools.discover({ query: "repo details", limit: 1 }); const path = matches.bestPath; return await tools.describe.tool({ path, includeSchemas: true });\'',
307 ' executor call \'return await tools.executor.sources.add({ endpoint: "https://example.com/mcp", name: "Example", namespace: "example" });\'',309 ' 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" } });\'',
308 " cat script.ts | executor call --stdin",311 " cat script.ts | executor call --stdin",
309 " executor call --file script.ts",312 " executor call --file script.ts",
310 " executor resume --execution-id exec_123",313 " executor resume --execution-id exec_123",
@@ -389,6 +392,7 @@ const parseInteractionPayload = (interaction: ExecutionInteraction): { · 78 unmodified lines
389 message: string;392 message: string;
390 mode: "form" | "url";393 mode: "form" | "url";
391 url?: string;394 url?: string;
395 requestedSchema?: Record<string, unknown>;
392} | null => {396} | null => {
393 try {397 try {
394 const parsed = JSON.parse(interaction.payloadJson) as {398 const parsed = JSON.parse(interaction.payloadJson) as {
@@ -396,6 +400,7 @@ const parseInteractionPayload = (interaction: ExecutionInteraction): { · 1 unmodified line
396 message?: string;400 message?: string;
397 mode?: "form" | "url";401 mode?: "form" | "url";
398 url?: string;402 url?: string;
403 requestedSchema?: Record<string, unknown>;
399 };404 };
400 };405 };
401 406
@@ -407,12 +412,158 @@ const parseInteractionPayload = (interaction: ExecutionInteraction): { · 5 unmodified lines
407 message: parsed.elicitation.message,412 message: parsed.elicitation.message,
408 mode: parsed.elicitation.mode === "url" ? "url" : "form",413 mode: parsed.elicitation.mode === "url" ? "url" : "form",
409 url: parsed.elicitation.url,414 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,
410 };421 };
411 } catch {422 } catch {
412 return null;423 return null;
413 }424 }
414};425};
415 426
427const isRecord = (value: unknown): value is Record<string, unknown> =>
428 typeof value === "object" && value !== null && !Array.isArray(value);
429
430type PromptField = {
431 name: string;
432 label: string;
433 description?: string;
434 type: string;
435 required: boolean;
436 enumValues?: readonly unknown[];
437};
438
439const 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
472const 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
518const 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
416const promptInteraction = (interaction: ExecutionInteraction) =>567const promptInteraction = (interaction: ExecutionInteraction) =>
417 Effect.gen(function* () {568 Effect.gen(function* () {
418 const parsed = parseInteractionPayload(interaction);569 const parsed = parseInteractionPayload(interaction);
@@ -428,6 +579,14 @@ const promptInteraction = (interaction: ExecutionInteraction) => · 9 unmodified lines
428 return null;579 return null;
429 }580 }
430 581
582 const structured = yield* promptStructuredInteraction(parsed);
583 if (structured !== null) {
584 return JSON.stringify({
585 action: "accept",
586 content: structured,
587 });
588 }
589
431 const line = yield* promptLine(`${parsed.message} [y/N] `);590 const line = yield* promptLine(`${parsed.message} [y/N] `);
432 const normalized = line.trim().toLowerCase();591 const normalized = line.trim().toLowerCase();
433 if (normalized.length === 0) {592 if (normalized.length === 0) {
packages/codemode-core/src/tool-map.ts+9−0
@@ -1,6 +1,7 @@
1import type { StandardSchemaV1 } from "@standard-schema/spec";1import type { StandardSchemaV1 } from "@standard-schema/spec";
2import * as Data from "effect/Data";2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as JSONSchema from "effect/JSONSchema";
4 5
5import type {6import type {
6 ElicitationRequest,7 ElicitationRequest,
@@ -340,6 +341,14 @@ const stringifySchema = (value: unknown): string | undefined => { · 333 unmodified lines
340 }341 }
341 342
342 try {343 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
343 return JSON.stringify(value);352 return JSON.stringify(value);
344 } catch {353 } catch {
345 return undefined;354 return undefined;
packages/codemode-mcp/src/index.ts+1−0
@@ -13,6 +13,7 @@ export { · 12 unmodified lines
13 type McpClientLike,13 type McpClientLike,
14 type McpConnection,14 type McpConnection,
15 type McpConnector,15 type McpConnector,
16 type McpDiscoveryElicitationContext,
16 type McpToolManifest,17 type McpToolManifest,
17 type McpToolManifestEntry,18 type McpToolManifestEntry,
18} from "./mcp-tools";19} from "./mcp-tools";
packages/codemode-mcp/src/mcp-tools.test.ts+74−2
@@ -1,12 +1,19 @@
1import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";1import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
2import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";2import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";3import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4import { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types.js";
4import { describe, expect, it } from "@effect/vitest";5import { describe, expect, it } from "@effect/vitest";
5import { assertInstanceOf, assertTrue } from "@effect/vitest/utils";6import { assertInstanceOf, assertTrue } from "@effect/vitest/utils";
7import * as Either from "effect/Either";
6import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
7import { z } from "zod/v4";9import { z } from "zod/v4";
8 10
9import type { ToolDefinition, ToolInput, ToolMap } from "@executor-v3/codemode-core";11import type {
12 ToolDefinition,
13 ToolInput,
14 ToolMap,
15 ToolPath,
16} from "@executor-v3/codemode-core";
10 17
11import { createSdkMcpConnector } from "./mcp-connection";18import { createSdkMcpConnector } from "./mcp-connection";
12import {19import {
@@ -290,9 +297,74 @@ describe("codemode-mcp", () => { · 277 unmodified lines
290 }),297 }),
291 );298 );
292 299
293 assertTrue(outcome._tag === "Left");300 assertTrue(Either.isLeft(outcome));
294 assertInstanceOf(outcome.left, McpToolsError);301 assertInstanceOf(outcome.left, McpToolsError);
295 expect(outcome.left.stage).toBe("list_tools");302 expect(outcome.left.stage).toBe("list_tools");
296 }),303 }),
297 );304 );
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 );
298});370});
packages/codemode-mcp/src/mcp-tools.ts+97−11
@@ -1,5 +1,6 @@
1import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";1import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
2import * as Data from "effect/Data";2import * as Data from "effect/Data";
3import * as Either from "effect/Either";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
4import * as PartitionedSemaphore from "effect/PartitionedSemaphore";5import * as PartitionedSemaphore from "effect/PartitionedSemaphore";
5 6
@@ -8,8 +9,10 @@ import { · 2 unmodified lines
8 toTool,9 toTool,
9 type ElicitationRequest,10 type ElicitationRequest,
10 type ElicitationResponse,11 type ElicitationResponse,
12 type ToolInvocationContext,
11 type ToolExecutionContext,13 type ToolExecutionContext,
12 type ToolMap,14 type ToolMap,
15 type ToolMetadata,
13 type ToolPath,16 type ToolPath,
14 unknownInputSchema,17 unknownInputSchema,
15} from "@executor-v3/codemode-core";18} from "@executor-v3/codemode-core";
@@ -47,6 +50,15 @@ export type McpConnection = { · 31 unmodified lines
47 50
48export type McpConnector = () => Promise<McpConnection>;51export type McpConnector = () => Promise<McpConnection>;
49 52
53export 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
50type McpDiscoveryStage = "connect" | "list_tools" | "call_tool";62type McpDiscoveryStage = "connect" | "list_tools" | "call_tool";
51 63
52export class McpToolsError extends Data.TaggedError("McpToolsError")<{64export class McpToolsError extends Data.TaggedError("McpToolsError")<{
@@ -252,6 +264,79 @@ const callMcpTool = (input: { · 199 unmodified lines
252 catch: (cause) => cause,264 catch: (cause) => cause,
253 });265 });
254 266
267const 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
280const 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
255const runMcpToolCallEffect = (input: {340const runMcpToolCallEffect = (input: {
256 connection: McpConnection;341 connection: McpConnection;
257 toolName: string;342 toolName: string;
@@ -284,7 +369,7 @@ const runMcpToolCallEffect = (input: { · 26 unmodified lines
284 }),369 }),
285 );370 );
286 371
287 if (attempt._tag === "Right") {372 if (Either.isRight(attempt)) {
288 return attempt.right;373 return attempt.right;
289 }374 }
290 375
@@ -385,6 +470,7 @@ export const discoverMcpToolsFromConnector = (input: { · 94 unmodified lines
385 connect: McpConnector;470 connect: McpConnector;
386 namespace?: string;471 namespace?: string;
387 sourceKey?: string;472 sourceKey?: string;
473 mcpDiscoveryElicitation?: McpDiscoveryElicitationContext;
388}): Effect.Effect<{ manifest: McpToolManifest; tools: ToolMap }, McpToolsError> =>474}): Effect.Effect<{ manifest: McpToolManifest; tools: ToolMap }, McpToolsError> =>
389 Effect.gen(function* () {475 Effect.gen(function* () {
390 const listed = yield* withConnectionEffect({476 const listed = yield* withConnectionEffect({
@@ -395,16 +481,16 @@ export const discoverMcpToolsFromConnector = (input: { · 4 unmodified lines
395 message: "Failed connecting to MCP server",481 message: "Failed connecting to MCP server",
396 details: toDetails(cause),482 details: toDetails(cause),
397 }),483 }),
398 run: (connection) =>484 run: (connection) => {
399 Effect.tryPromise({485 const listEffect = runMcpListToolsEffect({
400 try: () => connection.client.listTools(),486 connection,
401 catch: (cause) =>487 mcpDiscoveryElicitation: input.mcpDiscoveryElicitation,
402 new McpToolsError({488 });
403 stage: "list_tools",489
404 message: "Failed listing MCP tools",490 return input.mcpDiscoveryElicitation
405 details: toDetails(cause),491 ? withElicitationClientLock(connection.client, listEffect)
406 }),492 : listEffect;
407 }),493 },
408 });494 });
409 495
410 const manifest = extractMcpToolManifestFromListToolsResult(listed);496 const manifest = extractMcpToolManifestFromListToolsResult(listed);
packages/codemode-openapi/package.json+1−1
@@ -14,12 +14,12 @@ · 13 unmodified lines
14 "test:watch": "vitest"14 "test:watch": "vitest"
15 },15 },
16 "dependencies": {16 "dependencies": {
17 "@effect/platform": "catalog:",
17 "@executor-v3/codemode-core": "workspace:*",18 "@executor-v3/codemode-core": "workspace:*",
18 "effect": "catalog:",19 "effect": "catalog:",
19 "yaml": "^2.8.1"20 "yaml": "^2.8.1"
20 },21 },
21 "devDependencies": {22 "devDependencies": {
22 "@effect/platform": "catalog:",
23 "@effect/platform-node": "^0.104.1",23 "@effect/platform-node": "^0.104.1",
24 "@effect/vitest": "catalog:",24 "@effect/vitest": "catalog:",
25 "@types/node": "catalog:",25 "@types/node": "catalog:",
packages/codemode-openapi/src/openapi-tools.ts+101−26
@@ -1,5 +1,11 @@
1import {
2 FetchHttpClient,
3 HttpClient,
4 HttpClientRequest,
5} from "@effect/platform";
1import * as Data from "effect/Data";6import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
3 9
4import {10import {
5 standardSchemaFromJsonSchema,11 standardSchemaFromJsonSchema,
@@ -195,13 +201,30 @@ const normalizeHttpUrl = (value: string): string => { · 189 unmodified lines
195 }201 }
196};202};
197 203
198const decodeFetchResponseBody = async (response: Response): Promise<unknown> => {204const decodeHttpClientResponseBody = (
199 const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";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
200 if (contentType.includes("application/json")) {215 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 );
202 }221 }
203 222
204 return response.text();223 return response.text.pipe(
224 Effect.mapError((cause) =>
225 cause instanceof Error ? cause : new Error(String(cause)),
226 ),
227 );
205};228};
206 229
207const inputSchemaFromTypingJson = (inputSchemaJson: string | undefined) => {230const inputSchemaFromTypingJson = (inputSchemaJson: string | undefined) => {
@@ -227,6 +250,28 @@ type CreateOpenApiToolsFromManifestInput = { · 19 unmodified lines
227 sourceKey?: string;250 sourceKey?: string;
228 defaultHeaders?: Readonly<Record<string, string>>;251 defaultHeaders?: Readonly<Record<string, string>>;
229 credentialHeaders?: Readonly<Record<string, string>>;252 credentialHeaders?: Readonly<Record<string, string>>;
253 httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
254};
255
256const 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 }
230};275};
231 276
232const buildFetchRequest = (input: {277const buildFetchRequest = (input: {
@@ -237,16 +282,20 @@ const buildFetchRequest = (input: { · 4 unmodified lines
237 credentialHeaders: Readonly<Record<string, string>>;282 credentialHeaders: Readonly<Record<string, string>>;
238}): {283}): {
239 url: URL;284 url: URL;
240 init: RequestInit;285 method: string;
286 headers: Record<string, string>;
287 body?: string;
241} => {288} => {
242 const resolvedPath = replacePathTemplate(289 const resolvedPath = replacePathTemplate(
243 input.payload.pathTemplate,290 input.payload.pathTemplate,
244 input.args,291 input.args,
245 input.payload,292 input.payload,
246 );293 );
247 const url = new URL(resolvedPath, input.baseUrl);294 const url = resolveRequestUrl(input.baseUrl, resolvedPath);
248 295
249 const headers = new Headers(input.defaultHeaders);296 const headers: Record<string, string> = {
297 ...input.defaultHeaders,
298 };
250 const cookieParts: Array<string> = [];299 const cookieParts: Array<string> = [];
251 300
252 for (const parameter of input.payload.parameters) {301 for (const parameter of input.payload.parameters) {
@@ -271,14 +320,14 @@ const buildFetchRequest = (input: { · 18 unmodified lines
271 if (parameter.location === "query") {320 if (parameter.location === "query") {
272 url.searchParams.set(parameter.name, encoded);321 url.searchParams.set(parameter.name, encoded);
273 } else if (parameter.location === "header") {322 } else if (parameter.location === "header") {
274 headers.set(parameter.name, encoded);323 headers[parameter.name] = encoded;
275 } else if (parameter.location === "cookie") {324 } else if (parameter.location === "cookie") {
276 cookieParts.push(`${parameter.name}=${encodeURIComponent(encoded)}`);325 cookieParts.push(`${parameter.name}=${encodeURIComponent(encoded)}`);
277 }326 }
278 }327 }
279 328
280 if (cookieParts.length > 0) {329 if (cookieParts.length > 0) {
281 headers.set("cookie", cookieParts.join("; "));330 headers.cookie = cookieParts.join("; ");
282 }331 }
283 332
284 let body: string | undefined;333 let body: string | undefined;
@@ -297,24 +346,22 @@ const buildFetchRequest = (input: { · 12 unmodified lines
297 346
298 const preferredContentType = input.payload.requestBody.contentTypes[0];347 const preferredContentType = input.payload.requestBody.contentTypes[0];
299 if (preferredContentType) {348 if (preferredContentType) {
300 headers.set("content-type", preferredContentType);349 headers["content-type"] = preferredContentType;
301 } else if (!headers.has("content-type")) {350 } else if (!("content-type" in headers)) {
302 headers.set("content-type", "application/json");351 headers["content-type"] = "application/json";
303 }352 }
304 }353 }
305 }354 }
306 355
307 for (const [key, value] of Object.entries(input.credentialHeaders)) {356 for (const [key, value] of Object.entries(input.credentialHeaders)) {
308 headers.set(key, value);357 headers[key] = value;
309 }358 }
310 359
311 return {360 return {
312 url,361 url,
313 init: {362 method: input.payload.method.toUpperCase(),
314 method: input.payload.method.toUpperCase(),363 headers,
315 headers,364 body,
316 body,
317 },
318 };365 };
319};366};
320 367
@@ -326,6 +373,7 @@ export const createOpenApiToolsFromManifest = ( · 5 unmodified lines
326 const namespace = input.namespace;373 const namespace = input.namespace;
327 const defaultHeaders = input.defaultHeaders ?? {};374 const defaultHeaders = input.defaultHeaders ?? {};
328 const credentialHeaders = input.credentialHeaders ?? {};375 const credentialHeaders = input.credentialHeaders ?? {};
376 const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer;
329 377
330 const result: ToolMap = {};378 const result: ToolMap = {};
331 379
@@ -349,14 +397,39 @@ export const createOpenApiToolsFromManifest = ( · 17 unmodified lines
349 credentialHeaders,397 credentialHeaders,
350 });398 });
351 399
352 const response = await fetch(request.url, request.init);400 return Effect.runPromise(
353 const body = await decodeFetchResponseBody(response);401 Effect.gen(function* () {
354 402 const client = yield* HttpClient.HttpClient;
355 return {403 let clientRequest = HttpClientRequest.make(
356 status: response.status,404 request.method as Parameters<typeof HttpClientRequest.make>[0],
357 headers: Object.fromEntries(response.headers.entries()),405 )(request.url, {
358 body,406 headers: request.headers,
359 };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 );
360 },433 },
361 },434 },
362 metadata: {435 metadata: {
@@ -379,6 +452,7 @@ export const createOpenApiToolsFromSpec = (input: { · 16 unmodified lines
379 sourceKey?: string;452 sourceKey?: string;
380 defaultHeaders?: Readonly<Record<string, string>>;453 defaultHeaders?: Readonly<Record<string, string>>;
381 credentialHeaders?: Readonly<Record<string, string>>;454 credentialHeaders?: Readonly<Record<string, string>>;
455 httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
382}): Effect.Effect<456}): Effect.Effect<
383 { manifest: OpenApiToolManifest; tools: ToolMap },457 { manifest: OpenApiToolManifest; tools: ToolMap },
384 OpenApiExtractionError458 OpenApiExtractionError
@@ -394,5 +468,6 @@ export const createOpenApiToolsFromSpec = (input: { · 9 unmodified lines
394 sourceKey: input.sourceKey,468 sourceKey: input.sourceKey,
395 defaultHeaders: input.defaultHeaders,469 defaultHeaders: input.defaultHeaders,
396 credentialHeaders: input.credentialHeaders,470 credentialHeaders: input.credentialHeaders,
471 httpClientLayer: input.httpClientLayer,
397 }),472 }),
398 }));473 }));
packages/control-plane/src/runtime/executor-tools.ts+258−26
@@ -1,5 +1,8 @@
1import {1import {
2 type ElicitationResponse,2 type ElicitationResponse,
3 type OnElicitation,
4 type ToolInvocationContext,
5 type ToolMetadata,
3 toTool,6 toTool,
4 type ToolMap,7 type ToolMap,
5 type ToolPath,8 type ToolPath,
@@ -7,23 +10,104 @@ import { · 1 unmodified line
7import {10import {
8 ExecutionIdSchema,11 ExecutionIdSchema,
9 ExecutionInteractionIdSchema,12 ExecutionInteractionIdSchema,
13 SourceSchema,
10 type Source,14 type Source,
11 type WorkspaceId,15 type WorkspaceId,
12} from "#schema";16} from "#schema";
13import * as Effect from "effect/Effect";17import * as Effect from "effect/Effect";
14import * as Schema from "effect/Schema";18import * as Schema from "effect/Schema";
15 19
16import { type RuntimeSourceAuthService } from "./source-auth-service";20import {
21 type ExecutorAddSourceInput,
22 type RuntimeSourceAuthService,
23} from "./source-auth-service";
24import {
25 deriveSchemaJson,
26 deriveSchemaTypeSignature,
27} from "./schema-type-signature";
17 28
18const ExecutorSourcesAddInputSchema = Schema.standardSchemaV1(29const ExecutorOpenApiSourceAuthInputSchema = Schema.Union(
19 Schema.Struct({30 Schema.Struct({
20 endpoint: Schema.String,31 kind: Schema.Literal("none"),
21 name: Schema.optional(Schema.NullOr(Schema.String)),
22 namespace: Schema.optional(Schema.NullOr(Schema.String)),
23 }),32 }),
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
42const 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
49const 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
58const ExecutorSourcesAddSchema = Schema.Union(
59 ExecutorMcpSourceAddInputSchema,
60 ExecutorOpenApiSourceAddInputSchema,
61);
62
63const ExecutorSourcesAddInputSchema = Schema.standardSchemaV1(
64 ExecutorSourcesAddSchema,
65);
66
67const ExecutorSourcesAddOutputSchema = Schema.standardSchemaV1(SourceSchema);
68
69export const EXECUTOR_SOURCES_ADD_MCP_INPUT_SIGNATURE = deriveSchemaTypeSignature(
70 ExecutorMcpSourceAddInputSchema,
71 240,
72);
73
74export const EXECUTOR_SOURCES_ADD_OPENAPI_INPUT_SIGNATURE = deriveSchemaTypeSignature(
75 ExecutorOpenApiSourceAddInputSchema,
76 420,
77);
78
79export const EXECUTOR_SOURCES_ADD_INPUT_HINT = deriveSchemaTypeSignature(
80 ExecutorSourcesAddInputSchema,
81 320,
82);
83
84export const EXECUTOR_SOURCES_ADD_OUTPUT_SIGNATURE = deriveSchemaTypeSignature(
85 SourceSchema,
86 260,
87);
88
89export const EXECUTOR_SOURCES_ADD_INPUT_SCHEMA_JSON = JSON.stringify(
90 deriveSchemaJson(ExecutorSourcesAddSchema) ?? {},
24);91);
25 92
26const ExecutorSourcesAddOutputSchema = Schema.standardSchemaV1(Schema.Unknown);93export const EXECUTOR_SOURCES_ADD_OUTPUT_SCHEMA_JSON = JSON.stringify(
94 deriveSchemaJson(SourceSchema) ?? {},
95);
96
97export 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
106export const buildExecutorSourcesAddDescription = (): string =>
107 [
108 "Add an MCP or OpenAPI source to the current workspace.",
109 ...EXECUTOR_SOURCES_ADD_HELP_LINES,
110 ].join("\n");
27 111
28const toExecutionId = (value: unknown) => {112const toExecutionId = (value: unknown) => {
29 if (typeof value !== "string" || value.trim().length === 0) {113 if (typeof value !== "string" || value.trim().length === 0) {
@@ -35,36 +119,180 @@ const toExecutionId = (value: unknown) => { · 5 unmodified lines
35 119
36const asToolPath = (value: string): ToolPath => value as ToolPath;120const asToolPath = (value: string): ToolPath => value as ToolPath;
37 121
122const 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
131const resolveOpenApiSourceLabel = (input: {
132 name?: string | null;
133 endpoint: string;
134}): string => trimOrNull(input.name) ?? input.endpoint;
135
136const 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
38export const createExecutorToolMap = (input: {196export const createExecutorToolMap = (input: {
39 workspaceId: WorkspaceId;197 workspaceId: WorkspaceId;
40 sourceAuthService: RuntimeSourceAuthService;198 sourceAuthService: RuntimeSourceAuthService;
41}): ToolMap => ({199}): ToolMap => ({
42 "executor.sources.add": toTool({200 "executor.sources.add": toTool({
43 tool: {201 tool: {
44 description: "Add an MCP source to the current workspace, starting OAuth if the source requires it",202 description: buildExecutorSourcesAddDescription(),
45 inputSchema: ExecutorSourcesAddInputSchema,203 inputSchema: ExecutorSourcesAddInputSchema,
46 outputSchema: ExecutorSourcesAddOutputSchema,204 outputSchema: ExecutorSourcesAddOutputSchema,
47 execute: async (205 execute: async (
48 args: {206 args:
49 endpoint: string;207 | {
50 name?: string | null;208 kind?: "mcp";
51 namespace?: string | null;209 endpoint: string;
52 },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 },
53 context,232 context,
54 ): Promise<Source> => {233 ): Promise<Source> => {
55 const executionId = toExecutionId(context?.invocation?.runId);234 const executionId = toExecutionId(context?.invocation?.runId);
56 const interactionId = ExecutionInteractionIdSchema.make(235 const interactionId = ExecutionInteractionIdSchema.make(
57 `executor.sources.add:${crypto.randomUUID()}`,236 `executor.sources.add:${crypto.randomUUID()}`,
58 );237 );
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 };
59 const result = await Effect.runPromise(280 const result = await Effect.runPromise(
60 input.sourceAuthService.addExecutorMcpSource({281 input.sourceAuthService.addExecutorSource(
61 workspaceId: input.workspaceId,282 preparedArgs,
62 executionId,283 context?.onElicitation
63 interactionId,284 ? {
64 endpoint: args.endpoint,285 mcpDiscoveryElicitation: {
65 name: args.name ?? null,286 onElicitation: context.onElicitation,
66 namespace: args.namespace ?? null,287 path: context.path ?? asToolPath("executor.sources.add"),
67 }),288 sourceKey: context.sourceKey,
289 args,
290 metadata: context.metadata,
291 invocation: context.invocation,
292 },
293 }
294 : undefined,
295 ),
68 );296 );
69 297
70 if (result.kind === "connected") {298 if (result.kind === "connected") {
@@ -80,7 +308,7 @@ export const createExecutorToolMap = (input: { · 9 unmodified lines
80 interactionId,308 interactionId,
81 path: context.path ?? asToolPath("executor.sources.add"),309 path: context.path ?? asToolPath("executor.sources.add"),
82 sourceKey: context.sourceKey,310 sourceKey: context.sourceKey,
83 args,311 args: preparedArgs,
84 metadata: context.metadata,312 metadata: context.metadata,
85 context: context.invocation,313 context: context.invocation,
86 elicitation: {314 elicitation: {
@@ -104,9 +332,13 @@ export const createExecutorToolMap = (input: { · 17 unmodified lines
104 );332 );
105 },333 },
106 },334 },
107 metadata: {335 metadata: {
108 sourceKey: "executor",336 inputHint: EXECUTOR_SOURCES_ADD_INPUT_HINT,
109 interaction: "auto",337 outputHint: EXECUTOR_SOURCES_ADD_OUTPUT_SIGNATURE,
110 },338 inputSchemaJson: EXECUTOR_SOURCES_ADD_INPUT_SCHEMA_JSON,
111 }),339 outputSchemaJson: EXECUTOR_SOURCES_ADD_OUTPUT_SCHEMA_JSON,
340 sourceKey: "executor",
341 interaction: "auto",
342 },
343 }),
112});344});
packages/control-plane/src/runtime/index.ts+2−0
@@ -50,8 +50,10 @@ export { · 49 unmodified lines
50};50};
51 51
52export * from "./execution-state";52export * from "./execution-state";
53export * from "./executor-tools";
53export * from "./live-execution";54export * from "./live-execution";
54export * from "./local-installation";55export * from "./local-installation";
56export * from "./schema-type-signature";
55export * from "./source-auth-service";57export * from "./source-auth-service";
56export * from "./store";58export * from "./store";
57export * from "./workspace-execution-environment";59export * from "./workspace-execution-environment";
packages/control-plane/src/runtime/schema-type-signature.ts+114−0
@@ -0,0 +1,114 @@
1import * as JSONSchema from "effect/JSONSchema";
2
3type JsonSchemaRecord = Record<string, unknown>;
4
5const asRecord = (value: unknown): JsonSchemaRecord =>
6 typeof value === "object" && value !== null && !Array.isArray(value)
7 ? value as JsonSchemaRecord
8 : {};
9
10const asStringArray = (value: unknown): Array<string> =>
11 Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
12
13const truncate = (value: string, maxLength: number): string =>
14 value.length <= maxLength ? value : `${value.slice(0, Math.max(0, maxLength - 4))} ...`;
15
16const propertyLabel = (name: string, schema: JsonSchemaRecord, optional: boolean): string =>
17 `${name}${optional ? "?" : ""}: ${schemaToTypeSignature(schema)}`;
18
19const 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
39export 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
98export const deriveSchemaTypeSignature = (
99 schema: unknown,
100 maxLength?: number,
101): string => {
102 const schemaJson = deriveSchemaJson(schema);
103 return schemaJson ? schemaToTypeSignature(schemaJson, maxLength) : "unknown";
104};
105
106export 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
10import {10import {
11 createSdkMcpConnector,11 createSdkMcpConnector,
12 discoverMcpToolsFromConnector,12 discoverMcpToolsFromConnector,
13 type McpDiscoveryElicitationContext,
13} from "@executor-v3/codemode-mcp";14} from "@executor-v3/codemode-mcp";
14import {15import {
15 SqlControlPlaneRowsService,16 SqlControlPlaneRowsService,
@@ -173,7 +174,10 @@ const persistSource = (rows: SqlControlPlaneRows, source: Source) => · 157 unmodified lines
173 return source;174 return source;
174 });175 });
175 176
176const probeMcpSourceWithoutAuth = (source: Source) =>177const probeMcpSourceWithoutAuth = (
178 source: Source,
179 mcpDiscoveryElicitation?: McpDiscoveryElicitationContext,
180) =>
177 Effect.gen(function* () {181 Effect.gen(function* () {
178 if (source.kind !== "mcp") {182 if (source.kind !== "mcp") {
179 return yield* Effect.fail(new Error(`Expected MCP source, received ${source.kind}`));183 return yield* Effect.fail(new Error(`Expected MCP source, received ${source.kind}`));
@@ -195,6 +199,7 @@ const probeMcpSourceWithoutAuth = (source: Source) => · 15 unmodified lines
195 connect: connector,199 connect: connector,
196 namespace: source.namespace ?? defaultNamespaceFromName(source.name),200 namespace: source.namespace ?? defaultNamespaceFromName(source.name),
197 sourceKey: source.id,201 sourceKey: source.id,
202 mcpDiscoveryElicitation,
198 });203 });
199 });204 });
200 205
@@ -428,7 +433,7 @@ const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { · 227 unmodified lines
428 });433 });
429 434
430const upsertSecretMaterial = (rows: SqlControlPlaneRows, input: {435const upsertSecretMaterial = (rows: SqlControlPlaneRows, input: {
431 purpose: "oauth_access_token" | "oauth_refresh_token";436 purpose: "auth_material" | "oauth_access_token" | "oauth_refresh_token";
432 value: string;437 value: string;
433}) =>438}) =>
434 Effect.gen(function* () {439 Effect.gen(function* () {
@@ -457,19 +462,98 @@ export type ExecutorSourceAddResult = · 22 unmodified lines
457 authorizationUrl: string;462 authorizationUrl: string;
458 };463 };
459 464
465export 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
477export 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
499const 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
460type RuntimeSourceAuthServiceShape = {546type RuntimeSourceAuthServiceShape = {
461 getSourceById: (input: {547 getSourceById: (input: {
462 workspaceId: WorkspaceId;548 workspaceId: WorkspaceId;
463 sourceId: Source["id"];549 sourceId: Source["id"];
464 }) => Effect.Effect<Source, Error, never>;550 }) => Effect.Effect<Source, Error, never>;
465 addExecutorMcpSource: (input: {551 addExecutorSource: (
466 workspaceId: WorkspaceId;552 input: ExecutorAddSourceInput,
467 executionId: SourceAuthSession["executionId"];553 options?: {
468 interactionId: SourceAuthSession["interactionId"];554 mcpDiscoveryElicitation?: McpDiscoveryElicitationContext;
469 endpoint: string;555 },
470 name?: string | null;556 ) => Effect.Effect<ExecutorSourceAddResult, Error, never>;
471 namespace?: string | null;
472 }) => Effect.Effect<ExecutorSourceAddResult, Error, never>;
473 completeSourceAuthCallback: (input: {557 completeSourceAuthCallback: (input: {
474 state: string;558 state: string;
475 code?: string | null;559 code?: string | null;
@@ -524,166 +608,261 @@ export const createRuntimeSourceAuthService = (input: { · 48 unmodified lines
524 sourceId,608 sourceId,
525 }),609 }),
526 610
527 addExecutorMcpSource: ({611 addExecutorSource: (sourceInput, options) =>
528 workspaceId,612 sourceInput.kind === "openapi"
529 executionId,613 ? Effect.gen(function* () {
530 interactionId,614 const normalizedEndpoint = normalizeEndpoint(sourceInput.endpoint);
531 endpoint,615 const normalizedSpecUrl = normalizeEndpoint(sourceInput.specUrl);
532 name,616 const existingSources = yield* loadSourcesInWorkspace(
533 namespace,617 input.rows,
534 }) =>618 sourceInput.workspaceId,
535 Effect.gen(function* () {619 );
536 const normalizedEndpoint = normalizeEndpoint(endpoint);620 const existing = existingSources.find(
537 const existingSources = yield* loadSourcesInWorkspace(input.rows, workspaceId);621 (source) =>
538 const existing = existingSources.find(622 source.kind === "openapi"
539 (source) => source.kind === "mcp" && normalizeEndpoint(source.endpoint) === normalizedEndpoint,623 && normalizeEndpoint(source.endpoint) === normalizedEndpoint
540 );624 && trimOrNull(source.specUrl) === normalizedSpecUrl,
625 );
541 626
542 const chosenName =627 const chosenName =
543 trimOrNull(name)628 trimOrNull(sourceInput.name)
544 ?? existing?.name629 ?? existing?.name
545 ?? defaultSourceNameFromEndpoint(normalizedEndpoint);630 ?? defaultSourceNameFromEndpoint(normalizedEndpoint);
546 const chosenNamespace =631 const chosenNamespace =
547 trimOrNull(namespace)632 trimOrNull(sourceInput.namespace)
548 ?? existing?.namespace633 ?? existing?.namespace
549 ?? defaultNamespaceFromName(chosenName);634 ?? defaultNamespaceFromName(chosenName);
550 const now = Date.now();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 );
551 685
552 const draftSource = existing686 return yield* Either.match(synced, {
553 ? yield* updateSourceFromPayload({687 onLeft: (error) =>
554 source: existing,688 updateSourceStatus(input.rows, persistedDraft, {
555 payload: {689 status: "error",
556 name: chosenName,690 lastError: error.message,
557 endpoint: normalizedEndpoint,691 }).pipe(
558 namespace: chosenNamespace,692 Effect.zipRight(Effect.fail(error)),
559 kind: "mcp",693 ),
560 status: "probing",694 onRight: () =>
561 enabled: true,695 updateSourceStatus(input.rows, persistedDraft, {
562 transport: existing.transport ?? "auto",696 status: "connected",
563 auth: { kind: "none" },697 lastError: null,
564 lastError: null,698 }).pipe(
565 },699 Effect.map((source) =>
566 now,700 ({
567 })701 kind: "connected",
568 : yield* createSourceFromPayload({702 source,
569 workspaceId,703 } satisfies ExecutorSourceAddResult)
570 sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`),704 ),
571 payload: {705 ),
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,
582 });706 });
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 );
583 717
584 const persistedDraft = yield* persistSource(input.rows, draftSource);718 const chosenName =
585 yield* syncSourceToolArtifacts({719 trimOrNull(sourceInput.name)
586 rows: input.rows,720 ?? existing?.name
587 source: persistedDraft,721 ?? defaultSourceNameFromEndpoint(normalizedEndpoint);
588 resolveSecretMaterial,722 const chosenNamespace =
589 });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 });
590 766
591 const discovered = yield* Effect.either(767 const discovered = yield* Effect.either(
592 probeMcpSourceWithoutAuth(persistedDraft),768 probeMcpSourceWithoutAuth(
593 );769 persistedDraft,
770 options?.mcpDiscoveryElicitation,
771 ),
772 );
594 773
595 const connectedResult = yield* Either.match(discovered, {774 const connectedResult = yield* Either.match(discovered, {
596 onLeft: () => Effect.succeed(null),775 onLeft: () => Effect.succeed(null),
597 onRight: (result) =>776 onRight: (result) =>
598 Effect.gen(function* () {777 Effect.gen(function* () {
599 const connected = yield* updateSourceStatus(input.rows, persistedDraft, {778 const connected = yield* updateSourceStatus(input.rows, persistedDraft, {
600 status: "connected",779 status: "connected",
601 lastError: null,780 lastError: null,
602 auth: { kind: "none" },781 auth: { kind: "none" },
603 });782 });
604 const indexed = yield* Effect.either(783 const indexed = yield* Effect.either(
605 persistMcpToolArtifactsFromManifest({784 persistMcpToolArtifactsFromManifest({
606 rows: input.rows,785 rows: input.rows,
607 source: connected,786 source: connected,
608 manifestEntries: result.manifest.tools,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 });
609 }),805 }),
610 );806 });
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 }
632 807
633 const localServerBaseUrl = input.getLocalServerBaseUrl?.();808 if (connectedResult) {
634 if (!localServerBaseUrl) {809 return connectedResult;
635 return yield* Effect.fail(810 }
636 new Error("Local executor server base URL is unavailable for OAuth callback handling"),
637 );
638 }
639 811
640 const sessionId = SourceAuthSessionIdSchema.make(`src_auth_${crypto.randomUUID()}`);812 const localServerBaseUrl = input.getLocalServerBaseUrl?.();
641 const state = crypto.randomUUID();813 if (!localServerBaseUrl) {
642 const redirectUrl = resolveRedirectUrl(localServerBaseUrl);814 return yield* Effect.fail(
643 const oauthStart = yield* startOAuthAuthorization({815 new Error("Local executor server base URL is unavailable for OAuth callback handling"),
644 endpoint: normalizedEndpoint,816 );
645 redirectUrl,817 }
646 state,818
647 });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 });
648 827
649 const authRequiredSource = yield* updateSourceStatus(input.rows, persistedDraft, {828 const authRequiredSource = yield* updateSourceStatus(input.rows, persistedDraft, {
650 status: "auth_required",829 status: "auth_required",
651 lastError: null,830 lastError: null,
652 });831 });
653 832
654 const sessionNow = Date.now();833 const sessionNow = Date.now();
655 yield* input.rows.sourceAuthSessions.upsert({834 yield* input.rows.sourceAuthSessions.upsert({
656 id: sessionId,835 id: sessionId,
657 workspaceId,836 workspaceId: sourceInput.workspaceId,
658 sourceId: authRequiredSource.id,837 sourceId: authRequiredSource.id,
659 executionId,838 executionId: sourceInput.executionId,
660 interactionId,839 interactionId: sourceInput.interactionId,
661 strategy: "oauth2_authorization_code",840 strategy: "oauth2_authorization_code",
662 status: "pending",841 status: "pending",
663 endpoint: normalizedEndpoint,842 endpoint: normalizedEndpoint,
664 state,843 state,
665 redirectUri: redirectUrl,844 redirectUri: redirectUrl,
666 scope: null,845 scope: null,
667 resourceMetadataUrl: oauthStart.resourceMetadataUrl,846 resourceMetadataUrl: oauthStart.resourceMetadataUrl,
668 authorizationServerUrl: oauthStart.authorizationServerUrl,847 authorizationServerUrl: oauthStart.authorizationServerUrl,
669 resourceMetadataJson: oauthStart.resourceMetadataJson,848 resourceMetadataJson: oauthStart.resourceMetadataJson,
670 authorizationServerMetadataJson: oauthStart.authorizationServerMetadataJson,849 authorizationServerMetadataJson: oauthStart.authorizationServerMetadataJson,
671 clientInformationJson: oauthStart.clientInformationJson,850 clientInformationJson: oauthStart.clientInformationJson,
672 codeVerifier: oauthStart.codeVerifier,851 codeVerifier: oauthStart.codeVerifier,
673 authorizationUrl: oauthStart.authorizationUrl,852 authorizationUrl: oauthStart.authorizationUrl,
674 errorText: null,853 errorText: null,
675 completedAt: null,854 completedAt: null,
676 createdAt: sessionNow,855 createdAt: sessionNow,
677 updatedAt: sessionNow,856 updatedAt: sessionNow,
678 });857 });
679 858
680 return {859 return {
681 kind: "oauth_required",860 kind: "oauth_required",
682 source: authRequiredSource,861 source: authRequiredSource,
683 sessionId,862 sessionId,
684 authorizationUrl: oauthStart.authorizationUrl,863 authorizationUrl: oauthStart.authorizationUrl,
685 } satisfies ExecutorSourceAddResult;864 } satisfies ExecutorSourceAddResult;
686 }),865 }),
687 866
688 completeSourceAuthCallback: ({ state, code, error, errorDescription }) =>867 completeSourceAuthCallback: ({ state, code, error, errorDescription }) =>
689 Effect.gen(function* () {868 Effect.gen(function* () {
packages/control-plane/src/runtime/tool-artifacts.ts+46−10
@@ -1,3 +1,8 @@
1import {
2 FetchHttpClient,
3 HttpClient,
4 HttpClientRequest,
5} from "@effect/platform";
1import {6import {
2 createSdkMcpConnector,7 createSdkMcpConnector,
3 discoverMcpToolsFromConnector,8 discoverMcpToolsFromConnector,
@@ -5,7 +10,6 @@ import { · 1 unmodified line
5} from "@executor-v3/codemode-mcp";10} from "@executor-v3/codemode-mcp";
6import {11import {
7 extractOpenApiManifest,12 extractOpenApiManifest,
8 fetchOpenApiDocument,
9 type OpenApiExtractedTool,13 type OpenApiExtractedTool,
10} from "@executor-v3/codemode-openapi";14} from "@executor-v3/codemode-openapi";
11import type { SqlControlPlaneRows } from "#persistence";15import type { SqlControlPlaneRows } from "#persistence";
@@ -224,6 +228,32 @@ export const resolveSourceAuthMaterial = (input: { · 212 unmodified lines
224 } satisfies ResolvedSourceAuthMaterial;228 } satisfies ResolvedSourceAuthMaterial;
225 });229 });
226 230
231const 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
227const indexMcpSourceToolArtifacts = (input: {257const indexMcpSourceToolArtifacts = (input: {
228 rows: SqlControlPlaneRows;258 rows: SqlControlPlaneRows;
229 source: Source;259 source: Source;
@@ -293,6 +323,7 @@ const discoverAndIndexMcpSourceToolArtifacts = (input: { · 63 unmodified lines
293const indexOpenApiSourceToolArtifacts = (input: {323const indexOpenApiSourceToolArtifacts = (input: {
294 rows: SqlControlPlaneRows;324 rows: SqlControlPlaneRows;
295 source: Source;325 source: Source;
326 auth: ResolvedSourceAuthMaterial;
296}): Effect.Effect<void, Error, never> =>327}): Effect.Effect<void, Error, never> =>
297 Effect.gen(function* () {328 Effect.gen(function* () {
298 if (!input.source.specUrl) {329 if (!input.source.specUrl) {
@@ -301,15 +332,19 @@ const indexOpenApiSourceToolArtifacts = (input: { · 2 unmodified lines
301 );332 );
302 }333 }
303 334
304 const openApiDocument = yield* Effect.tryPromise({335 const openApiDocument = yield* fetchOpenApiDocumentWithHeaders({
305 try: () => fetchOpenApiDocument(input.source.specUrl!),336 url: input.source.specUrl,
306 catch: (cause) =>337 headers: {
307 cause instanceof Error338 ...(input.source.defaultHeaders ?? {}),
308 ? new Error(339 ...input.auth.headers,
309 `Failed fetching OpenAPI spec for ${input.source.id}: ${cause.message}`,340 },
310 )341 }).pipe(
311 : new Error(`Failed fetching OpenAPI spec for ${input.source.id}: ${String(cause)}`),342 Effect.mapError((cause) =>
312 });343 new Error(
344 `Failed fetching OpenAPI spec for ${input.source.id}: ${cause.message}`,
345 ),
346 ),
347 );
313 348
314 const manifest = yield* extractOpenApiManifest(349 const manifest = yield* extractOpenApiManifest(
315 input.source.name,350 input.source.name,
@@ -376,6 +411,7 @@ export const syncSourceToolArtifacts = (input: { · 60 unmodified lines
376 return yield* indexOpenApiSourceToolArtifacts({411 return yield* indexOpenApiSourceToolArtifacts({
377 rows: input.rows,412 rows: input.rows,
378 source: input.source,413 source: input.source,
414 auth,
379 });415 });
380 }416 }
381 417
packages/control-plane/src/runtime/workspace-execution-environment.test.ts+261−2
@@ -1,5 +1,14 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
2 2
3import {
4 HttpApi,
5 HttpApiBuilder,
6 HttpApiEndpoint,
7 HttpApiGroup,
8 HttpApiSchema,
9 HttpServer,
10} from "@effect/platform";
11import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
3import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";12import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
4import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";13import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";14import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
@@ -10,7 +19,12 @@ import { · 4 unmodified lines
10 SourceIdSchema,19 SourceIdSchema,
11 WorkspaceIdSchema,20 WorkspaceIdSchema,
12} from "#schema";21} from "#schema";
22import * as Context from "effect/Context";
13import * as Effect from "effect/Effect";23import * as Effect from "effect/Effect";
24import * as Exit from "effect/Exit";
25import * as Layer from "effect/Layer";
26import * as Scope from "effect/Scope";
27import { Schema } from "effect";
14import { z } from "zod/v4";28import { z } from "zod/v4";
15 29
16import {30import {
@@ -19,7 +33,11 @@ import { · 2 unmodified lines
19} from "#persistence";33} from "#persistence";
20import type { SourceId, WorkspaceId } from "#schema";34import type { SourceId, WorkspaceId } from "#schema";
21 35
22import type { RuntimeSourceAuthService } from "./source-auth-service";36import {
37 createRuntimeSourceAuthService,
38 type RuntimeSourceAuthService,
39} from "./source-auth-service";
40import { createLiveExecutionManager } from "./live-execution";
23import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment";41import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment";
24 42
25type CountedMcpServer = {43type CountedMcpServer = {
@@ -31,6 +49,16 @@ type CountedMcpServer = { · 5 unmodified lines
31 close: () => Promise<void>;49 close: () => Promise<void>;
32};50};
33 51
52type OpenApiSpecServer = {
53 baseUrl: string;
54 specUrl: string;
55 seenAuthHeaders: Array<string | null>;
56 close: () => Promise<void>;
57};
58
59const closeScope = (scope: Scope.CloseableScope) =>
60 Scope.close(scope, Exit.void).pipe(Effect.orDie);
61
34const makePersistence = Effect.acquireRelease(62const makePersistence = Effect.acquireRelease(
35 createSqlControlPlanePersistence({63 createSqlControlPlanePersistence({
36 localDataDir: ":memory:",64 localDataDir: ":memory:",
@@ -249,6 +277,73 @@ const makeCountedMcpServer = Effect.acquireRelease( · 212 unmodified lines
249 }).pipe(Effect.orDie),277 }).pipe(Effect.orDie),
250);278);
251 279
280const ownerParam = HttpApiSchema.param("owner", Schema.String);
281const repoParam = HttpApiSchema.param("repo", Schema.String);
282
283class 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
294class GeneratedApi extends HttpApi.make("generated").add(GeneratedReposApi) {}
295
296const 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
252const persistConnectedEchoTool = (input: {347const persistConnectedEchoTool = (input: {
253 persistence: SqlControlPlanePersistence;348 persistence: SqlControlPlanePersistence;
254 endpoint: string;349 endpoint: string;
@@ -323,11 +418,20 @@ const makeResolver = (persistence: SqlControlPlanePersistence) => · 68 unmodified lines
323 rows: persistence.rows,418 rows: persistence.rows,
324 sourceAuthService: {419 sourceAuthService: {
325 getSourceById: () => Effect.fail(new Error("not implemented in test")),420 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")),
327 completeSourceAuthCallback: () => Effect.fail(new Error("not implemented in test")),422 completeSourceAuthCallback: () => Effect.fail(new Error("not implemented in test")),
328 } as RuntimeSourceAuthService,423 } as RuntimeSourceAuthService,
329 });424 });
330 425
426const makeResolverWithLiveSourceAuth = (persistence: SqlControlPlanePersistence) =>
427 createWorkspaceExecutionEnvironmentResolver({
428 rows: persistence.rows,
429 sourceAuthService: createRuntimeSourceAuthService({
430 rows: persistence.rows,
431 liveExecutionManager: createLiveExecutionManager(),
432 }),
433 });
434
331describe("workspace-execution-environment", () => {435describe("workspace-execution-environment", () => {
332 it.scoped("discovers persisted tools without live MCP listing", () =>436 it.scoped("discovers persisted tools without live MCP listing", () =>
333 Effect.gen(function* () {437 Effect.gen(function* () {
@@ -407,4 +511,159 @@ describe("workspace-execution-environment", () => { · 73 unmodified lines
407 expect(result.content?.[0]?.text).toBe("echo:hello");511 expect(result.content?.[0]?.text).toBe("echo:hello");
408 }),512 }),
409 );513 );
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 );
410});669});