Unified · Split
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | NodeRuntime, | 9 | NodeRuntime, |
| 10 | } from "@effect/platform-node"; | 10 | } from "@effect/platform-node"; |
| 11 | import { | 11 | import { |
| 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 | ||
| 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 | const promptInteraction = (interaction: ExecutionInteraction) => | 567 | const 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) { |
| @@ -1,6 +1,7 @@ | |||
| 1 | import type { StandardSchemaV1 } from "@standard-schema/spec"; | 1 | import type { StandardSchemaV1 } from "@standard-schema/spec"; |
| 2 | import * as Data from "effect/Data"; | 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as JSONSchema from "effect/JSONSchema"; | ||
| 4 | 5 | ||
| 5 | import type { | 6 | import 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; |
| @@ -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"; |
| @@ -1,12 +1,19 @@ | |||
| 1 | import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; | 1 | import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; |
| 2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | 2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 3 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; | 3 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; |
| 4 | import { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types.js"; | ||
| 4 | import { describe, expect, it } from "@effect/vitest"; | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | import { assertInstanceOf, assertTrue } from "@effect/vitest/utils"; | 6 | import { assertInstanceOf, assertTrue } from "@effect/vitest/utils"; |
| 7 | import * as Either from "effect/Either"; | ||
| 6 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 7 | import { z } from "zod/v4"; | 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 | import { createSdkMcpConnector } from "./mcp-connection"; | 18 | import { createSdkMcpConnector } from "./mcp-connection"; |
| 12 | import { | 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 | 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 | }); |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; | 1 | import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; |
| 2 | import * as Data from "effect/Data"; | 2 | import * as Data from "effect/Data"; |
| 3 | import * as Either from "effect/Either"; | ||
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 4 | import * as PartitionedSemaphore from "effect/PartitionedSemaphore"; | 5 | import * 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 | ||
| 48 | export type McpConnector = () => Promise<McpConnection>; | 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 | type McpDiscoveryStage = "connect" | "list_tools" | "call_tool"; | 62 | type McpDiscoveryStage = "connect" | "list_tools" | "call_tool"; |
| 51 | 63 | ||
| 52 | export class McpToolsError extends Data.TaggedError("McpToolsError")<{ | 64 | export 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 | ||
| 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 | const runMcpToolCallEffect = (input: { | 340 | const 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); |
| @@ -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:", |
| @@ -1,5 +1,11 @@ | |||
| 1 | import { | ||
| 2 | FetchHttpClient, | ||
| 3 | HttpClient, | ||
| 4 | HttpClientRequest, | ||
| 5 | } from "@effect/platform"; | ||
| 1 | import * as Data from "effect/Data"; | 6 | import * as Data from "effect/Data"; |
| 2 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Layer from "effect/Layer"; | ||
| 3 | 9 | ||
| 4 | import { | 10 | import { |
| 5 | standardSchemaFromJsonSchema, | 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> => { | 204 | const 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 | ||
| 207 | const inputSchemaFromTypingJson = (inputSchemaJson: string | undefined) => { | 230 | const 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 | |||
| 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 | const buildFetchRequest = (input: { | 277 | const 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 | OpenApiExtractionError | 458 | 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 | })); |
| @@ -1,5 +1,8 @@ | |||
| 1 | import { | 1 | import { |
| 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 | |||
| 7 | import { | 10 | import { |
| 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"; |
| 13 | import * as Effect from "effect/Effect"; | 17 | import * as Effect from "effect/Effect"; |
| 14 | import * as Schema from "effect/Schema"; | 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 | 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 | |||
| 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 | const toExecutionId = (value: unknown) => { | 112 | const 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 | ||
| 36 | const asToolPath = (value: string): ToolPath => value as ToolPath; | 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 | export const createExecutorToolMap = (input: { | 196 | export 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 | }); |
| @@ -50,8 +50,10 @@ export { · 49 unmodified lines | |||
| 50 | }; | 50 | }; |
| 51 | 51 | ||
| 52 | export * from "./execution-state"; | 52 | export * from "./execution-state"; |
| 53 | export * from "./executor-tools"; | ||
| 53 | export * from "./live-execution"; | 54 | export * from "./live-execution"; |
| 54 | export * from "./local-installation"; | 55 | export * from "./local-installation"; |
| 56 | export * from "./schema-type-signature"; | ||
| 55 | export * from "./source-auth-service"; | 57 | export * from "./source-auth-service"; |
| 56 | export * from "./store"; | 58 | export * from "./store"; |
| 57 | export * from "./workspace-execution-environment"; | 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 | import { | 10 | import { |
| 11 | createSdkMcpConnector, | 11 | createSdkMcpConnector, |
| 12 | discoverMcpToolsFromConnector, | 12 | discoverMcpToolsFromConnector, |
| 13 | type McpDiscoveryElicitationContext, | ||
| 13 | } from "@executor-v3/codemode-mcp"; | 14 | } from "@executor-v3/codemode-mcp"; |
| 14 | import { | 15 | import { |
| 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 | ||
| 176 | const probeMcpSourceWithoutAuth = (source: Source) => | 177 | const 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 | ||
| 430 | const upsertSecretMaterial = (rows: SqlControlPlaneRows, input: { | 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 | 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 | ||
| 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 | type RuntimeSourceAuthServiceShape = { | 546 | type 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?.name | 629 | ?? existing?.name |
| 545 | ?? defaultSourceNameFromEndpoint(normalizedEndpoint); | 630 | ?? defaultSourceNameFromEndpoint(normalizedEndpoint); |
| 546 | const chosenNamespace = | 631 | const chosenNamespace = |
| 547 | trimOrNull(namespace) | 632 | trimOrNull(sourceInput.namespace) |
| 548 | ?? existing?.namespace | 633 | ?? 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 = existing | 686 | 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* () { |
| @@ -1,3 +1,8 @@ | |||
| 1 | import { | ||
| 2 | FetchHttpClient, | ||
| 3 | HttpClient, | ||
| 4 | HttpClientRequest, | ||
| 5 | } from "@effect/platform"; | ||
| 1 | import { | 6 | import { |
| 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"; |
| 6 | import { | 11 | import { |
| 7 | extractOpenApiManifest, | 12 | extractOpenApiManifest, |
| 8 | fetchOpenApiDocument, | ||
| 9 | type OpenApiExtractedTool, | 13 | type OpenApiExtractedTool, |
| 10 | } from "@executor-v3/codemode-openapi"; | 14 | } from "@executor-v3/codemode-openapi"; |
| 11 | import type { SqlControlPlaneRows } from "#persistence"; | 15 | import 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 | ||
| 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 | const indexMcpSourceToolArtifacts = (input: { | 257 | const indexMcpSourceToolArtifacts = (input: { |
| 228 | rows: SqlControlPlaneRows; | 258 | rows: SqlControlPlaneRows; |
| 229 | source: Source; | 259 | source: Source; |
| @@ -293,6 +323,7 @@ const discoverAndIndexMcpSourceToolArtifacts = (input: { · 63 unmodified lines | |||
| 293 | const indexOpenApiSourceToolArtifacts = (input: { | 323 | const 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 Error | 338 | ...(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 | ||
| @@ -1,5 +1,14 @@ | |||
| 1 | import { randomUUID } from "node:crypto"; | 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 | import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; | 12 | import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; |
| 4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | 13 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 5 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; | 14 | import { 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"; |
| 22 | import * as Context from "effect/Context"; | ||
| 13 | import * as Effect from "effect/Effect"; | 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 | import { z } from "zod/v4"; | 28 | import { z } from "zod/v4"; |
| 15 | 29 | ||
| 16 | import { | 30 | import { |
| @@ -19,7 +33,11 @@ import { · 2 unmodified lines | |||
| 19 | } from "#persistence"; | 33 | } from "#persistence"; |
| 20 | import type { SourceId, WorkspaceId } from "#schema"; | 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 | import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment"; | 41 | import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment"; |
| 24 | 42 | ||
| 25 | type CountedMcpServer = { | 43 | type CountedMcpServer = { |
| @@ -31,6 +49,16 @@ type CountedMcpServer = { · 5 unmodified lines | |||
| 31 | close: () => Promise<void>; | 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 | const makePersistence = Effect.acquireRelease( | 62 | const 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 | ||
| 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 | const persistConnectedEchoTool = (input: { | 347 | const 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 | ||
| 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 | describe("workspace-execution-environment", () => { | 435 | describe("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 | }); |