Add an OpenAPI provider and a minimal local JS runner that dispatches tools through the provider registry, plus an integration test that processes an OpenAPI spec and executes code against the resulting tool descriptor. Update engine package exports/docs/scripts to support this flow.
Unified · Split
| @@ -62,9 +62,13 @@ · 61 unmodified lines | |||
| 62 | "packages/engine": { | 62 | "packages/engine": { |
| 63 | "name": "@executor-v2/engine", | 63 | "name": "@executor-v2/engine", |
| 64 | "dependencies": { | 64 | "dependencies": { |
| 65 | "@executor-v2/persistence-ports": "workspace:*", | ||
| 65 | "@executor-v2/schema": "workspace:*", | 66 | "@executor-v2/schema": "workspace:*", |
| 66 | "effect": "^3.19.8", | 67 | "effect": "^3.19.8", |
| 67 | }, | 68 | }, |
| 69 | "devDependencies": { | ||
| 70 | "@executor-v2/source-manager": "workspace:*", | ||
| 71 | }, | ||
| 68 | }, | 72 | }, |
| 69 | "packages/mcp-gateway": { | 73 | "packages/mcp-gateway": { |
| 70 | "name": "@executor-v2/mcp-gateway", | 74 | "name": "@executor-v2/mcp-gateway", |
| @@ -5,4 +5,6 @@ Runtime execution engine package for Executor v2. · 4 unmodified lines | |||
| 5 | Current scaffold includes: | 5 | Current scaffold includes: |
| 6 | - provider contracts (`ToolProvider`) and canonical tool descriptor model | 6 | - provider contracts (`ToolProvider`) and canonical tool descriptor model |
| 7 | - provider registry service (`ToolProviderRegistryService`) with `discover`/`invoke` routing | 7 | - provider registry service (`ToolProviderRegistryService`) with `discover`/`invoke` routing |
| 8 | - live layer constructor for registry wiring | 8 | - OpenAPI provider invocation and manifest-to-descriptor conversion helpers |
| 9 | - minimal local JavaScript runner with `tools.*` proxy dispatch into provider registry | ||
| 10 | - vertical integration test covering OpenAPI extraction -> descriptor conversion -> code execution -> HTTP call | ||
| @@ -6,10 +6,15 @@ · 5 unmodified lines | |||
| 6 | ".": "./src/index.ts" | 6 | ".": "./src/index.ts" |
| 7 | }, | 7 | }, |
| 8 | "scripts": { | 8 | "scripts": { |
| 9 | "test": "bun test src/*.test.ts", | ||
| 9 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | 10 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| 10 | }, | 11 | }, |
| 11 | "dependencies": { | 12 | "dependencies": { |
| 13 | "@executor-v2/persistence-ports": "workspace:*", | ||
| 12 | "@executor-v2/schema": "workspace:*", | 14 | "@executor-v2/schema": "workspace:*", |
| 13 | "effect": "^3.19.8" | 15 | "effect": "^3.19.8" |
| 16 | }, | ||
| 17 | "devDependencies": { | ||
| 18 | "@executor-v2/source-manager": "workspace:*" | ||
| 14 | } | 19 | } |
| 15 | } | 20 | } |
| @@ -1,3 +1,13 @@ | |||
| 1 | export { | ||
| 2 | executeJavaScriptWithTools, | ||
| 3 | LocalCodeRunnerError, | ||
| 4 | type ExecuteJavaScriptInput, | ||
| 5 | type RunnableTool, | ||
| 6 | } from "./local-runner"; | ||
| 7 | export { | ||
| 8 | makeOpenApiToolProvider, | ||
| 9 | openApiToolDescriptorsFromManifest, | ||
| 10 | } from "./openapi-provider"; | ||
| 1 | export { | 11 | export { |
| 2 | ToolProviderRegistryLive, | 12 | ToolProviderRegistryLive, |
| 3 | ToolProviderRegistryError, | 13 | ToolProviderRegistryError, |
| @@ -0,0 +1,120 @@ | |||
| 1 | import type { Source } from "@executor-v2/schema"; | ||
| 2 | import * as Data from "effect/Data"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Runtime from "effect/Runtime"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | ToolProviderRegistryError, | ||
| 8 | ToolProviderRegistryService, | ||
| 9 | type CanonicalToolDescriptor, | ||
| 10 | type ToolProviderError, | ||
| 11 | } from "./tool-providers"; | ||
| 12 | |||
| 13 | export class LocalCodeRunnerError extends Data.TaggedError("LocalCodeRunnerError")<{ | ||
| 14 | operation: string; | ||
| 15 | message: string; | ||
| 16 | details: string | null; | ||
| 17 | }> {} | ||
| 18 | |||
| 19 | export type RunnableTool = { | ||
| 20 | descriptor: CanonicalToolDescriptor; | ||
| 21 | source: Source | null; | ||
| 22 | }; | ||
| 23 | |||
| 24 | export type ExecuteJavaScriptInput = { | ||
| 25 | code: string; | ||
| 26 | tools: ReadonlyArray<RunnableTool>; | ||
| 27 | }; | ||
| 28 | |||
| 29 | const duplicateToolIdError = (toolId: string): LocalCodeRunnerError => | ||
| 30 | new LocalCodeRunnerError({ | ||
| 31 | operation: "build_tools", | ||
| 32 | message: `Duplicate tool id in run context: ${toolId}`, | ||
| 33 | details: null, | ||
| 34 | }); | ||
| 35 | |||
| 36 | const toolCallFailedError = (toolId: string): LocalCodeRunnerError => | ||
| 37 | new LocalCodeRunnerError({ | ||
| 38 | operation: "invoke_tool", | ||
| 39 | message: `Tool call returned error: ${toolId}`, | ||
| 40 | details: null, | ||
| 41 | }); | ||
| 42 | |||
| 43 | const toExecutionError = (cause: unknown): LocalCodeRunnerError => | ||
| 44 | new LocalCodeRunnerError({ | ||
| 45 | operation: "execute", | ||
| 46 | message: "JavaScript execution failed", | ||
| 47 | details: cause instanceof Error ? cause.stack ?? cause.message : String(cause), | ||
| 48 | }); | ||
| 49 | |||
| 50 | const buildToolBindings = ( | ||
| 51 | tools: ReadonlyArray<RunnableTool>, | ||
| 52 | ): Effect.Effect<Map<string, RunnableTool>, LocalCodeRunnerError> => | ||
| 53 | Effect.gen(function* () { | ||
| 54 | const byToolId = new Map<string, RunnableTool>(); | ||
| 55 | |||
| 56 | for (const tool of tools) { | ||
| 57 | const toolId = tool.descriptor.toolId; | ||
| 58 | if (byToolId.has(toolId)) { | ||
| 59 | return yield* duplicateToolIdError(toolId); | ||
| 60 | } | ||
| 61 | byToolId.set(toolId, tool); | ||
| 62 | } | ||
| 63 | |||
| 64 | return byToolId; | ||
| 65 | }); | ||
| 66 | |||
| 67 | const runJavaScript = ( | ||
| 68 | code: string, | ||
| 69 | toolsObject: Record<string, (args: unknown) => Promise<unknown>>, | ||
| 70 | ): Effect.Effect<unknown, LocalCodeRunnerError> => | ||
| 71 | Effect.tryPromise({ | ||
| 72 | try: async () => { | ||
| 73 | const execute = new Function( | ||
| 74 | "tools", | ||
| 75 | `"use strict"; return (async () => {\n${code}\n})();`, | ||
| 76 | ) as (tools: Record<string, (args: unknown) => Promise<unknown>>) => | ||
| 77 | Promise<unknown>; | ||
| 78 | |||
| 79 | return await execute(toolsObject); | ||
| 80 | }, | ||
| 81 | catch: toExecutionError, | ||
| 82 | }); | ||
| 83 | |||
| 84 | export const executeJavaScriptWithTools = ( | ||
| 85 | input: ExecuteJavaScriptInput, | ||
| 86 | ): Effect.Effect< | ||
| 87 | unknown, | ||
| 88 | LocalCodeRunnerError | ToolProviderRegistryError | ToolProviderError, | ||
| 89 | ToolProviderRegistryService | ||
| 90 | > => | ||
| 91 | Effect.gen(function* () { | ||
| 92 | const registry = yield* ToolProviderRegistryService; | ||
| 93 | const runtime = yield* Effect.runtime<never>(); | ||
| 94 | const runPromise = Runtime.runPromise(runtime); | ||
| 95 | const toolBindings = yield* buildToolBindings(input.tools); | ||
| 96 | |||
| 97 | const toolsObject: Record<string, (args: unknown) => Promise<unknown>> = | ||
| 98 | Object.create(null); | ||
| 99 | |||
| 100 | for (const [toolId, binding] of toolBindings.entries()) { | ||
| 101 | toolsObject[toolId] = (args: unknown) => | ||
| 102 | runPromise( | ||
| 103 | registry | ||
| 104 | .invoke({ | ||
| 105 | source: binding.source, | ||
| 106 | tool: binding.descriptor, | ||
| 107 | args, | ||
| 108 | }) | ||
| 109 | .pipe( | ||
| 110 | Effect.flatMap((result) => | ||
| 111 | result.isError | ||
| 112 | ? toolCallFailedError(toolId) | ||
| 113 | : Effect.succeed(result.output), | ||
| 114 | ), | ||
| 115 | ), | ||
| 116 | ); | ||
| 117 | } | ||
| 118 | |||
| 119 | return yield* runJavaScript(input.code, toolsObject); | ||
| 120 | }); | ||
| @@ -0,0 +1,192 @@ | |||
| 1 | import { | ||
| 2 | type ToolArtifactStore, | ||
| 3 | } from "@executor-v2/persistence-ports"; | ||
| 4 | import { type Source, SourceSchema, type ToolArtifact } from "@executor-v2/schema"; | ||
| 5 | import { makeSourceManagerService } from "@executor-v2/source-manager"; | ||
| 6 | import { describe, expect, test } from "bun:test"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | |||
| 11 | import { executeJavaScriptWithTools } from "./local-runner"; | ||
| 12 | import { | ||
| 13 | makeOpenApiToolProvider, | ||
| 14 | openApiToolDescriptorsFromManifest, | ||
| 15 | } from "./openapi-provider"; | ||
| 16 | import { | ||
| 17 | makeToolProviderRegistry, | ||
| 18 | ToolProviderRegistryService, | ||
| 19 | } from "./tool-providers"; | ||
| 20 | |||
| 21 | const decodeSource = Schema.decodeUnknownSync(SourceSchema); | ||
| 22 | |||
| 23 | type TestServer = { | ||
| 24 | baseUrl: string; | ||
| 25 | requests: Array<{ | ||
| 26 | path: string; | ||
| 27 | query: string; | ||
| 28 | apiKey: string | null; | ||
| 29 | }>; | ||
| 30 | server: Bun.Server<unknown>; | ||
| 31 | }; | ||
| 32 | |||
| 33 | const makeTestServer = Effect.acquireRelease( | ||
| 34 | Effect.sync<TestServer>(() => { | ||
| 35 | const requests: TestServer["requests"] = []; | ||
| 36 | |||
| 37 | const server = Bun.serve({ | ||
| 38 | port: 0, | ||
| 39 | fetch: async (request) => { | ||
| 40 | const url = new URL(request.url); | ||
| 41 | |||
| 42 | if (url.pathname === "/users/u123") { | ||
| 43 | requests.push({ | ||
| 44 | path: url.pathname, | ||
| 45 | query: url.search, | ||
| 46 | apiKey: request.headers.get("x-api-key"), | ||
| 47 | }); | ||
| 48 | |||
| 49 | return Response.json({ | ||
| 50 | id: "u123", | ||
| 51 | verbose: url.searchParams.get("verbose") === "true", | ||
| 52 | apiKey: request.headers.get("x-api-key"), | ||
| 53 | }); | ||
| 54 | } | ||
| 55 | |||
| 56 | return Response.json({ error: "not found" }, { status: 404 }); | ||
| 57 | }, | ||
| 58 | }); | ||
| 59 | |||
| 60 | return { | ||
| 61 | baseUrl: `http://127.0.0.1:${server.port}`, | ||
| 62 | requests, | ||
| 63 | server, | ||
| 64 | }; | ||
| 65 | }), | ||
| 66 | ({ server }) => | ||
| 67 | Effect.sync(() => { | ||
| 68 | server.stop(true); | ||
| 69 | }), | ||
| 70 | ); | ||
| 71 | |||
| 72 | describe("OpenAPI execution vertical slice", () => { | ||
| 73 | test("extracts OpenAPI tools and executes code against provider", async () => { | ||
| 74 | const program = Effect.gen(function* () { | ||
| 75 | const testServer = yield* makeTestServer; | ||
| 76 | |||
| 77 | const openApiSpec = { | ||
| 78 | openapi: "3.1.0", | ||
| 79 | paths: { | ||
| 80 | "/users/{userId}": { | ||
| 81 | get: { | ||
| 82 | operationId: "getUser", | ||
| 83 | parameters: [ | ||
| 84 | { | ||
| 85 | name: "userId", | ||
| 86 | in: "path", | ||
| 87 | required: true, | ||
| 88 | }, | ||
| 89 | { | ||
| 90 | name: "verbose", | ||
| 91 | in: "query", | ||
| 92 | }, | ||
| 93 | { | ||
| 94 | name: "x-api-key", | ||
| 95 | in: "header", | ||
| 96 | required: true, | ||
| 97 | }, | ||
| 98 | ], | ||
| 99 | }, | ||
| 100 | }, | ||
| 101 | }, | ||
| 102 | }; | ||
| 103 | |||
| 104 | const source: Source = decodeSource({ | ||
| 105 | id: "src_openapi", | ||
| 106 | workspaceId: "ws_local", | ||
| 107 | name: "local-openapi", | ||
| 108 | kind: "openapi", | ||
| 109 | endpoint: testServer.baseUrl, | ||
| 110 | status: "connected", | ||
| 111 | enabled: true, | ||
| 112 | configJson: "{}", | ||
| 113 | sourceHash: null, | ||
| 114 | lastError: null, | ||
| 115 | createdAt: Date.now(), | ||
| 116 | updatedAt: Date.now(), | ||
| 117 | }); | ||
| 118 | |||
| 119 | const artifactsByKey = new Map<string, ToolArtifact>(); | ||
| 120 | const artifactStore: ToolArtifactStore = { | ||
| 121 | getBySource: (workspaceId: Source["workspaceId"], sourceId: Source["id"]) => | ||
| 122 | Effect.succeed( | ||
| 123 | Option.fromNullable(artifactsByKey.get(`${workspaceId}:${sourceId}`)), | ||
| 124 | ), | ||
| 125 | upsert: (artifact: ToolArtifact) => | ||
| 126 | Effect.sync(() => { | ||
| 127 | artifactsByKey.set( | ||
| 128 | `${artifact.workspaceId}:${artifact.sourceId}`, | ||
| 129 | artifact, | ||
| 130 | ); | ||
| 131 | }), | ||
| 132 | }; | ||
| 133 | const sourceManager = makeSourceManagerService(artifactStore); | ||
| 134 | |||
| 135 | const refreshResult = yield* sourceManager.refreshOpenApiArtifact({ | ||
| 136 | source, | ||
| 137 | openApiSpec, | ||
| 138 | }); | ||
| 139 | |||
| 140 | const tools = yield* openApiToolDescriptorsFromManifest( | ||
| 141 | source, | ||
| 142 | refreshResult.artifact.manifestJson, | ||
| 143 | ); | ||
| 144 | |||
| 145 | const getUserTool = tools.find((tool) => tool.toolId === "getUser"); | ||
| 146 | if (!getUserTool) { | ||
| 147 | throw new Error("expected getUser tool"); | ||
| 148 | } | ||
| 149 | |||
| 150 | const registry = makeToolProviderRegistry([makeOpenApiToolProvider()]); | ||
| 151 | |||
| 152 | const executionResult = yield* executeJavaScriptWithTools({ | ||
| 153 | code: ` | ||
| 154 | return await tools.getUser({ | ||
| 155 | userId: "u123", | ||
| 156 | verbose: "true", | ||
| 157 | "x-api-key": "sk_test" | ||
| 158 | }); | ||
| 159 | `, | ||
| 160 | tools: [ | ||
| 161 | { | ||
| 162 | descriptor: getUserTool, | ||
| 163 | source, | ||
| 164 | }, | ||
| 165 | ], | ||
| 166 | }).pipe( | ||
| 167 | Effect.provideService(ToolProviderRegistryService, registry), | ||
| 168 | ); | ||
| 169 | |||
| 170 | const output = executionResult as { | ||
| 171 | status: number; | ||
| 172 | body: { | ||
| 173 | id: string; | ||
| 174 | verbose: boolean; | ||
| 175 | apiKey: string | null; | ||
| 176 | }; | ||
| 177 | }; | ||
| 178 | |||
| 179 | expect(output.status).toBe(200); | ||
| 180 | expect(output.body.id).toBe("u123"); | ||
| 181 | expect(output.body.verbose).toBe(true); | ||
| 182 | expect(output.body.apiKey).toBe("sk_test"); | ||
| 183 | |||
| 184 | expect(testServer.requests).toHaveLength(1); | ||
| 185 | expect(testServer.requests[0]?.path).toBe("/users/u123"); | ||
| 186 | expect(testServer.requests[0]?.query).toBe("?verbose=true"); | ||
| 187 | expect(testServer.requests[0]?.apiKey).toBe("sk_test"); | ||
| 188 | }); | ||
| 189 | |||
| 190 | await Effect.runPromise(program.pipe(Effect.scoped)); | ||
| 191 | }); | ||
| 192 | }); | ||
| @@ -0,0 +1,348 @@ | |||
| 1 | import type { Source } from "@executor-v2/schema"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import { pipe } from "effect/Function"; | ||
| 4 | import * as ParseResult from "effect/ParseResult"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | ToolProviderError, | ||
| 9 | type CanonicalToolDescriptor, | ||
| 10 | type ToolProvider, | ||
| 11 | } from "./tool-providers"; | ||
| 12 | |||
| 13 | const OPEN_API_PARAMETER_LOCATIONS = [ | ||
| 14 | "path", | ||
| 15 | "query", | ||
| 16 | "header", | ||
| 17 | "cookie", | ||
| 18 | ] as const; | ||
| 19 | |||
| 20 | const OPEN_API_HTTP_METHODS = [ | ||
| 21 | "get", | ||
| 22 | "put", | ||
| 23 | "post", | ||
| 24 | "delete", | ||
| 25 | "patch", | ||
| 26 | "head", | ||
| 27 | "options", | ||
| 28 | "trace", | ||
| 29 | ] as const; | ||
| 30 | |||
| 31 | const OpenApiInvocationPayloadSchema = Schema.Struct({ | ||
| 32 | method: Schema.Literal(...OPEN_API_HTTP_METHODS), | ||
| 33 | pathTemplate: Schema.String, | ||
| 34 | parameters: Schema.Array( | ||
| 35 | Schema.Struct({ | ||
| 36 | name: Schema.String, | ||
| 37 | location: Schema.Literal(...OPEN_API_PARAMETER_LOCATIONS), | ||
| 38 | required: Schema.Boolean, | ||
| 39 | }), | ||
| 40 | ), | ||
| 41 | requestBody: Schema.NullOr( | ||
| 42 | Schema.Struct({ | ||
| 43 | required: Schema.Boolean, | ||
| 44 | contentTypes: Schema.Array(Schema.String), | ||
| 45 | }), | ||
| 46 | ), | ||
| 47 | }); | ||
| 48 | |||
| 49 | type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type; | ||
| 50 | |||
| 51 | const OpenApiManifestSchema = Schema.Struct({ | ||
| 52 | version: Schema.Literal(1), | ||
| 53 | sourceHash: Schema.String, | ||
| 54 | tools: Schema.Array( | ||
| 55 | Schema.Struct({ | ||
| 56 | toolId: Schema.String, | ||
| 57 | name: Schema.String, | ||
| 58 | description: Schema.NullOr(Schema.String), | ||
| 59 | invocation: OpenApiInvocationPayloadSchema, | ||
| 60 | }), | ||
| 61 | ), | ||
| 62 | }); | ||
| 63 | |||
| 64 | type OpenApiManifest = typeof OpenApiManifestSchema.Type; | ||
| 65 | |||
| 66 | const OpenApiToolArgsSchema = Schema.Record({ | ||
| 67 | key: Schema.String, | ||
| 68 | value: Schema.Unknown, | ||
| 69 | }); | ||
| 70 | |||
| 71 | type OpenApiToolArgs = typeof OpenApiToolArgsSchema.Type; | ||
| 72 | |||
| 73 | const decodeOpenApiManifestJson = Schema.decodeUnknown( | ||
| 74 | Schema.parseJson(OpenApiManifestSchema), | ||
| 75 | ); | ||
| 76 | const decodeOpenApiInvocationPayload = Schema.decodeUnknown( | ||
| 77 | OpenApiInvocationPayloadSchema, | ||
| 78 | ); | ||
| 79 | const decodeOpenApiToolArgs = Schema.decodeUnknown(OpenApiToolArgsSchema); | ||
| 80 | |||
| 81 | const toOpenApiProviderError = ( | ||
| 82 | operation: string, | ||
| 83 | message: string, | ||
| 84 | cause: unknown, | ||
| 85 | ): ToolProviderError => | ||
| 86 | new ToolProviderError({ | ||
| 87 | operation, | ||
| 88 | providerKind: "openapi", | ||
| 89 | message, | ||
| 90 | details: ParseResult.isParseError(cause) | ||
| 91 | ? ParseResult.TreeFormatter.formatErrorSync(cause) | ||
| 92 | : String(cause), | ||
| 93 | }); | ||
| 94 | |||
| 95 | const encodePathSegment = (value: unknown): string => | ||
| 96 | encodeURIComponent(String(value)); | ||
| 97 | |||
| 98 | const argsValueToString = (value: unknown): string => { | ||
| 99 | if (typeof value === "string") { | ||
| 100 | return value; | ||
| 101 | } | ||
| 102 | |||
| 103 | if ( | ||
| 104 | typeof value === "number" || | ||
| 105 | typeof value === "boolean" || | ||
| 106 | typeof value === "bigint" | ||
| 107 | ) { | ||
| 108 | return String(value); | ||
| 109 | } | ||
| 110 | |||
| 111 | return JSON.stringify(value); | ||
| 112 | }; | ||
| 113 | |||
| 114 | const replacePathTemplate = ( | ||
| 115 | pathTemplate: string, | ||
| 116 | args: OpenApiToolArgs, | ||
| 117 | payload: OpenApiInvocationPayload, | ||
| 118 | ): Effect.Effect<string, ToolProviderError> => | ||
| 119 | Effect.gen(function* () { | ||
| 120 | let resolvedPath = pathTemplate; | ||
| 121 | |||
| 122 | for (const parameter of payload.parameters) { | ||
| 123 | if (parameter.location !== "path") { | ||
| 124 | continue; | ||
| 125 | } | ||
| 126 | |||
| 127 | const parameterValue = args[parameter.name]; | ||
| 128 | if (parameterValue === undefined || parameterValue === null) { | ||
| 129 | if (parameter.required) { | ||
| 130 | return yield* new ToolProviderError({ | ||
| 131 | operation: "invoke.resolve_path", | ||
| 132 | providerKind: "openapi", | ||
| 133 | message: `Missing required path parameter: ${parameter.name}`, | ||
| 134 | details: pathTemplate, | ||
| 135 | }); | ||
| 136 | } | ||
| 137 | continue; | ||
| 138 | } | ||
| 139 | |||
| 140 | resolvedPath = resolvedPath.replaceAll( | ||
| 141 | `{${parameter.name}}`, | ||
| 142 | encodePathSegment(parameterValue), | ||
| 143 | ); | ||
| 144 | } | ||
| 145 | |||
| 146 | return resolvedPath; | ||
| 147 | }); | ||
| 148 | |||
| 149 | const hasRequestBody = ( | ||
| 150 | args: OpenApiToolArgs, | ||
| 151 | ): args is OpenApiToolArgs & { body: unknown } => | ||
| 152 | Object.prototype.hasOwnProperty.call(args, "body") && args.body !== undefined; | ||
| 153 | |||
| 154 | type BuiltFetchRequest = { | ||
| 155 | url: URL; | ||
| 156 | init: RequestInit; | ||
| 157 | }; | ||
| 158 | |||
| 159 | const buildFetchRequest = ( | ||
| 160 | source: Source, | ||
| 161 | payload: OpenApiInvocationPayload, | ||
| 162 | args: OpenApiToolArgs, | ||
| 163 | ): Effect.Effect<BuiltFetchRequest, ToolProviderError> => | ||
| 164 | Effect.gen(function* () { | ||
| 165 | const resolvedPath = yield* replacePathTemplate(payload.pathTemplate, args, payload); | ||
| 166 | const url = new URL(resolvedPath, source.endpoint); | ||
| 167 | |||
| 168 | const headers = new Headers(); | ||
| 169 | const cookieParts: Array<string> = []; | ||
| 170 | |||
| 171 | for (const parameter of payload.parameters) { | ||
| 172 | if (parameter.location === "path") { | ||
| 173 | continue; | ||
| 174 | } | ||
| 175 | |||
| 176 | const parameterValue = args[parameter.name]; | ||
| 177 | if (parameterValue === undefined || parameterValue === null) { | ||
| 178 | if (parameter.required) { | ||
| 179 | return yield* new ToolProviderError({ | ||
| 180 | operation: "invoke.validate_args", | ||
| 181 | providerKind: "openapi", | ||
| 182 | message: `Missing required ${parameter.location} parameter: ${parameter.name}`, | ||
| 183 | details: payload.pathTemplate, | ||
| 184 | }); | ||
| 185 | } | ||
| 186 | continue; | ||
| 187 | } | ||
| 188 | |||
| 189 | const encoded = argsValueToString(parameterValue); | ||
| 190 | |||
| 191 | if (parameter.location === "query") { | ||
| 192 | url.searchParams.set(parameter.name, encoded); | ||
| 193 | } else if (parameter.location === "header") { | ||
| 194 | headers.set(parameter.name, encoded); | ||
| 195 | } else if (parameter.location === "cookie") { | ||
| 196 | cookieParts.push(`${parameter.name}=${encodeURIComponent(encoded)}`); | ||
| 197 | } | ||
| 198 | } | ||
| 199 | |||
| 200 | if (cookieParts.length > 0) { | ||
| 201 | headers.set("cookie", cookieParts.join("; ")); | ||
| 202 | } | ||
| 203 | |||
| 204 | let body: string | undefined; | ||
| 205 | |||
| 206 | if (payload.requestBody !== null) { | ||
| 207 | if (!hasRequestBody(args)) { | ||
| 208 | if (payload.requestBody.required) { | ||
| 209 | return yield* new ToolProviderError({ | ||
| 210 | operation: "invoke.validate_args", | ||
| 211 | providerKind: "openapi", | ||
| 212 | message: "Missing required request body at args.body", | ||
| 213 | details: payload.pathTemplate, | ||
| 214 | }); | ||
| 215 | } | ||
| 216 | } else { | ||
| 217 | body = JSON.stringify(args.body); | ||
| 218 | |||
| 219 | const preferredContentType = payload.requestBody.contentTypes[0]; | ||
| 220 | if (preferredContentType) { | ||
| 221 | headers.set("content-type", preferredContentType); | ||
| 222 | } else if (!headers.has("content-type")) { | ||
| 223 | headers.set("content-type", "application/json"); | ||
| 224 | } | ||
| 225 | } | ||
| 226 | } | ||
| 227 | |||
| 228 | return { | ||
| 229 | url, | ||
| 230 | init: { | ||
| 231 | method: payload.method.toUpperCase(), | ||
| 232 | headers, | ||
| 233 | body, | ||
| 234 | }, | ||
| 235 | }; | ||
| 236 | }); | ||
| 237 | |||
| 238 | const decodeFetchResponseBody = ( | ||
| 239 | response: Response, | ||
| 240 | ): Effect.Effect<unknown, ToolProviderError> => | ||
| 241 | Effect.tryPromise({ | ||
| 242 | try: async () => { | ||
| 243 | const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; | ||
| 244 | if (contentType.includes("application/json")) { | ||
| 245 | return await response.json(); | ||
| 246 | } | ||
| 247 | |||
| 248 | return await response.text(); | ||
| 249 | }, | ||
| 250 | catch: (cause) => | ||
| 251 | new ToolProviderError({ | ||
| 252 | operation: "invoke.decode_response", | ||
| 253 | providerKind: "openapi", | ||
| 254 | message: "Failed to decode OpenAPI response body", | ||
| 255 | details: cause instanceof Error ? cause.message : String(cause), | ||
| 256 | }), | ||
| 257 | }); | ||
| 258 | |||
| 259 | export const openApiToolDescriptorsFromManifest = ( | ||
| 260 | source: Source, | ||
| 261 | manifestJson: string, | ||
| 262 | ): Effect.Effect<ReadonlyArray<CanonicalToolDescriptor>, ToolProviderError> => | ||
| 263 | pipe( | ||
| 264 | decodeOpenApiManifestJson(manifestJson), | ||
| 265 | Effect.mapError((cause) => | ||
| 266 | toOpenApiProviderError( | ||
| 267 | "discover.decode_manifest", | ||
| 268 | "Failed to decode OpenAPI manifest JSON", | ||
| 269 | cause, | ||
| 270 | ), | ||
| 271 | ), | ||
| 272 | Effect.map((manifest: OpenApiManifest) => | ||
| 273 | manifest.tools.map( | ||
| 274 | (tool): CanonicalToolDescriptor => ({ | ||
| 275 | providerKind: "openapi", | ||
| 276 | sourceId: source.id, | ||
| 277 | workspaceId: source.workspaceId, | ||
| 278 | toolId: tool.toolId, | ||
| 279 | name: tool.name, | ||
| 280 | description: tool.description, | ||
| 281 | invocationMode: "http", | ||
| 282 | availability: "remote_capable", | ||
| 283 | providerPayload: tool.invocation, | ||
| 284 | }), | ||
| 285 | ), | ||
| 286 | ), | ||
| 287 | ); | ||
| 288 | |||
| 289 | export const makeOpenApiToolProvider = (): ToolProvider => ({ | ||
| 290 | kind: "openapi", | ||
| 291 | |||
| 292 | invoke: (input) => | ||
| 293 | Effect.gen(function* () { | ||
| 294 | if (!input.source) { | ||
| 295 | return yield* new ToolProviderError({ | ||
| 296 | operation: "invoke.validate_source", | ||
| 297 | providerKind: "openapi", | ||
| 298 | message: "OpenAPI provider requires a source", | ||
| 299 | details: null, | ||
| 300 | }); | ||
| 301 | } | ||
| 302 | |||
| 303 | const payload = yield* pipe( | ||
| 304 | decodeOpenApiInvocationPayload(input.tool.providerPayload), | ||
| 305 | Effect.mapError((cause) => | ||
| 306 | toOpenApiProviderError( | ||
| 307 | "invoke.decode_payload", | ||
| 308 | `Invalid provider payload for tool: ${input.tool.toolId}`, | ||
| 309 | cause, | ||
| 310 | ), | ||
| 311 | ), | ||
| 312 | ); | ||
| 313 | |||
| 314 | const args = yield* pipe( | ||
| 315 | decodeOpenApiToolArgs(input.args), | ||
| 316 | Effect.mapError((cause) => | ||
| 317 | toOpenApiProviderError( | ||
| 318 | "invoke.decode_args", | ||
| 319 | `Invalid tool args for tool: ${input.tool.toolId}`, | ||
| 320 | cause, | ||
| 321 | ), | ||
| 322 | ), | ||
| 323 | ); | ||
| 324 | |||
| 325 | const request = yield* buildFetchRequest(input.source, payload, args); | ||
| 326 | |||
| 327 | const response = yield* Effect.tryPromise({ | ||
| 328 | try: () => fetch(request.url, request.init), | ||
| 329 | catch: (cause) => | ||
| 330 | toOpenApiProviderError( | ||
| 331 | "invoke.http_request", | ||
| 332 | `OpenAPI request failed for tool: ${input.tool.toolId}`, | ||
| 333 | cause, | ||
| 334 | ), | ||
| 335 | }); | ||
| 336 | |||
| 337 | const body = yield* decodeFetchResponseBody(response); | ||
| 338 | |||
| 339 | return { | ||
| 340 | output: { | ||
| 341 | status: response.status, | ||
| 342 | headers: Object.fromEntries(response.headers.entries()), | ||
| 343 | body, | ||
| 344 | }, | ||
| 345 | isError: response.status >= 400, | ||
| 346 | }; | ||
| 347 | }), | ||
| 348 | }); | ||