Unified · Split
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | NodeRuntime, |
| 10 | 10 | } from "@effect/platform-node"; |
| 11 | 11 | import { |
| 12 | + EXECUTOR_SOURCES_ADD_HELP_LINES, | |
| 12 | 13 | ExecutionIdSchema, |
| 13 | 14 | RuntimeExecutionResolverService, |
| 14 | 15 | createControlPlaneClient, |
| @@ -146,7 +147,8 @@ const buildWorkflowText = (namespaces: readonly string[] = []): string => · 131 unmodified lines | ||
| 146 | 147 | '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', |
| 147 | 148 | "2) const details = await tools.describe.tool({ path, includeSchemas: true });", |
| 148 | 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 | 152 | "5) If execution pauses for interaction, resume it with `executor resume --execution-id ...`.", |
| 151 | 153 | "Do not use fetch; use tools.* only.", |
| 152 | 154 | ].join("\n"); |
| @@ -305,6 +307,7 @@ const printCallHelp = (workflow: string) => · 152 unmodified lines | ||
| 305 | 307 | ' executor call \'const matches = await tools.discover({ query: "github issues", limit: 5 }); return matches;\'', |
| 306 | 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 | 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 | 311 | " cat script.ts | executor call --stdin", |
| 309 | 312 | " executor call --file script.ts", |
| 310 | 313 | " executor resume --execution-id exec_123", |
| @@ -389,6 +392,7 @@ const parseInteractionPayload = (interaction: ExecutionInteraction): { · 78 unmodified lines | ||
| 389 | 392 | message: string; |
| 390 | 393 | mode: "form" | "url"; |
| 391 | 394 | url?: string; |
| 395 | + requestedSchema?: Record<string, unknown>; | |
| 392 | 396 | } | null => { |
| 393 | 397 | try { |
| 394 | 398 | const parsed = JSON.parse(interaction.payloadJson) as { |
| @@ -396,6 +400,7 @@ const parseInteractionPayload = (interaction: ExecutionInteraction): { · 1 unmodified line | ||
| 396 | 400 | message?: string; |
| 397 | 401 | mode?: "form" | "url"; |
| 398 | 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 | 412 | message: parsed.elicitation.message, |
| 408 | 413 | mode: parsed.elicitation.mode === "url" ? "url" : "form", |
| 409 | 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 | 422 | } catch { |
| 412 | 423 | return null; |
| 413 | 424 | } |
| 414 | 425 | }; |
| 415 | 426 | |
| 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 | + | |
| 416 | 567 | const promptInteraction = (interaction: ExecutionInteraction) => |
| 417 | 568 | Effect.gen(function* () { |
| 418 | 569 | const parsed = parseInteractionPayload(interaction); |
| @@ -428,6 +579,14 @@ const promptInteraction = (interaction: ExecutionInteraction) => · 9 unmodified lines | ||
| 428 | 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 | 590 | const line = yield* promptLine(`${parsed.message} [y/N] `); |
| 432 | 591 | const normalized = line.trim().toLowerCase(); |
| 433 | 592 | if (normalized.length === 0) { |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import type { StandardSchemaV1 } from "@standard-schema/spec"; |
| 2 | 2 | import * as Data from "effect/Data"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | +import * as JSONSchema from "effect/JSONSchema"; | |
| 4 | 5 | |
| 5 | 6 | import type { |
| 6 | 7 | ElicitationRequest, |
| @@ -340,6 +341,14 @@ const stringifySchema = (value: unknown): string | undefined => { · 333 unmodified lines | ||
| 340 | 341 | } |
| 341 | 342 | |
| 342 | 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 | 352 | return JSON.stringify(value); |
| 344 | 353 | } catch { |
| 345 | 354 | return undefined; |
| @@ -13,6 +13,7 @@ export { · 12 unmodified lines | ||
| 13 | 13 | type McpClientLike, |
| 14 | 14 | type McpConnection, |
| 15 | 15 | type McpConnector, |
| 16 | + type McpDiscoveryElicitationContext, | |
| 16 | 17 | type McpToolManifest, |
| 17 | 18 | type McpToolManifestEntry, |
| 18 | 19 | } from "./mcp-tools"; |
| @@ -1,12 +1,19 @@ | ||
| 1 | 1 | import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; |
| 2 | 2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 3 | 3 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; |
| 4 | +import { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types.js"; | |
| 4 | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | 6 | import { assertInstanceOf, assertTrue } from "@effect/vitest/utils"; |
| 7 | +import * as Either from "effect/Either"; | |
| 6 | 8 | import * as Effect from "effect/Effect"; |
| 7 | 9 | import { z } from "zod/v4"; |
| 8 | 10 | |
| 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"; | |
| 10 | 17 | |
| 11 | 18 | import { createSdkMcpConnector } from "./mcp-connection"; |
| 12 | 19 | import { |
| @@ -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 | 301 | assertInstanceOf(outcome.left, McpToolsError); |
| 295 | 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 | }); |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; |
| 2 | 2 | import * as Data from "effect/Data"; |
| 3 | +import * as Either from "effect/Either"; | |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| 4 | 5 | import * as PartitionedSemaphore from "effect/PartitionedSemaphore"; |
| 5 | 6 | |
| @@ -8,8 +9,10 @@ import { · 2 unmodified lines | ||
| 8 | 9 | toTool, |
| 9 | 10 | type ElicitationRequest, |
| 10 | 11 | type ElicitationResponse, |
| 12 | + type ToolInvocationContext, | |
| 11 | 13 | type ToolExecutionContext, |
| 12 | 14 | type ToolMap, |
| 15 | + type ToolMetadata, | |
| 13 | 16 | type ToolPath, |
| 14 | 17 | unknownInputSchema, |
| 15 | 18 | } from "@executor-v3/codemode-core"; |
| @@ -47,6 +50,15 @@ export type McpConnection = { · 31 unmodified lines | ||
| 47 | 50 | |
| 48 | 51 | export type McpConnector = () => Promise<McpConnection>; |
| 49 | 52 | |
| 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 | + | |
| 50 | 62 | type McpDiscoveryStage = "connect" | "list_tools" | "call_tool"; |
| 51 | 63 | |
| 52 | 64 | export class McpToolsError extends Data.TaggedError("McpToolsError")<{ |
| @@ -252,6 +264,79 @@ const callMcpTool = (input: { · 199 unmodified lines | ||
| 252 | 264 | catch: (cause) => cause, |
| 253 | 265 | }); |
| 254 | 266 | |
| 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 | + | |
| 255 | 340 | const runMcpToolCallEffect = (input: { |
| 256 | 341 | connection: McpConnection; |
| 257 | 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 | 373 | return attempt.right; |
| 289 | 374 | } |
| 290 | 375 | |
| @@ -385,6 +470,7 @@ export const discoverMcpToolsFromConnector = (input: { · 94 unmodified lines | ||
| 385 | 470 | connect: McpConnector; |
| 386 | 471 | namespace?: string; |
| 387 | 472 | sourceKey?: string; |
| 473 | + mcpDiscoveryElicitation?: McpDiscoveryElicitationContext; | |
| 388 | 474 | }): Effect.Effect<{ manifest: McpToolManifest; tools: ToolMap }, McpToolsError> => |
| 389 | 475 | Effect.gen(function* () { |
| 390 | 476 | const listed = yield* withConnectionEffect({ |
| @@ -395,16 +481,16 @@ export const discoverMcpToolsFromConnector = (input: { · 4 unmodified lines | ||
| 395 | 481 | message: "Failed connecting to MCP server", |
| 396 | 482 | details: toDetails(cause), |
| 397 | 483 | }), |
| 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 | + }, | |
| 408 | 494 | }); |
| 409 | 495 | |
| 410 | 496 | const manifest = extractMcpToolManifestFromListToolsResult(listed); |
| @@ -14,12 +14,12 @@ · 13 unmodified lines | ||
| 14 | 14 | "test:watch": "vitest" |
| 15 | 15 | }, |
| 16 | 16 | "dependencies": { |
| 17 | + "@effect/platform": "catalog:", | |
| 17 | 18 | "@executor-v3/codemode-core": "workspace:*", |
| 18 | 19 | "effect": "catalog:", |
| 19 | 20 | "yaml": "^2.8.1" |
| 20 | 21 | }, |
| 21 | 22 | "devDependencies": { |
| 22 | − "@effect/platform": "catalog:", | |
| 23 | 23 | "@effect/platform-node": "^0.104.1", |
| 24 | 24 | "@effect/vitest": "catalog:", |
| 25 | 25 | "@types/node": "catalog:", |
| @@ -1,5 +1,11 @@ | ||
| 1 | +import { | |
| 2 | + FetchHttpClient, | |
| 3 | + HttpClient, | |
| 4 | + HttpClientRequest, | |
| 5 | +} from "@effect/platform"; | |
| 1 | 6 | import * as Data from "effect/Data"; |
| 2 | 7 | import * as Effect from "effect/Effect"; |
| 8 | +import * as Layer from "effect/Layer"; | |
| 3 | 9 | |
| 4 | 10 | import { |
| 5 | 11 | standardSchemaFromJsonSchema, |
| @@ -195,13 +201,30 @@ const normalizeHttpUrl = (value: string): string => { · 189 unmodified lines | ||
| 195 | 201 | } |
| 196 | 202 | }; |
| 197 | 203 | |
| 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 | + | |
| 200 | 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 | |
| 207 | 230 | const inputSchemaFromTypingJson = (inputSchemaJson: string | undefined) => { |
| @@ -227,6 +250,28 @@ type CreateOpenApiToolsFromManifestInput = { · 19 unmodified lines | ||
| 227 | 250 | sourceKey?: string; |
| 228 | 251 | defaultHeaders?: Readonly<Record<string, string>>; |
| 229 | 252 | 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 | + } | |
| 230 | 275 | }; |
| 231 | 276 | |
| 232 | 277 | const buildFetchRequest = (input: { |
| @@ -237,16 +282,20 @@ const buildFetchRequest = (input: { · 4 unmodified lines | ||
| 237 | 282 | credentialHeaders: Readonly<Record<string, string>>; |
| 238 | 283 | }): { |
| 239 | 284 | url: URL; |
| 240 | − init: RequestInit; | |
| 285 | + method: string; | |
| 286 | + headers: Record<string, string>; | |
| 287 | + body?: string; | |
| 241 | 288 | } => { |
| 242 | 289 | const resolvedPath = replacePathTemplate( |
| 243 | 290 | input.payload.pathTemplate, |
| 244 | 291 | input.args, |
| 245 | 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 | 299 | const cookieParts: Array<string> = []; |
| 251 | 300 | |
| 252 | 301 | for (const parameter of input.payload.parameters) { |
| @@ -271,14 +320,14 @@ const buildFetchRequest = (input: { · 18 unmodified lines | ||
| 271 | 320 | if (parameter.location === "query") { |
| 272 | 321 | url.searchParams.set(parameter.name, encoded); |
| 273 | 322 | } else if (parameter.location === "header") { |
| 274 | − headers.set(parameter.name, encoded); | |
| 323 | + headers[parameter.name] = encoded; | |
| 275 | 324 | } else if (parameter.location === "cookie") { |
| 276 | 325 | cookieParts.push(`${parameter.name}=${encodeURIComponent(encoded)}`); |
| 277 | 326 | } |
| 278 | 327 | } |
| 279 | 328 | |
| 280 | 329 | if (cookieParts.length > 0) { |
| 281 | − headers.set("cookie", cookieParts.join("; ")); | |
| 330 | + headers.cookie = cookieParts.join("; "); | |
| 282 | 331 | } |
| 283 | 332 | |
| 284 | 333 | let body: string | undefined; |
| @@ -297,24 +346,22 @@ const buildFetchRequest = (input: { · 12 unmodified lines | ||
| 297 | 346 | |
| 298 | 347 | const preferredContentType = input.payload.requestBody.contentTypes[0]; |
| 299 | 348 | 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"; | |
| 303 | 352 | } |
| 304 | 353 | } |
| 305 | 354 | } |
| 306 | 355 | |
| 307 | 356 | for (const [key, value] of Object.entries(input.credentialHeaders)) { |
| 308 | − headers.set(key, value); | |
| 357 | + headers[key] = value; | |
| 309 | 358 | } |
| 310 | 359 | |
| 311 | 360 | return { |
| 312 | 361 | url, |
| 313 | − init: { | |
| 314 | − method: input.payload.method.toUpperCase(), | |
| 315 | − headers, | |
| 316 | − body, | |
| 317 | − }, | |
| 362 | + method: input.payload.method.toUpperCase(), | |
| 363 | + headers, | |
| 364 | + body, | |
| 318 | 365 | }; |
| 319 | 366 | }; |
| 320 | 367 | |
| @@ -326,6 +373,7 @@ export const createOpenApiToolsFromManifest = ( · 5 unmodified lines | ||
| 326 | 373 | const namespace = input.namespace; |
| 327 | 374 | const defaultHeaders = input.defaultHeaders ?? {}; |
| 328 | 375 | const credentialHeaders = input.credentialHeaders ?? {}; |
| 376 | + const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer; | |
| 329 | 377 | |
| 330 | 378 | const result: ToolMap = {}; |
| 331 | 379 | |
| @@ -349,14 +397,39 @@ export const createOpenApiToolsFromManifest = ( · 17 unmodified lines | ||
| 349 | 397 | credentialHeaders, |
| 350 | 398 | }); |
| 351 | 399 | |
| 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 | + ); | |
| 360 | 433 | }, |
| 361 | 434 | }, |
| 362 | 435 | metadata: { |
| @@ -379,6 +452,7 @@ export const createOpenApiToolsFromSpec = (input: { · 16 unmodified lines | ||
| 379 | 452 | sourceKey?: string; |
| 380 | 453 | defaultHeaders?: Readonly<Record<string, string>>; |
| 381 | 454 | credentialHeaders?: Readonly<Record<string, string>>; |
| 455 | + httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; | |
| 382 | 456 | }): Effect.Effect< |
| 383 | 457 | { manifest: OpenApiToolManifest; tools: ToolMap }, |
| 384 | 458 | OpenApiExtractionError |
| @@ -394,5 +468,6 @@ export const createOpenApiToolsFromSpec = (input: { · 9 unmodified lines | ||
| 394 | 468 | sourceKey: input.sourceKey, |
| 395 | 469 | defaultHeaders: input.defaultHeaders, |
| 396 | 470 | credentialHeaders: input.credentialHeaders, |
| 471 | + httpClientLayer: input.httpClientLayer, | |
| 397 | 472 | }), |
| 398 | 473 | })); |
| @@ -1,5 +1,8 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | type ElicitationResponse, |
| 3 | + type OnElicitation, | |
| 4 | + type ToolInvocationContext, | |
| 5 | + type ToolMetadata, | |
| 3 | 6 | toTool, |
| 4 | 7 | type ToolMap, |
| 5 | 8 | type ToolPath, |
| @@ -7,23 +10,104 @@ import { · 1 unmodified line | ||
| 7 | 10 | import { |
| 8 | 11 | ExecutionIdSchema, |
| 9 | 12 | ExecutionInteractionIdSchema, |
| 13 | + SourceSchema, | |
| 10 | 14 | type Source, |
| 11 | 15 | type WorkspaceId, |
| 12 | 16 | } from "#schema"; |
| 13 | 17 | import * as Effect from "effect/Effect"; |
| 14 | 18 | import * as Schema from "effect/Schema"; |
| 15 | 19 | |
| 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"; | |
| 17 | 28 | |
| 18 | −const ExecutorSourcesAddInputSchema = Schema.standardSchemaV1( | |
| 29 | +const ExecutorOpenApiSourceAuthInputSchema = Schema.Union( | |
| 19 | 30 | 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"), | |
| 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 | + | |
| 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) ?? {}, | |
| 24 | 91 | ); |
| 25 | 92 | |
| 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"); | |
| 27 | 111 | |
| 28 | 112 | const toExecutionId = (value: unknown) => { |
| 29 | 113 | if (typeof value !== "string" || value.trim().length === 0) { |
| @@ -35,36 +119,180 @@ const toExecutionId = (value: unknown) => { · 5 unmodified lines | ||
| 35 | 119 | |
| 36 | 120 | const asToolPath = (value: string): ToolPath => value as ToolPath; |
| 37 | 121 | |
| 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 | + | |
| 38 | 196 | export const createExecutorToolMap = (input: { |
| 39 | 197 | workspaceId: WorkspaceId; |
| 40 | 198 | sourceAuthService: RuntimeSourceAuthService; |
| 41 | 199 | }): ToolMap => ({ |
| 42 | 200 | "executor.sources.add": toTool({ |
| 43 | 201 | tool: { |
| 44 | − description: "Add an MCP source to the current workspace, starting OAuth if the source requires it", | |
| 202 | + description: buildExecutorSourcesAddDescription(), | |
| 45 | 203 | inputSchema: ExecutorSourcesAddInputSchema, |
| 46 | 204 | outputSchema: ExecutorSourcesAddOutputSchema, |
| 47 | 205 | 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 | + }, | |
| 53 | 232 | context, |
| 54 | 233 | ): Promise<Source> => { |
| 55 | 234 | const executionId = toExecutionId(context?.invocation?.runId); |
| 56 | 235 | const interactionId = ExecutionInteractionIdSchema.make( |
| 57 | 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 | 280 | 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 | + ), | |
| 68 | 296 | ); |
| 69 | 297 | |
| 70 | 298 | if (result.kind === "connected") { |
| @@ -80,7 +308,7 @@ export const createExecutorToolMap = (input: { · 9 unmodified lines | ||
| 80 | 308 | interactionId, |
| 81 | 309 | path: context.path ?? asToolPath("executor.sources.add"), |
| 82 | 310 | sourceKey: context.sourceKey, |
| 83 | − args, | |
| 311 | + args: preparedArgs, | |
| 84 | 312 | metadata: context.metadata, |
| 85 | 313 | context: context.invocation, |
| 86 | 314 | elicitation: { |
| @@ -104,9 +332,13 @@ export const createExecutorToolMap = (input: { · 17 unmodified lines | ||
| 104 | 332 | ); |
| 105 | 333 | }, |
| 106 | 334 | }, |
| 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 | + }), | |
| 112 | 344 | }); |
| @@ -50,8 +50,10 @@ export { · 49 unmodified lines | ||
| 50 | 50 | }; |
| 51 | 51 | |
| 52 | 52 | export * from "./execution-state"; |
| 53 | +export * from "./executor-tools"; | |
| 53 | 54 | export * from "./live-execution"; |
| 54 | 55 | export * from "./local-installation"; |
| 56 | +export * from "./schema-type-signature"; | |
| 55 | 57 | export * from "./source-auth-service"; |
| 56 | 58 | export * from "./store"; |
| 57 | 59 | export * from "./workspace-execution-environment"; |
| @@ -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 | +}; | |
| @@ -10,6 +10,7 @@ import type { · 9 unmodified lines | ||
| 10 | 10 | import { |
| 11 | 11 | createSdkMcpConnector, |
| 12 | 12 | discoverMcpToolsFromConnector, |
| 13 | + type McpDiscoveryElicitationContext, | |
| 13 | 14 | } from "@executor-v3/codemode-mcp"; |
| 14 | 15 | import { |
| 15 | 16 | SqlControlPlaneRowsService, |
| @@ -173,7 +174,10 @@ const persistSource = (rows: SqlControlPlaneRows, source: Source) => · 157 unmodified lines | ||
| 173 | 174 | return source; |
| 174 | 175 | }); |
| 175 | 176 | |
| 176 | −const probeMcpSourceWithoutAuth = (source: Source) => | |
| 177 | +const probeMcpSourceWithoutAuth = ( | |
| 178 | + source: Source, | |
| 179 | + mcpDiscoveryElicitation?: McpDiscoveryElicitationContext, | |
| 180 | +) => | |
| 177 | 181 | Effect.gen(function* () { |
| 178 | 182 | if (source.kind !== "mcp") { |
| 179 | 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 | 199 | connect: connector, |
| 196 | 200 | namespace: source.namespace ?? defaultNamespaceFromName(source.name), |
| 197 | 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 | |
| 430 | 435 | const upsertSecretMaterial = (rows: SqlControlPlaneRows, input: { |
| 431 | − purpose: "oauth_access_token" | "oauth_refresh_token"; | |
| 436 | + purpose: "auth_material" | "oauth_access_token" | "oauth_refresh_token"; | |
| 432 | 437 | value: string; |
| 433 | 438 | }) => |
| 434 | 439 | Effect.gen(function* () { |
| @@ -457,19 +462,98 @@ export type ExecutorSourceAddResult = · 22 unmodified lines | ||
| 457 | 462 | authorizationUrl: string; |
| 458 | 463 | }; |
| 459 | 464 | |
| 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 | + | |
| 460 | 546 | type RuntimeSourceAuthServiceShape = { |
| 461 | 547 | getSourceById: (input: { |
| 462 | 548 | workspaceId: WorkspaceId; |
| 463 | 549 | sourceId: Source["id"]; |
| 464 | 550 | }) => 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>; | |
| 473 | 557 | completeSourceAuthCallback: (input: { |
| 474 | 558 | state: string; |
| 475 | 559 | code?: string | null; |
| @@ -524,166 +608,261 @@ export const createRuntimeSourceAuthService = (input: { · 48 unmodified lines | ||
| 524 | 608 | sourceId, |
| 525 | 609 | }), |
| 526 | 610 | |
| 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 | + ); | |
| 541 | 626 | |
| 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 | + ); | |
| 551 | 685 | |
| 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 | + ), | |
| 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); | |
| 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 | + }); | |
| 590 | 766 | |
| 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 | + ); | |
| 594 | 773 | |
| 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 | + }); | |
| 609 | 805 | }), |
| 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 | + }); | |
| 632 | 807 | |
| 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 | + } | |
| 639 | 811 | |
| 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 | + }); | |
| 648 | 827 | |
| 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 | + }); | |
| 653 | 832 | |
| 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 | + }); | |
| 679 | 858 | |
| 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 | + }), | |
| 687 | 866 | |
| 688 | 867 | completeSourceAuthCallback: ({ state, code, error, errorDescription }) => |
| 689 | 868 | Effect.gen(function* () { |
| @@ -1,3 +1,8 @@ | ||
| 1 | +import { | |
| 2 | + FetchHttpClient, | |
| 3 | + HttpClient, | |
| 4 | + HttpClientRequest, | |
| 5 | +} from "@effect/platform"; | |
| 1 | 6 | import { |
| 2 | 7 | createSdkMcpConnector, |
| 3 | 8 | discoverMcpToolsFromConnector, |
| @@ -5,7 +10,6 @@ import { · 1 unmodified line | ||
| 5 | 10 | } from "@executor-v3/codemode-mcp"; |
| 6 | 11 | import { |
| 7 | 12 | extractOpenApiManifest, |
| 8 | − fetchOpenApiDocument, | |
| 9 | 13 | type OpenApiExtractedTool, |
| 10 | 14 | } from "@executor-v3/codemode-openapi"; |
| 11 | 15 | import type { SqlControlPlaneRows } from "#persistence"; |
| @@ -224,6 +228,32 @@ export const resolveSourceAuthMaterial = (input: { · 212 unmodified lines | ||
| 224 | 228 | } satisfies ResolvedSourceAuthMaterial; |
| 225 | 229 | }); |
| 226 | 230 | |
| 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 | + | |
| 227 | 257 | const indexMcpSourceToolArtifacts = (input: { |
| 228 | 258 | rows: SqlControlPlaneRows; |
| 229 | 259 | source: Source; |
| @@ -293,6 +323,7 @@ const discoverAndIndexMcpSourceToolArtifacts = (input: { · 63 unmodified lines | ||
| 293 | 323 | const indexOpenApiSourceToolArtifacts = (input: { |
| 294 | 324 | rows: SqlControlPlaneRows; |
| 295 | 325 | source: Source; |
| 326 | + auth: ResolvedSourceAuthMaterial; | |
| 296 | 327 | }): Effect.Effect<void, Error, never> => |
| 297 | 328 | Effect.gen(function* () { |
| 298 | 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({ | |
| 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 | + ); | |
| 313 | 348 | |
| 314 | 349 | const manifest = yield* extractOpenApiManifest( |
| 315 | 350 | input.source.name, |
| @@ -376,6 +411,7 @@ export const syncSourceToolArtifacts = (input: { · 60 unmodified lines | ||
| 376 | 411 | return yield* indexOpenApiSourceToolArtifacts({ |
| 377 | 412 | rows: input.rows, |
| 378 | 413 | source: input.source, |
| 414 | + auth, | |
| 379 | 415 | }); |
| 380 | 416 | } |
| 381 | 417 | |
| @@ -1,5 +1,14 @@ | ||
| 1 | 1 | import { randomUUID } from "node:crypto"; |
| 2 | 2 | |
| 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"; | |
| 3 | 12 | import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; |
| 4 | 13 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 5 | 14 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; |
| @@ -10,7 +19,12 @@ import { · 4 unmodified lines | ||
| 10 | 19 | SourceIdSchema, |
| 11 | 20 | WorkspaceIdSchema, |
| 12 | 21 | } from "#schema"; |
| 22 | +import * as Context from "effect/Context"; | |
| 13 | 23 | 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"; | |
| 14 | 28 | import { z } from "zod/v4"; |
| 15 | 29 | |
| 16 | 30 | import { |
| @@ -19,7 +33,11 @@ import { · 2 unmodified lines | ||
| 19 | 33 | } from "#persistence"; |
| 20 | 34 | import type { SourceId, WorkspaceId } from "#schema"; |
| 21 | 35 | |
| 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"; | |
| 23 | 41 | import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment"; |
| 24 | 42 | |
| 25 | 43 | type CountedMcpServer = { |
| @@ -31,6 +49,16 @@ type CountedMcpServer = { · 5 unmodified lines | ||
| 31 | 49 | close: () => Promise<void>; |
| 32 | 50 | }; |
| 33 | 51 | |
| 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 | + | |
| 34 | 62 | const makePersistence = Effect.acquireRelease( |
| 35 | 63 | createSqlControlPlanePersistence({ |
| 36 | 64 | localDataDir: ":memory:", |
| @@ -249,6 +277,73 @@ const makeCountedMcpServer = Effect.acquireRelease( · 212 unmodified lines | ||
| 249 | 277 | }).pipe(Effect.orDie), |
| 250 | 278 | ); |
| 251 | 279 | |
| 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 | + | |
| 252 | 347 | const persistConnectedEchoTool = (input: { |
| 253 | 348 | persistence: SqlControlPlanePersistence; |
| 254 | 349 | endpoint: string; |
| @@ -323,11 +418,20 @@ const makeResolver = (persistence: SqlControlPlanePersistence) => · 68 unmodified lines | ||
| 323 | 418 | rows: persistence.rows, |
| 324 | 419 | sourceAuthService: { |
| 325 | 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 | 422 | completeSourceAuthCallback: () => Effect.fail(new Error("not implemented in test")), |
| 328 | 423 | } as RuntimeSourceAuthService, |
| 329 | 424 | }); |
| 330 | 425 | |
| 426 | +const makeResolverWithLiveSourceAuth = (persistence: SqlControlPlanePersistence) => | |
| 427 | + createWorkspaceExecutionEnvironmentResolver({ | |
| 428 | + rows: persistence.rows, | |
| 429 | + sourceAuthService: createRuntimeSourceAuthService({ | |
| 430 | + rows: persistence.rows, | |
| 431 | + liveExecutionManager: createLiveExecutionManager(), | |
| 432 | + }), | |
| 433 | + }); | |
| 434 | + | |
| 331 | 435 | describe("workspace-execution-environment", () => { |
| 332 | 436 | it.scoped("discovers persisted tools without live MCP listing", () => |
| 333 | 437 | Effect.gen(function* () { |
| @@ -407,4 +511,159 @@ describe("workspace-execution-environment", () => { · 73 unmodified lines | ||
| 407 | 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 | }); |