- Added support for managing source credentials in OpenAPI execution, allowing for dynamic header injection based on source configuration. - Introduced new tools for adding, listing, and removing sources within the PM context. - Updated the approval client to handle new PM configuration paths. - Enhanced error handling in local and deno subprocess runners to include detailed output from tool calls. - Updated documentation to reflect new features and changes in source management.
Unified · Split
| @@ -25,6 +25,7 @@ interface ParsedArgs { · 24 unmodified lines | ||
| 25 | 25 | } |
| 26 | 26 | |
| 27 | 27 | const DEFAULT_MCP_CONFIG_PATH = path.resolve(import.meta.dir, "claude-mcp-demo.json"); |
| 28 | +const DEFAULT_PM_MCP_CONFIG_PATH = path.resolve(import.meta.dir, "claude-mcp-pm.json"); | |
| 28 | 29 | const HOME_DIR = process.env.HOME ?? "."; |
| 29 | 30 | const STATE_PATH = path.join(HOME_DIR, ".executor-lite-approvals", "mcp-state.json"); |
| 30 | 31 | |
| @@ -47,6 +48,11 @@ function defaultAgentCommand(session: string): string { · 16 unmodified lines | ||
| 47 | 48 | return `claude --strict-mcp-config --mcp-config ${shellQuote(DEFAULT_MCP_CONFIG_PATH)} --allowedTools ${shellQuote(allowedTools)} --permission-mode bypassPermissions`; |
| 48 | 49 | } |
| 49 | 50 | |
| 51 | + if (session === "claude-pm") { | |
| 52 | + const allowedTools = "mcp__executor-v2-local__executor.execute"; | |
| 53 | + return `claude --strict-mcp-config --mcp-config ${shellQuote(DEFAULT_PM_MCP_CONFIG_PATH)} --allowedTools ${shellQuote(allowedTools)} --permission-mode bypassPermissions`; | |
| 54 | + } | |
| 55 | + | |
| 50 | 56 | return session; |
| 51 | 57 | } |
| 52 | 58 | |
| @@ -0,0 +1,8 @@ | ||
| 1 | +{ | |
| 2 | + "mcpServers": { | |
| 3 | + "executor-v2-local": { | |
| 4 | + "type": "http", | |
| 5 | + "url": "http://127.0.0.1:8787/mcp" | |
| 6 | + } | |
| 7 | + } | |
| 8 | +} | |
| @@ -1 +1,3 @@ | ||
| 1 | 1 | .reference/ |
| 2 | +.executor-v2/ | |
| 3 | +apps/pm/.executor-v2/ | |
| @@ -410,23 +410,25 @@ Implemented in `v2` so far: · 409 unmodified lines | ||
| 410 | 410 | - Deno subprocess runtime path with IPC tool-call proxying and tests |
| 411 | 411 | - runtime adapter contract + registry scaffold in `engine` with mapped adapters (`local-inproc`, `deno-subprocess`, `cloudflare-worker-loader`) |
| 412 | 412 | - minimal PM execute lifecycle wired through MCP `executor.execute` -> runtime adapter registry (single run, no persisted run state yet) |
| 413 | +- PM source control path wired through execute-time tools namespace (`tools.executor.sources.add/list/remove`) with local file persistence and OpenAPI probe/extraction | |
| 414 | +- source credential-aware OpenAPI invocation support via source config (`api_key`/`bearer` modes) | |
| 413 | 415 | - vertical test: OpenAPI spec -> manifest -> execute code -> HTTP tool call |
| 414 | 416 | - service-first wiring (`Context.Tag` + Layer) across source manager and persistence/local runtime orchestration |
| 415 | 417 | |
| 416 | 418 | Not implemented yet: |
| 417 | −- `tools.executor.sources.add/list/remove` end-to-end PM + MCP gateway wiring | |
| 418 | 419 | - approval adapter/state machine integration (pending/resume/deny persistence) in v2 runtime path |
| 419 | 420 | - provider/runtime conformance suite beyond current baseline tests (timeouts, cancellation, pending approval) |
| 420 | 421 | - Cloudflare worker loader adapter execution integration (current adapter is explicit scaffold/not-implemented) |
| 421 | 422 | - MCP/GraphQL provider implementations |
| 423 | +- secure secret-store-backed credential persistence (current local MVP stores credential value in source config) | |
| 422 | 424 | - full rpc/sdk adapter wiring and sync engine |
| 423 | 425 | |
| 424 | 426 | --- |
| 425 | 427 | |
| 426 | 428 | ## 19) Immediate next steps |
| 427 | 429 | |
| 428 | −1. Add first `tools.executor.sources.add/list/remove` control operations end-to-end in PM + MCP gateway. | |
| 429 | −2. Implement approval adapter contract with persisted pending/resume/deny flow for runtime tool calls. | |
| 430 | −3. Add provider/runtime conformance tests (invoke success/failure, timeout, pending approval, cancel). | |
| 431 | −4. Add Cloudflare worker loader adapter integration against the runtime adapter contract. | |
| 430 | +1. Implement approval adapter contract with persisted pending/resume/deny flow for runtime tool calls. | |
| 431 | +2. Add provider/runtime conformance tests (invoke success/failure, timeout, pending approval, cancel). | |
| 432 | +3. Add Cloudflare worker loader adapter integration against the runtime adapter contract. | |
| 433 | +4. Move source credential persistence to secure store-backed refs (remove raw credential values from source config). | |
| 432 | 434 | 5. Extend PM execute lifecycle from single-run scaffold to persisted run state + streaming status. |
| @@ -4,4 +4,4 @@ Convex remote server app scaffold for Executor v2. · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | Current scaffold includes: |
| 6 | 6 | - Convex schema wiring at `convex/schema.ts`, sourced from `@executor-v2/persistence-convex` |
| 7 | −- bare-minimum MCP HTTP endpoint with a stub tool at `convex/http.ts` and `convex/mcp.ts` | |
| 7 | +- bare-minimum MCP HTTP endpoint exposing `executor.execute` (currently returns not-wired error) at `convex/http.ts` and `convex/mcp.ts` | |
| @@ -6,4 +6,8 @@ export const mcpHandler = httpAction(async (_ctx, request) => · 5 unmodified lines | ||
| 6 | 6 | target: "remote", |
| 7 | 7 | serverName: "executor-v2-convex", |
| 8 | 8 | serverVersion: "0.0.0", |
| 9 | + execute: async () => ({ | |
| 10 | + isError: true, | |
| 11 | + error: "Remote execute path is not wired yet", | |
| 12 | + }), | |
| 9 | 13 | })); |
| @@ -7,3 +7,4 @@ Current scaffold includes: · 6 unmodified lines | ||
| 7 | 7 | - health endpoint at `GET /healthz` |
| 8 | 8 | - MCP tool routing via `@executor-v2/mcp-gateway` |
| 9 | 9 | - `executor.execute` wired to engine runtime adapters (`local-inproc` default, `deno-subprocess` optional) |
| 10 | +- `tools.executor.sources.add/list/remove` available inside execute runtime via in-memory provider wiring | |
| @@ -8,8 +8,13 @@ · 7 unmodified lines | ||
| 8 | 8 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| 9 | 9 | }, |
| 10 | 10 | "dependencies": { |
| 11 | + "@effect/platform-bun": "^0.86.0", | |
| 11 | 12 | "@executor-v2/engine": "workspace:*", |
| 12 | 13 | "@executor-v2/mcp-gateway": "workspace:*", |
| 14 | + "@executor-v2/persistence-local": "workspace:*", | |
| 15 | + "@executor-v2/persistence-ports": "workspace:*", | |
| 16 | + "@executor-v2/schema": "workspace:*", | |
| 17 | + "@executor-v2/source-manager": "workspace:*", | |
| 13 | 18 | "effect": "^3.19.8" |
| 14 | 19 | } |
| 15 | 20 | } |
| @@ -1,20 +1,147 @@ | ||
| 1 | +import { resolve } from "node:path"; | |
| 2 | + | |
| 3 | +import * as BunContext from "@effect/platform-bun/BunContext"; | |
| 1 | 4 | import { |
| 5 | + ToolProviderError, | |
| 2 | 6 | makeCloudflareWorkerLoaderRuntimeAdapter, |
| 3 | 7 | makeDenoSubprocessRuntimeAdapter, |
| 4 | 8 | makeLocalInProcessRuntimeAdapter, |
| 9 | + makeOpenApiToolProvider, | |
| 5 | 10 | makeRuntimeAdapterRegistry, |
| 6 | 11 | makeToolProviderRegistry, |
| 12 | + openApiToolDescriptorsFromManifest, | |
| 7 | 13 | ToolProviderRegistryService, |
| 14 | + type RuntimeRunnableTool, | |
| 15 | + type ToolProvider, | |
| 8 | 16 | } from "@executor-v2/engine"; |
| 9 | 17 | import { |
| 10 | 18 | handleMcpHttpRequest, |
| 11 | 19 | type ExecuteToolInput, |
| 12 | 20 | type ExecuteToolResult, |
| 13 | 21 | } from "@executor-v2/mcp-gateway"; |
| 22 | +import { | |
| 23 | + makeLocalSourceStore, | |
| 24 | + makeLocalToolArtifactStore, | |
| 25 | +} from "@executor-v2/persistence-local"; | |
| 26 | +import type { | |
| 27 | + SourceStore, | |
| 28 | + ToolArtifactStore, | |
| 29 | +} from "@executor-v2/persistence-ports"; | |
| 30 | +import { | |
| 31 | + CanonicalToolDescriptorSchema, | |
| 32 | + OpenApiSourceConfigSchema, | |
| 33 | + SourceSchema, | |
| 34 | + SourceIdSchema, | |
| 35 | + WorkspaceIdSchema, | |
| 36 | + type CanonicalToolDescriptor, | |
| 37 | + type OpenApiSourceConfig, | |
| 38 | + type Source, | |
| 39 | +} from "@executor-v2/schema"; | |
| 40 | +import { | |
| 41 | + makeSourceManagerService, | |
| 42 | + type SourceManagerService, | |
| 43 | +} from "@executor-v2/source-manager"; | |
| 14 | 44 | import * as Effect from "effect/Effect"; |
| 15 | 45 | import * as Either from "effect/Either"; |
| 46 | +import * as Option from "effect/Option"; | |
| 47 | +import * as Schema from "effect/Schema"; | |
| 16 | 48 | |
| 17 | 49 | const port = Number(Bun.env.PORT ?? 8787); |
| 50 | +const defaultStateRootDir = resolve(import.meta.dir, "../../../.executor-v2/local"); | |
| 51 | +const stateRootDir = Bun.env.EXECUTOR_V2_STATE_DIR?.trim() | |
| 52 | + ? resolve(Bun.env.EXECUTOR_V2_STATE_DIR) | |
| 53 | + : defaultStateRootDir; | |
| 54 | + | |
| 55 | +const decodeSource = Schema.decodeUnknownSync(SourceSchema); | |
| 56 | +const decodeSourceId = Schema.decodeUnknownSync(SourceIdSchema); | |
| 57 | +const decodeWorkspaceId = Schema.decodeUnknownSync(WorkspaceIdSchema); | |
| 58 | +const decodeOpenApiSourceConfig = Schema.decodeUnknownSync(OpenApiSourceConfigSchema); | |
| 59 | +const decodeCanonicalToolDescriptor = Schema.decodeUnknownSync( | |
| 60 | + CanonicalToolDescriptorSchema, | |
| 61 | +); | |
| 62 | + | |
| 63 | +const workspaceId = decodeWorkspaceId( | |
| 64 | + Bun.env.EXECUTOR_V2_WORKSPACE_ID?.trim() || "ws_local", | |
| 65 | +); | |
| 66 | + | |
| 67 | +const BUILTIN_PROVIDER_KIND = "in_memory" as const; | |
| 68 | +const SOURCE_ADD_TOOL_ID = "executor.sources.add"; | |
| 69 | +const SOURCE_LIST_TOOL_ID = "executor.sources.list"; | |
| 70 | +const SOURCE_REMOVE_TOOL_ID = "executor.sources.remove"; | |
| 71 | + | |
| 72 | +const sourceAddDescriptor: CanonicalToolDescriptor = decodeCanonicalToolDescriptor({ | |
| 73 | + providerKind: BUILTIN_PROVIDER_KIND, | |
| 74 | + sourceId: null, | |
| 75 | + workspaceId: null, | |
| 76 | + toolId: SOURCE_ADD_TOOL_ID, | |
| 77 | + name: "executor.sources.add", | |
| 78 | + description: "Add a source and probe OpenAPI connectivity", | |
| 79 | + invocationMode: "in_memory", | |
| 80 | + availability: "local_only", | |
| 81 | + providerPayload: { | |
| 82 | + operation: "add", | |
| 83 | + }, | |
| 84 | +}); | |
| 85 | + | |
| 86 | +const sourceListDescriptor: CanonicalToolDescriptor = decodeCanonicalToolDescriptor({ | |
| 87 | + providerKind: BUILTIN_PROVIDER_KIND, | |
| 88 | + sourceId: null, | |
| 89 | + workspaceId: null, | |
| 90 | + toolId: SOURCE_LIST_TOOL_ID, | |
| 91 | + name: "executor.sources.list", | |
| 92 | + description: "List configured sources", | |
| 93 | + invocationMode: "in_memory", | |
| 94 | + availability: "local_only", | |
| 95 | + providerPayload: { | |
| 96 | + operation: "list", | |
| 97 | + }, | |
| 98 | +}); | |
| 99 | + | |
| 100 | +const sourceRemoveDescriptor: CanonicalToolDescriptor = | |
| 101 | + decodeCanonicalToolDescriptor({ | |
| 102 | + providerKind: BUILTIN_PROVIDER_KIND, | |
| 103 | + sourceId: null, | |
| 104 | + workspaceId: null, | |
| 105 | + toolId: SOURCE_REMOVE_TOOL_ID, | |
| 106 | + name: "executor.sources.remove", | |
| 107 | + description: "Remove a source by id or name", | |
| 108 | + invocationMode: "in_memory", | |
| 109 | + availability: "local_only", | |
| 110 | + providerPayload: { | |
| 111 | + operation: "remove", | |
| 112 | + }, | |
| 113 | + }); | |
| 114 | + | |
| 115 | +const builtInDescriptors = [ | |
| 116 | + sourceAddDescriptor, | |
| 117 | + sourceListDescriptor, | |
| 118 | + sourceRemoveDescriptor, | |
| 119 | +] as const; | |
| 120 | + | |
| 121 | +type LocalPersistenceContext = { | |
| 122 | + sourceStore: SourceStore; | |
| 123 | + artifactStore: ToolArtifactStore; | |
| 124 | + sourceManager: SourceManagerService; | |
| 125 | +}; | |
| 126 | + | |
| 127 | +type SourceCredentialMode = "none" | "api_key" | "bearer"; | |
| 128 | + | |
| 129 | +type AddSourceArgs = { | |
| 130 | + kind: "openapi" | "mcp" | "graphql" | "internal"; | |
| 131 | + name?: string; | |
| 132 | + endpoint: string; | |
| 133 | + credential?: { | |
| 134 | + mode: SourceCredentialMode; | |
| 135 | + value?: string; | |
| 136 | + headerName?: string; | |
| 137 | + }; | |
| 138 | + staticHeaders?: Record<string, string>; | |
| 139 | +}; | |
| 140 | + | |
| 141 | +type RemoveSourceArgs = { | |
| 142 | + sourceId?: string; | |
| 143 | + name?: string; | |
| 144 | +}; | |
| 18 | 145 | |
| 19 | 146 | const runtimeRegistry = makeRuntimeAdapterRegistry([ |
| 20 | 147 | makeLocalInProcessRuntimeAdapter(), |
| @@ -22,11 +149,34 @@ const runtimeRegistry = makeRuntimeAdapterRegistry([ · 1 unmodified line | ||
| 22 | 149 | makeCloudflareWorkerLoaderRuntimeAdapter(), |
| 23 | 150 | ]); |
| 24 | 151 | |
| 25 | −const toolRegistry = makeToolProviderRegistry([]); | |
| 152 | +const localPersistencePromise = Effect.runPromise( | |
| 153 | + Effect.gen(function* () { | |
| 154 | + const sourceStore = yield* makeLocalSourceStore({ rootDir: stateRootDir }); | |
| 155 | + const artifactStore = yield* makeLocalToolArtifactStore({ rootDir: stateRootDir }); | |
| 156 | + | |
| 157 | + return { | |
| 158 | + sourceStore, | |
| 159 | + artifactStore, | |
| 160 | + sourceManager: makeSourceManagerService(artifactStore), | |
| 161 | + } satisfies LocalPersistenceContext; | |
| 162 | + }).pipe(Effect.provide(BunContext.layer)), | |
| 163 | +); | |
| 26 | 164 | |
| 27 | 165 | const errorToText = (error: unknown): string => { |
| 166 | + const extractDetails = (value: unknown): string | null => { | |
| 167 | + if (typeof value === "object" && value !== null && "details" in value) { | |
| 168 | + const details = (value as { details?: unknown }).details; | |
| 169 | + if (typeof details === "string" && details.length > 0) { | |
| 170 | + return details; | |
| 171 | + } | |
| 172 | + } | |
| 173 | + | |
| 174 | + return null; | |
| 175 | + }; | |
| 176 | + | |
| 28 | 177 | if (error instanceof Error) { |
| 29 | − return error.message; | |
| 178 | + const details = extractDetails(error); | |
| 179 | + return details ? `${error.message}\n${details}` : error.message; | |
| 30 | 180 | } |
| 31 | 181 | |
| 32 | 182 | if ( |
| @@ -35,22 +185,596 @@ const errorToText = (error: unknown): string => { · 2 unmodified lines | ||
| 35 | 185 | "message" in error && |
| 36 | 186 | typeof error.message === "string" |
| 37 | 187 | ) { |
| 38 | − return error.message; | |
| 188 | + const details = extractDetails(error); | |
| 189 | + return details ? `${error.message}\n${details}` : error.message; | |
| 39 | 190 | } |
| 40 | 191 | |
| 41 | 192 | return String(error); |
| 42 | 193 | }; |
| 43 | 194 | |
| 195 | +const asRecord = (value: unknown): Record<string, unknown> | null => { | |
| 196 | + if (typeof value !== "object" || value === null || Array.isArray(value)) { | |
| 197 | + return null; | |
| 198 | + } | |
| 199 | + | |
| 200 | + return value as Record<string, unknown>; | |
| 201 | +}; | |
| 202 | + | |
| 203 | +const toSlug = (value: string): string => { | |
| 204 | + const slug = value | |
| 205 | + .trim() | |
| 206 | + .toLowerCase() | |
| 207 | + .replace(/[^a-z0-9]+/g, "_") | |
| 208 | + .replace(/^_+|_+$/g, ""); | |
| 209 | + | |
| 210 | + return slug.length > 0 ? slug : "source"; | |
| 211 | +}; | |
| 212 | + | |
| 213 | +const defaultSourceName = (endpoint: string): string => { | |
| 214 | + try { | |
| 215 | + const parsed = new URL(endpoint); | |
| 216 | + return parsed.hostname; | |
| 217 | + } catch { | |
| 218 | + return endpoint; | |
| 219 | + } | |
| 220 | +}; | |
| 221 | + | |
| 222 | +const parseOpenApiSourceConfig = (source: Source): OpenApiSourceConfig | null => { | |
| 223 | + const trimmed = source.configJson.trim(); | |
| 224 | + if (trimmed.length === 0) { | |
| 225 | + return null; | |
| 226 | + } | |
| 227 | + | |
| 228 | + try { | |
| 229 | + const parsed = JSON.parse(trimmed) as unknown; | |
| 230 | + return decodeOpenApiSourceConfig(parsed); | |
| 231 | + } catch { | |
| 232 | + return null; | |
| 233 | + } | |
| 234 | +}; | |
| 235 | + | |
| 236 | +const buildOpenApiSourceConfig = (input: AddSourceArgs): OpenApiSourceConfig => ({ | |
| 237 | + type: "openapi", | |
| 238 | + auth: input.credential | |
| 239 | + ? { | |
| 240 | + mode: input.credential.mode, | |
| 241 | + headerName: input.credential.headerName, | |
| 242 | + value: input.credential.value, | |
| 243 | + } | |
| 244 | + : { | |
| 245 | + mode: "none", | |
| 246 | + }, | |
| 247 | + staticHeaders: input.staticHeaders, | |
| 248 | +}); | |
| 249 | + | |
| 250 | +const configuredHeadersForOpenApi = ( | |
| 251 | + config: OpenApiSourceConfig, | |
| 252 | +): { headers: Headers; error: string | null } => { | |
| 253 | + const headers = new Headers(); | |
| 254 | + | |
| 255 | + if (config.staticHeaders) { | |
| 256 | + for (const [key, value] of Object.entries(config.staticHeaders)) { | |
| 257 | + headers.set(key, value); | |
| 258 | + } | |
| 259 | + } | |
| 260 | + | |
| 261 | + const auth = config.auth; | |
| 262 | + if (!auth || auth.mode === "none") { | |
| 263 | + return { headers, error: null }; | |
| 264 | + } | |
| 265 | + | |
| 266 | + const credentialValue = auth.value?.trim(); | |
| 267 | + if (!credentialValue) { | |
| 268 | + return { | |
| 269 | + headers, | |
| 270 | + error: "Configured credential mode requires a value", | |
| 271 | + }; | |
| 272 | + } | |
| 273 | + | |
| 274 | + if (auth.mode === "api_key") { | |
| 275 | + headers.set(auth.headerName?.trim() || "x-api-key", credentialValue); | |
| 276 | + return { headers, error: null }; | |
| 277 | + } | |
| 278 | + | |
| 279 | + headers.set("authorization", `Bearer ${credentialValue}`); | |
| 280 | + return { headers, error: null }; | |
| 281 | +}; | |
| 282 | + | |
| 283 | +const sourceSummary = (source: Source, toolCount: number) => { | |
| 284 | + const config = parseOpenApiSourceConfig(source); | |
| 285 | + | |
| 286 | + return { | |
| 287 | + id: source.id, | |
| 288 | + workspaceId: source.workspaceId, | |
| 289 | + name: source.name, | |
| 290 | + kind: source.kind, | |
| 291 | + endpoint: source.endpoint, | |
| 292 | + status: source.status, | |
| 293 | + enabled: source.enabled, | |
| 294 | + sourceHash: source.sourceHash, | |
| 295 | + lastError: source.lastError, | |
| 296 | + toolCount, | |
| 297 | + credential: config?.auth | |
| 298 | + ? { | |
| 299 | + mode: config.auth.mode, | |
| 300 | + headerName: config.auth.headerName ?? null, | |
| 301 | + configured: Boolean(config.auth.value && config.auth.value.trim().length > 0), | |
| 302 | + } | |
| 303 | + : null, | |
| 304 | + }; | |
| 305 | +}; | |
| 306 | + | |
| 307 | +const parseSourceCredentialMode = (value: unknown): SourceCredentialMode => { | |
| 308 | + if (value === "none" || value === "api_key" || value === "bearer") { | |
| 309 | + return value; | |
| 310 | + } | |
| 311 | + | |
| 312 | + throw new Error("credential.mode must be one of: none, api_key, bearer"); | |
| 313 | +}; | |
| 314 | + | |
| 315 | +const parseAddSourceArgs = (args: unknown): AddSourceArgs => { | |
| 316 | + const record = asRecord(args); | |
| 317 | + if (!record) { | |
| 318 | + throw new Error("executor.sources.add expects an object argument"); | |
| 319 | + } | |
| 320 | + | |
| 321 | + const endpoint = record.endpoint; | |
| 322 | + if (typeof endpoint !== "string" || endpoint.trim().length === 0) { | |
| 323 | + throw new Error("executor.sources.add requires endpoint"); | |
| 324 | + } | |
| 325 | + | |
| 326 | + const kind = record.kind; | |
| 327 | + const normalizedKind = | |
| 328 | + kind === "openapi" || kind === "mcp" || kind === "graphql" || kind === "internal" | |
| 329 | + ? kind | |
| 330 | + : "openapi"; | |
| 331 | + | |
| 332 | + const name = typeof record.name === "string" ? record.name : undefined; | |
| 333 | + | |
| 334 | + let credential: AddSourceArgs["credential"]; | |
| 335 | + const credentialRecord = asRecord(record.credential); | |
| 336 | + if (credentialRecord) { | |
| 337 | + credential = { | |
| 338 | + mode: parseSourceCredentialMode(credentialRecord.mode), | |
| 339 | + value: | |
| 340 | + typeof credentialRecord.value === "string" ? credentialRecord.value : undefined, | |
| 341 | + headerName: | |
| 342 | + typeof credentialRecord.headerName === "string" | |
| 343 | + ? credentialRecord.headerName | |
| 344 | + : undefined, | |
| 345 | + }; | |
| 346 | + } | |
| 347 | + | |
| 348 | + let staticHeaders: Record<string, string> | undefined; | |
| 349 | + const staticHeadersRecord = asRecord(record.staticHeaders); | |
| 350 | + if (staticHeadersRecord) { | |
| 351 | + staticHeaders = {}; | |
| 352 | + | |
| 353 | + for (const [key, value] of Object.entries(staticHeadersRecord)) { | |
| 354 | + if (typeof value !== "string") { | |
| 355 | + throw new Error("staticHeaders values must be strings"); | |
| 356 | + } | |
| 357 | + | |
| 358 | + staticHeaders[key] = value; | |
| 359 | + } | |
| 360 | + } | |
| 361 | + | |
| 362 | + return { | |
| 363 | + kind: normalizedKind, | |
| 364 | + name, | |
| 365 | + endpoint, | |
| 366 | + credential, | |
| 367 | + staticHeaders, | |
| 368 | + }; | |
| 369 | +}; | |
| 370 | + | |
| 371 | +const parseRemoveSourceArgs = (args: unknown): RemoveSourceArgs => { | |
| 372 | + const record = asRecord(args); | |
| 373 | + if (!record) { | |
| 374 | + throw new Error("executor.sources.remove expects an object argument"); | |
| 375 | + } | |
| 376 | + | |
| 377 | + const sourceId = typeof record.sourceId === "string" ? record.sourceId : undefined; | |
| 378 | + const name = typeof record.name === "string" ? record.name : undefined; | |
| 379 | + | |
| 380 | + return { | |
| 381 | + sourceId, | |
| 382 | + name, | |
| 383 | + }; | |
| 384 | +}; | |
| 385 | + | |
| 386 | +const addSource = async ( | |
| 387 | + context: LocalPersistenceContext, | |
| 388 | + args: AddSourceArgs, | |
| 389 | +): Promise<{ source: ReturnType<typeof sourceSummary>; toolCount: number }> => { | |
| 390 | + if (args.kind !== "openapi") { | |
| 391 | + throw new Error(`Source kind '${args.kind}' is not supported yet (MVP supports openapi)`); | |
| 392 | + } | |
| 393 | + | |
| 394 | + const endpoint = args.endpoint.trim(); | |
| 395 | + if (endpoint.length === 0) { | |
| 396 | + throw new Error("endpoint is required"); | |
| 397 | + } | |
| 398 | + | |
| 399 | + const name = args.name?.trim() || defaultSourceName(endpoint); | |
| 400 | + const config = buildOpenApiSourceConfig(args); | |
| 401 | + const configJson = JSON.stringify(config); | |
| 402 | + | |
| 403 | + const existingSources = await Effect.runPromise( | |
| 404 | + context.sourceStore.listByWorkspace(workspaceId), | |
| 405 | + ); | |
| 406 | + const existingSource = existingSources.find( | |
| 407 | + (source) => source.kind === "openapi" && source.endpoint === endpoint, | |
| 408 | + ); | |
| 409 | + | |
| 410 | + const now = Date.now(); | |
| 411 | + const sourceId = | |
| 412 | + existingSource?.id ?? | |
| 413 | + decodeSourceId( | |
| 414 | + `src_${toSlug(name)}_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`, | |
| 415 | + ); | |
| 416 | + | |
| 417 | + const probingSource = decodeSource({ | |
| 418 | + id: sourceId, | |
| 419 | + workspaceId, | |
| 420 | + name, | |
| 421 | + kind: "openapi", | |
| 422 | + endpoint, | |
| 423 | + status: "probing", | |
| 424 | + enabled: true, | |
| 425 | + configJson, | |
| 426 | + sourceHash: existingSource?.sourceHash ?? null, | |
| 427 | + lastError: null, | |
| 428 | + createdAt: existingSource?.createdAt ?? now, | |
| 429 | + updatedAt: now, | |
| 430 | + }); | |
| 431 | + | |
| 432 | + await Effect.runPromise(context.sourceStore.upsert(probingSource)); | |
| 433 | + | |
| 434 | + const configuredHeaders = configuredHeadersForOpenApi(config); | |
| 435 | + if (configuredHeaders.error) { | |
| 436 | + const authRequiredSource = decodeSource({ | |
| 437 | + ...probingSource, | |
| 438 | + status: "auth_required", | |
| 439 | + sourceHash: null, | |
| 440 | + lastError: configuredHeaders.error, | |
| 441 | + updatedAt: Date.now(), | |
| 442 | + }); | |
| 443 | + | |
| 444 | + await Effect.runPromise(context.sourceStore.upsert(authRequiredSource)); | |
| 445 | + | |
| 446 | + return { | |
| 447 | + source: sourceSummary(authRequiredSource, 0), | |
| 448 | + toolCount: 0, | |
| 449 | + }; | |
| 450 | + } | |
| 451 | + | |
| 452 | + let response: Response; | |
| 453 | + try { | |
| 454 | + response = await fetch(endpoint, { | |
| 455 | + method: "GET", | |
| 456 | + headers: configuredHeaders.headers, | |
| 457 | + }); | |
| 458 | + } catch (cause) { | |
| 459 | + const failedSource = decodeSource({ | |
| 460 | + ...probingSource, | |
| 461 | + status: "error", | |
| 462 | + sourceHash: null, | |
| 463 | + lastError: `Failed to fetch OpenAPI spec: ${errorToText(cause)}`, | |
| 464 | + updatedAt: Date.now(), | |
| 465 | + }); | |
| 466 | + | |
| 467 | + await Effect.runPromise(context.sourceStore.upsert(failedSource)); | |
| 468 | + | |
| 469 | + return { | |
| 470 | + source: sourceSummary(failedSource, 0), | |
| 471 | + toolCount: 0, | |
| 472 | + }; | |
| 473 | + } | |
| 474 | + | |
| 475 | + if (response.status === 401 || response.status === 403) { | |
| 476 | + const authRequiredSource = decodeSource({ | |
| 477 | + ...probingSource, | |
| 478 | + status: "auth_required", | |
| 479 | + sourceHash: null, | |
| 480 | + lastError: `OpenAPI endpoint responded with HTTP ${response.status}`, | |
| 481 | + updatedAt: Date.now(), | |
| 482 | + }); | |
| 483 | + | |
| 484 | + await Effect.runPromise(context.sourceStore.upsert(authRequiredSource)); | |
| 485 | + | |
| 486 | + return { | |
| 487 | + source: sourceSummary(authRequiredSource, 0), | |
| 488 | + toolCount: 0, | |
| 489 | + }; | |
| 490 | + } | |
| 491 | + | |
| 492 | + if (!response.ok) { | |
| 493 | + const failedSource = decodeSource({ | |
| 494 | + ...probingSource, | |
| 495 | + status: "error", | |
| 496 | + sourceHash: null, | |
| 497 | + lastError: `OpenAPI endpoint responded with HTTP ${response.status}`, | |
| 498 | + updatedAt: Date.now(), | |
| 499 | + }); | |
| 500 | + | |
| 501 | + await Effect.runPromise(context.sourceStore.upsert(failedSource)); | |
| 502 | + | |
| 503 | + return { | |
| 504 | + source: sourceSummary(failedSource, 0), | |
| 505 | + toolCount: 0, | |
| 506 | + }; | |
| 507 | + } | |
| 508 | + | |
| 509 | + let openApiSpec: unknown; | |
| 510 | + try { | |
| 511 | + openApiSpec = await response.json(); | |
| 512 | + } catch (cause) { | |
| 513 | + const failedSource = decodeSource({ | |
| 514 | + ...probingSource, | |
| 515 | + status: "error", | |
| 516 | + sourceHash: null, | |
| 517 | + lastError: `Failed to parse OpenAPI response JSON: ${errorToText(cause)}`, | |
| 518 | + updatedAt: Date.now(), | |
| 519 | + }); | |
| 520 | + | |
| 521 | + await Effect.runPromise(context.sourceStore.upsert(failedSource)); | |
| 522 | + | |
| 523 | + return { | |
| 524 | + source: sourceSummary(failedSource, 0), | |
| 525 | + toolCount: 0, | |
| 526 | + }; | |
| 527 | + } | |
| 528 | + | |
| 529 | + const refreshResult = await Effect.runPromise( | |
| 530 | + Effect.either( | |
| 531 | + context.sourceManager.refreshOpenApiArtifact({ | |
| 532 | + source: probingSource, | |
| 533 | + openApiSpec, | |
| 534 | + }), | |
| 535 | + ), | |
| 536 | + ); | |
| 537 | + | |
| 538 | + if (Either.isLeft(refreshResult)) { | |
| 539 | + const failedSource = decodeSource({ | |
| 540 | + ...probingSource, | |
| 541 | + status: "error", | |
| 542 | + sourceHash: null, | |
| 543 | + lastError: `OpenAPI extraction failed: ${errorToText(refreshResult.left)}`, | |
| 544 | + updatedAt: Date.now(), | |
| 545 | + }); | |
| 546 | + | |
| 547 | + await Effect.runPromise(context.sourceStore.upsert(failedSource)); | |
| 548 | + | |
| 549 | + return { | |
| 550 | + source: sourceSummary(failedSource, 0), | |
| 551 | + toolCount: 0, | |
| 552 | + }; | |
| 553 | + } | |
| 554 | + | |
| 555 | + const connectedSource = decodeSource({ | |
| 556 | + ...probingSource, | |
| 557 | + status: "connected", | |
| 558 | + sourceHash: refreshResult.right.artifact.sourceHash, | |
| 559 | + lastError: null, | |
| 560 | + updatedAt: Date.now(), | |
| 561 | + }); | |
| 562 | + | |
| 563 | + await Effect.runPromise(context.sourceStore.upsert(connectedSource)); | |
| 564 | + | |
| 565 | + return { | |
| 566 | + source: sourceSummary(connectedSource, refreshResult.right.artifact.toolCount), | |
| 567 | + toolCount: refreshResult.right.artifact.toolCount, | |
| 568 | + }; | |
| 569 | +}; | |
| 570 | + | |
| 571 | +const listSources = async ( | |
| 572 | + context: LocalPersistenceContext, | |
| 573 | +): Promise<{ sources: ReadonlyArray<ReturnType<typeof sourceSummary>> }> => { | |
| 574 | + const result = await Effect.runPromise( | |
| 575 | + Effect.either( | |
| 576 | + Effect.gen(function* () { | |
| 577 | + const sources = yield* context.sourceStore.listByWorkspace(workspaceId); | |
| 578 | + | |
| 579 | + return yield* Effect.forEach(sources, (source) => | |
| 580 | + Effect.gen(function* () { | |
| 581 | + const artifactOption = yield* context.artifactStore.getBySource( | |
| 582 | + source.workspaceId, | |
| 583 | + source.id, | |
| 584 | + ); | |
| 585 | + const artifact = Option.getOrUndefined(artifactOption); | |
| 586 | + return sourceSummary(source, artifact?.toolCount ?? 0); | |
| 587 | + }), | |
| 588 | + ); | |
| 589 | + }), | |
| 590 | + ), | |
| 591 | + ); | |
| 592 | + | |
| 593 | + if (Either.isLeft(result)) { | |
| 594 | + throw new Error(errorToText(result.left)); | |
| 595 | + } | |
| 596 | + | |
| 597 | + return { | |
| 598 | + sources: result.right, | |
| 599 | + }; | |
| 600 | +}; | |
| 601 | + | |
| 602 | +const removeSource = async ( | |
| 603 | + context: LocalPersistenceContext, | |
| 604 | + args: RemoveSourceArgs, | |
| 605 | +): Promise<{ removed: boolean }> => { | |
| 606 | + const targetSourceId = args.sourceId?.trim(); | |
| 607 | + const targetName = args.name?.trim(); | |
| 608 | + | |
| 609 | + if (!targetSourceId && !targetName) { | |
| 610 | + throw new Error("Provide either sourceId or name"); | |
| 611 | + } | |
| 612 | + | |
| 613 | + const sources = await Effect.runPromise(context.sourceStore.listByWorkspace(workspaceId)); | |
| 614 | + | |
| 615 | + const sourceToRemove = targetSourceId | |
| 616 | + ? sources.find((source) => source.id === targetSourceId) | |
| 617 | + : sources.find((source) => source.name === targetName); | |
| 618 | + | |
| 619 | + if (!sourceToRemove) { | |
| 620 | + return { | |
| 621 | + removed: false, | |
| 622 | + }; | |
| 623 | + } | |
| 624 | + | |
| 625 | + const removed = await Effect.runPromise( | |
| 626 | + context.sourceStore.removeById(workspaceId, sourceToRemove.id), | |
| 627 | + ); | |
| 628 | + | |
| 629 | + return { | |
| 630 | + removed, | |
| 631 | + }; | |
| 632 | +}; | |
| 633 | + | |
| 634 | +const builtInToolError = ( | |
| 635 | + operation: string, | |
| 636 | + message: string, | |
| 637 | + details: string | null, | |
| 638 | +): ToolProviderError => | |
| 639 | + new ToolProviderError({ | |
| 640 | + operation, | |
| 641 | + providerKind: BUILTIN_PROVIDER_KIND, | |
| 642 | + message, | |
| 643 | + details, | |
| 644 | + }); | |
| 645 | + | |
| 646 | +const makeBuiltInToolsProvider = ( | |
| 647 | + context: LocalPersistenceContext, | |
| 648 | +): ToolProvider => ({ | |
| 649 | + kind: BUILTIN_PROVIDER_KIND, | |
| 650 | + invoke: (input) => | |
| 651 | + Effect.gen(function* () { | |
| 652 | + if (input.tool.toolId === SOURCE_ADD_TOOL_ID) { | |
| 653 | + const output = yield* Effect.tryPromise({ | |
| 654 | + try: () => addSource(context, parseAddSourceArgs(input.args)), | |
| 655 | + catch: (cause) => | |
| 656 | + builtInToolError( | |
| 657 | + "invoke.add_source", | |
| 658 | + "Failed to add source", | |
| 659 | + errorToText(cause), | |
| 660 | + ), | |
| 661 | + }); | |
| 662 | + | |
| 663 | + return { | |
| 664 | + output, | |
| 665 | + isError: false, | |
| 666 | + } as const; | |
| 667 | + } | |
| 668 | + | |
| 669 | + if (input.tool.toolId === SOURCE_LIST_TOOL_ID) { | |
| 670 | + const output = yield* Effect.tryPromise({ | |
| 671 | + try: () => listSources(context), | |
| 672 | + catch: (cause) => | |
| 673 | + builtInToolError( | |
| 674 | + "invoke.list_sources", | |
| 675 | + "Failed to list sources", | |
| 676 | + errorToText(cause), | |
| 677 | + ), | |
| 678 | + }); | |
| 679 | + | |
| 680 | + return { | |
| 681 | + output, | |
| 682 | + isError: false, | |
| 683 | + } as const; | |
| 684 | + } | |
| 685 | + | |
| 686 | + if (input.tool.toolId === SOURCE_REMOVE_TOOL_ID) { | |
| 687 | + const output = yield* Effect.tryPromise({ | |
| 688 | + try: () => removeSource(context, parseRemoveSourceArgs(input.args)), | |
| 689 | + catch: (cause) => | |
| 690 | + builtInToolError( | |
| 691 | + "invoke.remove_source", | |
| 692 | + "Failed to remove source", | |
| 693 | + errorToText(cause), | |
| 694 | + ), | |
| 695 | + }); | |
| 696 | + | |
| 697 | + return { | |
| 698 | + output, | |
| 699 | + isError: false, | |
| 700 | + } as const; | |
| 701 | + } | |
| 702 | + | |
| 703 | + return yield* builtInToolError( | |
| 704 | + "invoke", | |
| 705 | + `Unknown in-memory tool id: ${input.tool.toolId}`, | |
| 706 | + null, | |
| 707 | + ); | |
| 708 | + }), | |
| 709 | +}); | |
| 710 | + | |
| 711 | +const collectRunnableTools = async ( | |
| 712 | + context: LocalPersistenceContext, | |
| 713 | +): Promise<ReadonlyArray<RuntimeRunnableTool>> => { | |
| 714 | + const tools: Array<RuntimeRunnableTool> = builtInDescriptors.map((descriptor) => ({ | |
| 715 | + descriptor, | |
| 716 | + source: null, | |
| 717 | + })); | |
| 718 | + | |
| 719 | + const sources = await Effect.runPromise( | |
| 720 | + context.sourceStore.listByWorkspace(workspaceId), | |
| 721 | + ); | |
| 722 | + | |
| 723 | + for (const source of sources) { | |
| 724 | + if (source.kind !== "openapi" || !source.enabled || source.status !== "connected") { | |
| 725 | + continue; | |
| 726 | + } | |
| 727 | + | |
| 728 | + const artifactOption = await Effect.runPromise( | |
| 729 | + context.artifactStore.getBySource(source.workspaceId, source.id), | |
| 730 | + ); | |
| 731 | + | |
| 732 | + if (Option.isNone(artifactOption)) { | |
| 733 | + continue; | |
| 734 | + } | |
| 735 | + | |
| 736 | + const descriptors = await Effect.runPromise( | |
| 737 | + openApiToolDescriptorsFromManifest(source, artifactOption.value.manifestJson), | |
| 738 | + ); | |
| 739 | + | |
| 740 | + for (const descriptor of descriptors) { | |
| 741 | + tools.push({ | |
| 742 | + descriptor, | |
| 743 | + source, | |
| 744 | + }); | |
| 745 | + } | |
| 746 | + } | |
| 747 | + | |
| 748 | + return tools; | |
| 749 | +}; | |
| 750 | + | |
| 44 | 751 | const executeTool = async (input: ExecuteToolInput): Promise<ExecuteToolResult> => { |
| 752 | + const context = await localPersistencePromise; | |
| 753 | + | |
| 754 | + let runnableTools: ReadonlyArray<RuntimeRunnableTool>; | |
| 755 | + try { | |
| 756 | + runnableTools = await collectRunnableTools(context); | |
| 757 | + } catch (cause) { | |
| 758 | + return { | |
| 759 | + isError: true, | |
| 760 | + error: errorToText(cause), | |
| 761 | + }; | |
| 762 | + } | |
| 763 | + | |
| 764 | + const providerRegistry = makeToolProviderRegistry([ | |
| 765 | + makeOpenApiToolProvider(), | |
| 766 | + makeBuiltInToolsProvider(context), | |
| 767 | + ]); | |
| 768 | + | |
| 45 | 769 | const result = await Effect.runPromise( |
| 46 | 770 | Effect.either( |
| 47 | 771 | runtimeRegistry |
| 48 | 772 | .execute({ |
| 49 | 773 | runtimeKind: input.runtimeKind ?? "local-inproc", |
| 50 | 774 | code: input.code, |
| 51 | − tools: [], | |
| 775 | + tools: runnableTools, | |
| 52 | 776 | }) |
| 53 | − .pipe(Effect.provideService(ToolProviderRegistryService, toolRegistry)), | |
| 777 | + .pipe(Effect.provideService(ToolProviderRegistryService, providerRegistry)), | |
| 54 | 778 | ), |
| 55 | 779 | ); |
| 56 | 780 | |
| @@ -79,7 +803,16 @@ const server = Bun.serve({ · 22 unmodified lines | ||
| 79 | 803 | port, |
| 80 | 804 | routes: { |
| 81 | 805 | "/healthz": { |
| 82 | − GET: () => Response.json({ ok: true, service: "pm" }, { status: 200 }), | |
| 806 | + GET: () => | |
| 807 | + Response.json( | |
| 808 | + { | |
| 809 | + ok: true, | |
| 810 | + service: "pm", | |
| 811 | + workspaceId, | |
| 812 | + stateRootDir, | |
| 813 | + }, | |
| 814 | + { status: 200 }, | |
| 815 | + ), | |
| 83 | 816 | }, |
| 84 | 817 | "/mcp": { |
| 85 | 818 | GET: handleMcp, |
| @@ -95,3 +828,4 @@ const server = Bun.serve({ · 9 unmodified lines | ||
| 95 | 828 | }); |
| 96 | 829 | |
| 97 | 830 | console.log(`executor-v2 PM listening on http://127.0.0.1:${server.port}`); |
| 831 | +console.log(`executor-v2 PM state dir: ${stateRootDir}`); | |
| @@ -25,8 +25,13 @@ · 24 unmodified lines | ||
| 25 | 25 | "apps/pm": { |
| 26 | 26 | "name": "@executor-v2/app-pm", |
| 27 | 27 | "dependencies": { |
| 28 | + "@effect/platform-bun": "^0.86.0", | |
| 28 | 29 | "@executor-v2/engine": "workspace:*", |
| 29 | 30 | "@executor-v2/mcp-gateway": "workspace:*", |
| 31 | + "@executor-v2/persistence-local": "workspace:*", | |
| 32 | + "@executor-v2/persistence-ports": "workspace:*", | |
| 33 | + "@executor-v2/schema": "workspace:*", | |
| 34 | + "@executor-v2/source-manager": "workspace:*", | |
| 30 | 35 | "effect": "^3.19.8", |
| 31 | 36 | }, |
| 32 | 37 | }, |
| @@ -6,6 +6,7 @@ Current scaffold includes: · 5 unmodified lines | ||
| 6 | 6 | - provider contracts (`ToolProvider`) and canonical tool descriptor model |
| 7 | 7 | - provider registry service (`ToolProviderRegistryService`) with `discover`/`invoke` routing |
| 8 | 8 | - OpenAPI provider invocation and manifest-to-descriptor conversion helpers |
| 9 | +- source-config-aware OpenAPI auth header injection (`api_key` / `bearer`) | |
| 9 | 10 | - minimal in-process JavaScript runner with `tools.*` proxy dispatch into provider registry |
| 10 | 11 | - Deno subprocess runner with line-delimited JSON IPC for proxied `tools.*` calls |
| 11 | 12 | - runtime adapter contract + registry (`local-inproc`, `deno-subprocess`, `cloudflare-worker-loader` scaffold) |
| @@ -102,12 +102,36 @@ const missingToolBindingError = (toolId: string): DenoSubprocessRunnerError => · 101 unmodified lines | ||
| 102 | 102 | details: null, |
| 103 | 103 | }); |
| 104 | 104 | |
| 105 | −const toolCallFailedError = (toolId: string): DenoSubprocessRunnerError => | |
| 106 | − new DenoSubprocessRunnerError({ | |
| 105 | +const formatToolErrorDetails = (output: unknown): string | null => { | |
| 106 | + if (output === undefined) { | |
| 107 | + return null; | |
| 108 | + } | |
| 109 | + | |
| 110 | + if (typeof output === "string") { | |
| 111 | + return output; | |
| 112 | + } | |
| 113 | + | |
| 114 | + try { | |
| 115 | + const serialized = JSON.stringify(output, null, 2); | |
| 116 | + return typeof serialized === "string" ? serialized : String(output); | |
| 117 | + } catch { | |
| 118 | + return String(output); | |
| 119 | + } | |
| 120 | +}; | |
| 121 | + | |
| 122 | +const toolCallFailedError = ( | |
| 123 | + toolId: string, | |
| 124 | + output: unknown, | |
| 125 | +): DenoSubprocessRunnerError => { | |
| 126 | + const details = formatToolErrorDetails(output); | |
| 127 | + const baseMessage = `Tool call returned error: ${toolId}`; | |
| 128 | + | |
| 129 | + return new DenoSubprocessRunnerError({ | |
| 107 | 130 | operation: "invoke_tool", |
| 108 | − message: `Tool call returned error: ${toolId}`, | |
| 109 | − details: null, | |
| 131 | + message: details ? `${baseMessage}\n${details}` : baseMessage, | |
| 132 | + details, | |
| 110 | 133 | }); |
| 134 | +}; | |
| 111 | 135 | |
| 112 | 136 | const parseWorkerMessageError = ( |
| 113 | 137 | line: string, |
| @@ -231,11 +255,12 @@ const handleToolCall = ( · 117 unmodified lines | ||
| 231 | 255 | }); |
| 232 | 256 | |
| 233 | 257 | if (invokeResult.isError) { |
| 258 | + const error = toolCallFailedError(message.toolId, invokeResult.output); | |
| 234 | 259 | yield* writeMessage(stdin, { |
| 235 | 260 | type: "tool_result", |
| 236 | 261 | requestId: message.requestId, |
| 237 | 262 | ok: false, |
| 238 | − error: toolCallFailedError(message.toolId).message, | |
| 263 | + error: error.message, | |
| 239 | 264 | }); |
| 240 | 265 | return; |
| 241 | 266 | } |
| @@ -30,10 +30,37 @@ const createToolCaller = (toolId) => (args) => · 29 unmodified lines | ||
| 30 | 30 | }); |
| 31 | 31 | }); |
| 32 | 32 | |
| 33 | +const assignToolCaller = (toolsRoot, toolId, callTool) => { | |
| 34 | + toolsRoot[toolId] = callTool; | |
| 35 | + | |
| 36 | + const segments = toolId.split(".").filter((segment) => segment.length > 0); | |
| 37 | + if (segments.length <= 1) { | |
| 38 | + return; | |
| 39 | + } | |
| 40 | + | |
| 41 | + let cursor = toolsRoot; | |
| 42 | + for (let index = 0; index < segments.length - 1; index += 1) { | |
| 43 | + const segment = segments[index]; | |
| 44 | + const current = cursor[segment]; | |
| 45 | + | |
| 46 | + if (!current || typeof current !== "object" || Array.isArray(current)) { | |
| 47 | + const next = Object.create(null); | |
| 48 | + cursor[segment] = next; | |
| 49 | + cursor = next; | |
| 50 | + continue; | |
| 51 | + } | |
| 52 | + | |
| 53 | + cursor = current; | |
| 54 | + } | |
| 55 | + | |
| 56 | + const leafSegment = segments[segments.length - 1]; | |
| 57 | + cursor[leafSegment] = callTool; | |
| 58 | +}; | |
| 59 | + | |
| 33 | 60 | const runUserCode = async (code, toolIds) => { |
| 34 | 61 | const tools = Object.create(null); |
| 35 | 62 | for (const toolId of toolIds) { |
| 36 | − tools[toolId] = createToolCaller(toolId); | |
| 63 | + assignToolCaller(tools, toolId, createToolCaller(toolId)); | |
| 37 | 64 | } |
| 38 | 65 | |
| 39 | 66 | const execute = new Function( |
| @@ -33,19 +33,53 @@ const duplicateToolIdError = (toolId: string): LocalCodeRunnerError => · 32 unmodified lines | ||
| 33 | 33 | details: null, |
| 34 | 34 | }); |
| 35 | 35 | |
| 36 | −const toolCallFailedError = (toolId: string): LocalCodeRunnerError => | |
| 37 | − new LocalCodeRunnerError({ | |
| 36 | +const formatToolErrorDetails = (output: unknown): string | null => { | |
| 37 | + if (output === undefined) { | |
| 38 | + return null; | |
| 39 | + } | |
| 40 | + | |
| 41 | + if (typeof output === "string") { | |
| 42 | + return output; | |
| 43 | + } | |
| 44 | + | |
| 45 | + try { | |
| 46 | + const serialized = JSON.stringify(output, null, 2); | |
| 47 | + return typeof serialized === "string" ? serialized : String(output); | |
| 48 | + } catch { | |
| 49 | + return String(output); | |
| 50 | + } | |
| 51 | +}; | |
| 52 | + | |
| 53 | +const toolCallFailedError = ( | |
| 54 | + toolId: string, | |
| 55 | + output: unknown, | |
| 56 | +): LocalCodeRunnerError => { | |
| 57 | + const details = formatToolErrorDetails(output); | |
| 58 | + const baseMessage = `Tool call returned error: ${toolId}`; | |
| 59 | + | |
| 60 | + return new LocalCodeRunnerError({ | |
| 38 | 61 | operation: "invoke_tool", |
| 39 | − message: `Tool call returned error: ${toolId}`, | |
| 40 | − details: null, | |
| 62 | + message: details ? `${baseMessage}\n${details}` : baseMessage, | |
| 63 | + details, | |
| 41 | 64 | }); |
| 65 | +}; | |
| 42 | 66 | |
| 43 | −const toExecutionError = (cause: unknown): LocalCodeRunnerError => | |
| 44 | − new LocalCodeRunnerError({ | |
| 67 | +const toExecutionError = (cause: unknown): LocalCodeRunnerError => { | |
| 68 | + let details = cause instanceof Error ? cause.stack ?? cause.message : String(cause); | |
| 69 | + | |
| 70 | + if (typeof cause === "object" && cause !== null && "details" in cause) { | |
| 71 | + const causeDetails = (cause as { details?: unknown }).details; | |
| 72 | + if (typeof causeDetails === "string" && causeDetails.length > 0) { | |
| 73 | + details = `${details}\n${causeDetails}`; | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + return new LocalCodeRunnerError({ | |
| 45 | 78 | operation: "execute", |
| 46 | 79 | message: "JavaScript execution failed", |
| 47 | − details: cause instanceof Error ? cause.stack ?? cause.message : String(cause), | |
| 80 | + details, | |
| 48 | 81 | }); |
| 82 | +}; | |
| 49 | 83 | |
| 50 | 84 | const buildToolBindings = ( |
| 51 | 85 | tools: ReadonlyArray<RunnableTool>, |
| @@ -64,17 +98,47 @@ const buildToolBindings = ( · 12 unmodified lines | ||
| 64 | 98 | return byToolId; |
| 65 | 99 | }); |
| 66 | 100 | |
| 101 | +const assignToolBinding = ( | |
| 102 | + toolsRoot: Record<string, unknown>, | |
| 103 | + toolId: string, | |
| 104 | + callTool: (args: unknown) => Promise<unknown>, | |
| 105 | +): void => { | |
| 106 | + toolsRoot[toolId] = callTool; | |
| 107 | + | |
| 108 | + const segments = toolId.split(".").filter((segment) => segment.length > 0); | |
| 109 | + if (segments.length <= 1) { | |
| 110 | + return; | |
| 111 | + } | |
| 112 | + | |
| 113 | + let cursor: Record<string, unknown> = toolsRoot; | |
| 114 | + for (let index = 0; index < segments.length - 1; index += 1) { | |
| 115 | + const segment = segments[index]; | |
| 116 | + const current = cursor[segment]; | |
| 117 | + | |
| 118 | + if (typeof current !== "object" || current === null || Array.isArray(current)) { | |
| 119 | + const next = Object.create(null) as Record<string, unknown>; | |
| 120 | + cursor[segment] = next; | |
| 121 | + cursor = next; | |
| 122 | + continue; | |
| 123 | + } | |
| 124 | + | |
| 125 | + cursor = current as Record<string, unknown>; | |
| 126 | + } | |
| 127 | + | |
| 128 | + const leafSegment = segments[segments.length - 1]; | |
| 129 | + cursor[leafSegment] = callTool; | |
| 130 | +}; | |
| 131 | + | |
| 67 | 132 | const runJavaScript = ( |
| 68 | 133 | code: string, |
| 69 | − toolsObject: Record<string, (args: unknown) => Promise<unknown>>, | |
| 134 | + toolsObject: Record<string, unknown>, | |
| 70 | 135 | ): Effect.Effect<unknown, LocalCodeRunnerError> => |
| 71 | 136 | Effect.tryPromise({ |
| 72 | 137 | try: async () => { |
| 73 | 138 | const execute = new Function( |
| 74 | 139 | "tools", |
| 75 | 140 | `"use strict"; return (async () => {\n${code}\n})();`, |
| 76 | − ) as (tools: Record<string, (args: unknown) => Promise<unknown>>) => | |
| 77 | − Promise<unknown>; | |
| 141 | + ) as (tools: Record<string, unknown>) => Promise<unknown>; | |
| 78 | 142 | |
| 79 | 143 | return await execute(toolsObject); |
| 80 | 144 | }, |
| @@ -94,11 +158,10 @@ export const executeJavaScriptWithTools = ( · 13 unmodified lines | ||
| 94 | 158 | const runPromise = Runtime.runPromise(runtime); |
| 95 | 159 | const toolBindings = yield* buildToolBindings(input.tools); |
| 96 | 160 | |
| 97 | − const toolsObject: Record<string, (args: unknown) => Promise<unknown>> = | |
| 98 | − Object.create(null); | |
| 161 | + const toolsObject: Record<string, unknown> = Object.create(null); | |
| 99 | 162 | |
| 100 | 163 | for (const [toolId, binding] of toolBindings.entries()) { |
| 101 | − toolsObject[toolId] = (args: unknown) => | |
| 164 | + assignToolBinding(toolsObject, toolId, (args: unknown) => | |
| 102 | 165 | runPromise( |
| 103 | 166 | registry |
| 104 | 167 | .invoke({ |
| @@ -109,11 +172,12 @@ export const executeJavaScriptWithTools = ( · 4 unmodified lines | ||
| 109 | 172 | .pipe( |
| 110 | 173 | Effect.flatMap((result) => |
| 111 | 174 | result.isError |
| 112 | − ? toolCallFailedError(toolId) | |
| 175 | + ? toolCallFailedError(toolId, result.output) | |
| 113 | 176 | : Effect.succeed(result.output), |
| 114 | 177 | ), |
| 115 | 178 | ), |
| 116 | − ); | |
| 179 | + ), | |
| 180 | + ); | |
| 117 | 181 | } |
| 118 | 182 | |
| 119 | 183 | return yield* runJavaScript(input.code, toolsObject); |
| @@ -241,4 +241,128 @@ return await tools.getUser({ · 240 unmodified lines | ||
| 241 | 241 | expect(testServer.requests[0]?.apiKey).toBe("sk_test"); |
| 242 | 242 | }), |
| 243 | 243 | ); |
| 244 | + | |
| 245 | + it.scoped("uses configured source credentials when required header arg is omitted", () => | |
| 246 | + Effect.gen(function* () { | |
| 247 | + const testServer = yield* makeTestServer; | |
| 248 | + | |
| 249 | + const openApiSpec = { | |
| 250 | + openapi: "3.1.0", | |
| 251 | + paths: { | |
| 252 | + "/users/{userId}": { | |
| 253 | + get: { | |
| 254 | + operationId: "getUser", | |
| 255 | + parameters: [ | |
| 256 | + { | |
| 257 | + name: "userId", | |
| 258 | + in: "path", | |
| 259 | + required: true, | |
| 260 | + }, | |
| 261 | + { | |
| 262 | + name: "verbose", | |
| 263 | + in: "query", | |
| 264 | + }, | |
| 265 | + { | |
| 266 | + name: "x-api-key", | |
| 267 | + in: "header", | |
| 268 | + required: true, | |
| 269 | + }, | |
| 270 | + ], | |
| 271 | + }, | |
| 272 | + }, | |
| 273 | + }, | |
| 274 | + }; | |
| 275 | + | |
| 276 | + const source: Source = decodeSource({ | |
| 277 | + id: "src_openapi", | |
| 278 | + workspaceId: "ws_local", | |
| 279 | + name: "local-openapi", | |
| 280 | + kind: "openapi", | |
| 281 | + endpoint: testServer.baseUrl, | |
| 282 | + status: "connected", | |
| 283 | + enabled: true, | |
| 284 | + configJson: JSON.stringify({ | |
| 285 | + type: "openapi", | |
| 286 | + auth: { | |
| 287 | + mode: "api_key", | |
| 288 | + headerName: "x-api-key", | |
| 289 | + value: "sk_from_config", | |
| 290 | + }, | |
| 291 | + }), | |
| 292 | + sourceHash: null, | |
| 293 | + lastError: null, | |
| 294 | + createdAt: Date.now(), | |
| 295 | + updatedAt: Date.now(), | |
| 296 | + }); | |
| 297 | + | |
| 298 | + const artifactsByKey = new Map<string, ToolArtifact>(); | |
| 299 | + const artifactStore: ToolArtifactStore = { | |
| 300 | + getBySource: (workspaceId: Source["workspaceId"], sourceId: Source["id"]) => | |
| 301 | + Effect.succeed( | |
| 302 | + Option.fromNullable(artifactsByKey.get(`${workspaceId}:${sourceId}`)), | |
| 303 | + ), | |
| 304 | + upsert: (artifact: ToolArtifact) => | |
| 305 | + Effect.sync(() => { | |
| 306 | + artifactsByKey.set( | |
| 307 | + `${artifact.workspaceId}:${artifact.sourceId}`, | |
| 308 | + artifact, | |
| 309 | + ); | |
| 310 | + }), | |
| 311 | + }; | |
| 312 | + const sourceManager = makeSourceManagerService(artifactStore); | |
| 313 | + | |
| 314 | + const refreshResult = yield* sourceManager.refreshOpenApiArtifact({ | |
| 315 | + source, | |
| 316 | + openApiSpec, | |
| 317 | + }); | |
| 318 | + | |
| 319 | + const tools = yield* openApiToolDescriptorsFromManifest( | |
| 320 | + source, | |
| 321 | + refreshResult.artifact.manifestJson, | |
| 322 | + ); | |
| 323 | + | |
| 324 | + const getUserTool = tools.find((tool) => tool.toolId === "getUser"); | |
| 325 | + if (!getUserTool) { | |
| 326 | + throw new Error("expected getUser tool"); | |
| 327 | + } | |
| 328 | + | |
| 329 | + const registry = makeToolProviderRegistry([makeOpenApiToolProvider()]); | |
| 330 | + | |
| 331 | + const executionResult = yield* executeJavaScriptWithTools({ | |
| 332 | + code: ` | |
| 333 | +return await tools.getUser({ | |
| 334 | + userId: "u123", | |
| 335 | + verbose: "true" | |
| 336 | +}); | |
| 337 | +`, | |
| 338 | + tools: [ | |
| 339 | + { | |
| 340 | + descriptor: getUserTool, | |
| 341 | + source, | |
| 342 | + }, | |
| 343 | + ], | |
| 344 | + }).pipe( | |
| 345 | + Effect.provideService(ToolProviderRegistryService, registry), | |
| 346 | + ); | |
| 347 | + | |
| 348 | + const output = executionResult as { | |
| 349 | + status: number; | |
| 350 | + body: { | |
| 351 | + id: string; | |
| 352 | + verbose: boolean; | |
| 353 | + apiKey: string | null; | |
| 354 | + }; | |
| 355 | + }; | |
| 356 | + | |
| 357 | + expect(output.status).toBe(200); | |
| 358 | + expect(output.body.id).toBe("u123"); | |
| 359 | + expect(output.body.verbose).toBe(true); | |
| 360 | + expect(output.body.apiKey).toBe("sk_from_config"); | |
| 361 | + | |
| 362 | + expect(testServer.requests).toHaveLength(1); | |
| 363 | + expect(testServer.requests[0]?.path).toBe("/users/u123"); | |
| 364 | + expect(testServer.requests[0]?.query).toBe("?verbose=true"); | |
| 365 | + expect(testServer.requests[0]?.apiKey).toBe("sk_from_config"); | |
| 366 | + }), | |
| 367 | + ); | |
| 244 | 368 | }); |
| @@ -1,9 +1,11 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | OpenApiInvocationPayloadSchema, |
| 3 | + OpenApiSourceConfigSchema, | |
| 3 | 4 | OpenApiToolManifestSchema, |
| 4 | 5 | type CanonicalToolDescriptor, |
| 5 | 6 | type OpenApiCanonicalToolDescriptor, |
| 6 | 7 | type OpenApiInvocationPayload, |
| 8 | + type OpenApiSourceConfig, | |
| 7 | 9 | type OpenApiToolManifest, |
| 8 | 10 | type Source, |
| 9 | 11 | } from "@executor-v2/schema"; |
| @@ -27,6 +29,9 @@ const decodeOpenApiManifestJson = Schema.decodeUnknown( · 17 unmodified lines | ||
| 27 | 29 | const decodeOpenApiInvocationPayload = Schema.decodeUnknown( |
| 28 | 30 | OpenApiInvocationPayloadSchema, |
| 29 | 31 | ); |
| 32 | +const decodeOpenApiSourceConfigSync = Schema.decodeUnknownSync( | |
| 33 | + OpenApiSourceConfigSchema, | |
| 34 | +); | |
| 30 | 35 | const decodeOpenApiToolArgs = Schema.decodeUnknown(OpenApiToolArgsSchema); |
| 31 | 36 | const encodeUnknownToJson = Schema.encode(Schema.parseJson(Schema.Unknown)); |
| 32 | 37 | |
| @@ -63,6 +68,72 @@ const argsValueToString = (value: unknown): string => { · 30 unmodified lines | ||
| 63 | 68 | return String(value); |
| 64 | 69 | }; |
| 65 | 70 | |
| 71 | +const parseOpenApiSourceConfig = (source: Source): OpenApiSourceConfig | null => { | |
| 72 | + const trimmed = source.configJson.trim(); | |
| 73 | + if (trimmed.length === 0) { | |
| 74 | + return null; | |
| 75 | + } | |
| 76 | + | |
| 77 | + try { | |
| 78 | + const parsed = JSON.parse(trimmed) as unknown; | |
| 79 | + return decodeOpenApiSourceConfigSync(parsed); | |
| 80 | + } catch { | |
| 81 | + return null; | |
| 82 | + } | |
| 83 | +}; | |
| 84 | + | |
| 85 | +const hasHeaderCaseInsensitive = (headers: Headers, headerName: string): boolean => { | |
| 86 | + const target = headerName.toLowerCase(); | |
| 87 | + | |
| 88 | + for (const [key] of headers.entries()) { | |
| 89 | + if (key.toLowerCase() === target) { | |
| 90 | + return true; | |
| 91 | + } | |
| 92 | + } | |
| 93 | + | |
| 94 | + return false; | |
| 95 | +}; | |
| 96 | + | |
| 97 | +const applySourceConfiguredHeaders = ( | |
| 98 | + source: Source, | |
| 99 | + headers: Headers, | |
| 100 | +): Effect.Effect<void, ToolProviderError> => | |
| 101 | + Effect.gen(function* () { | |
| 102 | + const config = parseOpenApiSourceConfig(source); | |
| 103 | + if (!config) { | |
| 104 | + return; | |
| 105 | + } | |
| 106 | + | |
| 107 | + if (config.staticHeaders) { | |
| 108 | + for (const [key, value] of Object.entries(config.staticHeaders)) { | |
| 109 | + headers.set(key, value); | |
| 110 | + } | |
| 111 | + } | |
| 112 | + | |
| 113 | + const auth = config.auth; | |
| 114 | + if (!auth || auth.mode === "none") { | |
| 115 | + return; | |
| 116 | + } | |
| 117 | + | |
| 118 | + const credentialValue = auth.value?.trim(); | |
| 119 | + if (!credentialValue) { | |
| 120 | + return yield* new ToolProviderError({ | |
| 121 | + operation: "invoke.auth", | |
| 122 | + providerKind: "openapi", | |
| 123 | + message: "Configured source credential is missing a value", | |
| 124 | + details: source.id, | |
| 125 | + }); | |
| 126 | + } | |
| 127 | + | |
| 128 | + if (auth.mode === "api_key") { | |
| 129 | + const headerName = auth.headerName?.trim() || "x-api-key"; | |
| 130 | + headers.set(headerName, credentialValue); | |
| 131 | + return; | |
| 132 | + } | |
| 133 | + | |
| 134 | + headers.set("authorization", `Bearer ${credentialValue}`); | |
| 135 | + }); | |
| 136 | + | |
| 66 | 137 | const replacePathTemplate = ( |
| 67 | 138 | pathTemplate: string, |
| 68 | 139 | args: OpenApiToolArgs, |
| @@ -118,6 +189,7 @@ const buildFetchRequest = ( · 49 unmodified lines | ||
| 118 | 189 | const url = new URL(resolvedPath, source.endpoint); |
| 119 | 190 | |
| 120 | 191 | const headers = new Headers(); |
| 192 | + yield* applySourceConfiguredHeaders(source, headers); | |
| 121 | 193 | const cookieParts: Array<string> = []; |
| 122 | 194 | |
| 123 | 195 | for (const parameter of payload.parameters) { |
| @@ -128,6 +200,13 @@ const buildFetchRequest = ( · 4 unmodified lines | ||
| 128 | 200 | const parameterValue = args[parameter.name]; |
| 129 | 201 | if (parameterValue === undefined || parameterValue === null) { |
| 130 | 202 | if (parameter.required) { |
| 203 | + if ( | |
| 204 | + parameter.location === "header" && | |
| 205 | + hasHeaderCaseInsensitive(headers, parameter.name) | |
| 206 | + ) { | |
| 207 | + continue; | |
| 208 | + } | |
| 209 | + | |
| 131 | 210 | return yield* new ToolProviderError({ |
| 132 | 211 | operation: "invoke.validate_args", |
| 133 | 212 | providerKind: "openapi", |
| @@ -35,6 +35,18 @@ const sumToolDescriptor: CanonicalToolDescriptor = decodeCanonicalToolDescriptor · 34 unmodified lines | ||
| 35 | 35 | providerPayload: null, |
| 36 | 36 | }); |
| 37 | 37 | |
| 38 | +const failToolDescriptor: CanonicalToolDescriptor = decodeCanonicalToolDescriptor({ | |
| 39 | + providerKind: "in_memory", | |
| 40 | + sourceId: null, | |
| 41 | + workspaceId: null, | |
| 42 | + toolId: "fail", | |
| 43 | + name: "fail", | |
| 44 | + description: null, | |
| 45 | + invocationMode: "in_memory", | |
| 46 | + availability: "local_only", | |
| 47 | + providerPayload: null, | |
| 48 | +}); | |
| 49 | + | |
| 38 | 50 | describe("runtime adapters", () => { |
| 39 | 51 | it.effect("executes with local-inproc adapter via runtime registry", () => |
| 40 | 52 | Effect.gen(function* () { |
| @@ -67,6 +79,47 @@ describe("runtime adapters", () => { · 26 unmodified lines | ||
| 67 | 79 | }), |
| 68 | 80 | ); |
| 69 | 81 | |
| 82 | + it.effect("surfaces tool-provider error output details for local-inproc", () => | |
| 83 | + Effect.gen(function* () { | |
| 84 | + const failingProvider: ToolProvider = { | |
| 85 | + kind: "in_memory", | |
| 86 | + invoke: () => | |
| 87 | + Effect.succeed({ | |
| 88 | + output: { | |
| 89 | + status: 400, | |
| 90 | + body: { | |
| 91 | + message: "domain is required", | |
| 92 | + }, | |
| 93 | + }, | |
| 94 | + isError: true, | |
| 95 | + } as const), | |
| 96 | + }; | |
| 97 | + | |
| 98 | + const toolRegistry = makeToolProviderRegistry([failingProvider]); | |
| 99 | + const runtimeRegistry = makeRuntimeAdapterRegistry([ | |
| 100 | + makeLocalInProcessRuntimeAdapter(), | |
| 101 | + ]); | |
| 102 | + | |
| 103 | + const result = yield* Effect.either( | |
| 104 | + runtimeRegistry | |
| 105 | + .execute({ | |
| 106 | + runtimeKind: "local-inproc", | |
| 107 | + code: "return await tools.fail({});", | |
| 108 | + tools: [{ descriptor: failToolDescriptor, source: null }], | |
| 109 | + }) | |
| 110 | + .pipe(Effect.provideService(ToolProviderRegistryService, toolRegistry)), | |
| 111 | + ); | |
| 112 | + | |
| 113 | + expect(Either.isLeft(result)).toBe(true); | |
| 114 | + if (Either.isLeft(result)) { | |
| 115 | + if ("details" in result.left && typeof result.left.details === "string") { | |
| 116 | + expect(result.left.details).toContain("Tool call returned error: fail"); | |
| 117 | + expect(result.left.details).toContain("domain is required"); | |
| 118 | + } | |
| 119 | + } | |
| 120 | + }), | |
| 121 | + ); | |
| 122 | + | |
| 70 | 123 | it.effect("returns typed not-implemented error for cloudflare adapter", () => |
| 71 | 124 | Effect.gen(function* () { |
| 72 | 125 | const toolRegistry = makeToolProviderRegistry([]); |
| @@ -5,4 +5,5 @@ Bare-minimum MCP gateway for Executor v2. · 4 unmodified lines | ||
| 5 | 5 | Current scaffold includes: |
| 6 | 6 | - MCP server built with `@modelcontextprotocol/sdk` in `src/server.ts` |
| 7 | 7 | - streamable HTTP request handling (`handleMcpHttpRequest`) |
| 8 | −- tools: `executor.ping` and `executor.execute` (the execute tool is host-wired via callback) | |
| 8 | +- single top-level tool: `executor.execute` | |
| 9 | +- runtime/control operations are intended to flow through code execution via `tools.*` (for example `tools.executor.sources.*`) | |
| @@ -24,18 +24,13 @@ export type McpGatewayOptions = { · 23 unmodified lines | ||
| 24 | 24 | target: GatewayTarget; |
| 25 | 25 | serverName?: string; |
| 26 | 26 | serverVersion?: string; |
| 27 | − execute?: (input: ExecuteToolInput) => Promise<ExecuteToolResult>; | |
| 27 | + execute: (input: ExecuteToolInput) => Promise<ExecuteToolResult>; | |
| 28 | 28 | }; |
| 29 | 29 | |
| 30 | 30 | const DEFAULT_SERVER_NAME = "executor-v2"; |
| 31 | 31 | const DEFAULT_SERVER_VERSION = "0.0.0"; |
| 32 | −const STUB_TOOL_NAME = "executor.ping"; | |
| 33 | 32 | const EXECUTE_TOOL_NAME = "executor.execute"; |
| 34 | 33 | |
| 35 | −const PingToolInput = z.object({ | |
| 36 | − message: z.string().optional(), | |
| 37 | −}); | |
| 38 | − | |
| 39 | 34 | const ExecuteToolInputSchema = z.object({ |
| 40 | 35 | code: z.string(), |
| 41 | 36 | runtimeKind: z |
| @@ -49,60 +44,31 @@ const contentText = (value: unknown): string => { · 7 unmodified lines | ||
| 49 | 44 | } |
| 50 | 45 | |
| 51 | 46 | try { |
| 52 | − return JSON.stringify(value, null, 2); | |
| 47 | + const serialized = JSON.stringify(value, null, 2); | |
| 48 | + if (typeof serialized === "string") { | |
| 49 | + return serialized; | |
| 50 | + } | |
| 53 | 51 | } catch { |
| 54 | − return String(value); | |
| 52 | + // fall through | |
| 55 | 53 | } |
| 54 | + | |
| 55 | + return String(value); | |
| 56 | 56 | }; |
| 57 | 57 | |
| 58 | −const createStubMcpServer = (options: McpGatewayOptions): McpServer => { | |
| 58 | +const createMcpServer = (options: McpGatewayOptions): McpServer => { | |
| 59 | 59 | const mcp = new McpServer({ |
| 60 | 60 | name: options.serverName ?? DEFAULT_SERVER_NAME, |
| 61 | 61 | version: options.serverVersion ?? DEFAULT_SERVER_VERSION, |
| 62 | 62 | }); |
| 63 | 63 | |
| 64 | − mcp.registerTool( | |
| 65 | − STUB_TOOL_NAME, | |
| 66 | − { | |
| 67 | − description: "Stub MCP tool that replies with pong", | |
| 68 | − inputSchema: PingToolInput, | |
| 69 | − }, | |
| 70 | − async (input: { message?: string }) => { | |
| 71 | − const text = input.message | |
| 72 | − ? `pong (${options.target}) - ${input.message}` | |
| 73 | − : `pong (${options.target})`; | |
| 74 | − | |
| 75 | − return { | |
| 76 | − content: [ | |
| 77 | − { | |
| 78 | − type: "text" as const, | |
| 79 | − text, | |
| 80 | − }, | |
| 81 | − ], | |
| 82 | − isError: false, | |
| 83 | − }; | |
| 84 | − }, | |
| 85 | − ); | |
| 86 | − | |
| 87 | 64 | mcp.registerTool( |
| 88 | 65 | EXECUTE_TOOL_NAME, |
| 89 | 66 | { |
| 90 | − description: "Execute JavaScript against configured runtime adapter", | |
| 67 | + description: | |
| 68 | + "Execute JavaScript against configured runtime adapter. Runtime receives tools namespace, including executor source controls under tools.executor.sources.*.", | |
| 91 | 69 | inputSchema: ExecuteToolInputSchema, |
| 92 | 70 | }, |
| 93 | 71 | async (input: ExecuteToolInput) => { |
| 94 | − if (!options.execute) { | |
| 95 | − return { | |
| 96 | − content: [ | |
| 97 | − { | |
| 98 | − type: "text" as const, | |
| 99 | − text: "executor.execute is not configured for this gateway target", | |
| 100 | − }, | |
| 101 | − ], | |
| 102 | − isError: true, | |
| 103 | − }; | |
| 104 | − } | |
| 105 | − | |
| 106 | 72 | try { |
| 107 | 73 | const result = await options.execute(input); |
| 108 | 74 | return { |
| @@ -141,7 +107,7 @@ export const handleMcpHttpRequest = async ( · 32 unmodified lines | ||
| 141 | 107 | sessionIdGenerator: undefined, |
| 142 | 108 | enableJsonResponse: true, |
| 143 | 109 | }); |
| 144 | − const mcp = createStubMcpServer(options); | |
| 110 | + const mcp = createMcpServer(options); | |
| 145 | 111 | |
| 146 | 112 | try { |
| 147 | 113 | await mcp.connect(transport); |
| @@ -6,5 +6,6 @@ Current scaffold includes: · 5 unmodified lines | ||
| 6 | 6 | - branded domain IDs (`src/ids.ts`) |
| 7 | 7 | - shared enums and primitives (`src/enums.ts`, `src/common.ts`) |
| 8 | 8 | - core model schemas in `src/models/*` |
| 9 | +- source config schema for provider/runtime auth metadata in `src/models/source-config.ts` | |
| 9 | 10 | - core event envelope schema in `src/models/event-envelope.ts` |
| 10 | 11 | - local snapshot/WAL schemas live in `@executor-v2/persistence-local` |
| @@ -5,6 +5,7 @@ export * from "./ids"; · 4 unmodified lines | ||
| 5 | 5 | export * from "./models/profile"; |
| 6 | 6 | export * from "./models/workspace"; |
| 7 | 7 | export * from "./models/source"; |
| 8 | +export * from "./models/source-config"; | |
| 8 | 9 | export * from "./models/tool-artifact"; |
| 9 | 10 | export * from "./models/credential-ref"; |
| 10 | 11 | export * from "./models/oauth-token"; |
| @@ -0,0 +1,28 @@ | ||
| 1 | +import { Schema } from "effect"; | |
| 2 | + | |
| 3 | +export const OpenApiSourceAuthModeSchema = Schema.Literal( | |
| 4 | + "none", | |
| 5 | + "api_key", | |
| 6 | + "bearer", | |
| 7 | +); | |
| 8 | + | |
| 9 | +export const OpenApiSourceAuthSchema = Schema.Struct({ | |
| 10 | + mode: OpenApiSourceAuthModeSchema, | |
| 11 | + headerName: Schema.optional(Schema.String), | |
| 12 | + value: Schema.optional(Schema.String), | |
| 13 | +}); | |
| 14 | + | |
| 15 | +export const OpenApiSourceConfigSchema = Schema.Struct({ | |
| 16 | + type: Schema.Literal("openapi"), | |
| 17 | + auth: Schema.optional(OpenApiSourceAuthSchema), | |
| 18 | + staticHeaders: Schema.optional( | |
| 19 | + Schema.Record({ | |
| 20 | + key: Schema.String, | |
| 21 | + value: Schema.String, | |
| 22 | + }), | |
| 23 | + ), | |
| 24 | +}); | |
| 25 | + | |
| 26 | +export type OpenApiSourceAuthMode = typeof OpenApiSourceAuthModeSchema.Type; | |
| 27 | +export type OpenApiSourceAuth = typeof OpenApiSourceAuthSchema.Type; | |
| 28 | +export type OpenApiSourceConfig = typeof OpenApiSourceConfigSchema.Type; | |