History

feat: enhance PM and OpenAPI integration with source credential management

- 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.

Rhys Sullivan committed 8 months ago

Commit 785e08f1 parent aa5b69e22 files · +1217 −80Browse files

Unified · Split

22 files
  1. experiemental/approval-client.ts +6 −0
  2. experiemental/claude-mcp-pm.json +8 −0
  3. v2/.gitignore +2 −0
  4. v2/PLAN.md +7 −5
  5. v2/apps/convex/README.md +1 −1
  6. v2/apps/convex/convex/mcp.ts +4 −0
  7. v2/apps/pm/README.md +1 −0
  8. v2/apps/pm/package.json +5 −0
  9. v2/apps/pm/src/main.ts +740 −6
  10. v2/bun.lock +5 −0
  11. v2/packages/engine/README.md +1 −0
  12. v2/packages/engine/src/deno-subprocess-runner.ts +30 −5
  13. v2/packages/engine/src/deno-subprocess-worker.mjs +28 −1
  14. v2/packages/engine/src/local-runner.ts +79 −15
  15. v2/packages/engine/src/openapi-execution.test.ts +124 −0
  16. v2/packages/engine/src/openapi-provider.ts +79 −0
  17. v2/packages/engine/src/runtime-adapters.test.ts +53 −0
  18. v2/packages/mcp-gateway/README.md +2 −1
  19. v2/packages/mcp-gateway/src/server.ts +12 −46
  20. v2/packages/schema/README.md +1 −0
  21. v2/packages/schema/src/index.ts +1 −0
  22. v2/packages/schema/src/models/source-config.ts +28 −0
experiemental/approval-client.ts+6−0
@@ -25,6 +25,7 @@ interface ParsedArgs { · 24 unmodified lines
2525 }
2626
2727 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");
2829 const HOME_DIR = process.env.HOME ?? ".";
2930 const STATE_PATH = path.join(HOME_DIR, ".executor-lite-approvals", "mcp-state.json");
3031
@@ -47,6 +48,11 @@ function defaultAgentCommand(session: string): string { · 16 unmodified lines
4748 return `claude --strict-mcp-config --mcp-config ${shellQuote(DEFAULT_MCP_CONFIG_PATH)} --allowedTools ${shellQuote(allowedTools)} --permission-mode bypassPermissions`;
4849 }
4950
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+
5056 return session;
5157 }
5258
experiemental/claude-mcp-pm.json+8−0
@@ -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+}
v2/.gitignore+2−0
@@ -1 +1,3 @@
11 .reference/
2+.executor-v2/
3+apps/pm/.executor-v2/
v2/PLAN.md+7−5
@@ -410,23 +410,25 @@ Implemented in `v2` so far: · 409 unmodified lines
410410 - Deno subprocess runtime path with IPC tool-call proxying and tests
411411 - runtime adapter contract + registry scaffold in `engine` with mapped adapters (`local-inproc`, `deno-subprocess`, `cloudflare-worker-loader`)
412412 - 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)
413415 - vertical test: OpenAPI spec -> manifest -> execute code -> HTTP tool call
414416 - service-first wiring (`Context.Tag` + Layer) across source manager and persistence/local runtime orchestration
415417
416418 Not implemented yet:
417−- `tools.executor.sources.add/list/remove` end-to-end PM + MCP gateway wiring
418419 - approval adapter/state machine integration (pending/resume/deny persistence) in v2 runtime path
419420 - provider/runtime conformance suite beyond current baseline tests (timeouts, cancellation, pending approval)
420421 - Cloudflare worker loader adapter execution integration (current adapter is explicit scaffold/not-implemented)
421422 - MCP/GraphQL provider implementations
423+- secure secret-store-backed credential persistence (current local MVP stores credential value in source config)
422424 - full rpc/sdk adapter wiring and sync engine
423425
424426 ---
425427
426428 ## 19) Immediate next steps
427429
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).
432434 5. Extend PM execute lifecycle from single-run scaffold to persisted run state + streaming status.
v2/apps/convex/README.md+1−1
@@ -4,4 +4,4 @@ Convex remote server app scaffold for Executor v2. · 3 unmodified lines
44
55 Current scaffold includes:
66 - 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`
v2/apps/convex/convex/mcp.ts+4−0
@@ -6,4 +6,8 @@ export const mcpHandler = httpAction(async (_ctx, request) => · 5 unmodified lines
66 target: "remote",
77 serverName: "executor-v2-convex",
88 serverVersion: "0.0.0",
9+ execute: async () => ({
10+ isError: true,
11+ error: "Remote execute path is not wired yet",
12+ }),
913 }));
v2/apps/pm/README.md+1−0
@@ -7,3 +7,4 @@ Current scaffold includes: · 6 unmodified lines
77 - health endpoint at `GET /healthz`
88 - MCP tool routing via `@executor-v2/mcp-gateway`
99 - `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
v2/apps/pm/package.json+5−0
@@ -8,8 +8,13 @@ · 7 unmodified lines
88 "typecheck": "bunx tsc --noEmit -p tsconfig.json"
99 },
1010 "dependencies": {
11+ "@effect/platform-bun": "^0.86.0",
1112 "@executor-v2/engine": "workspace:*",
1213 "@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:*",
1318 "effect": "^3.19.8"
1419 }
1520 }
v2/apps/pm/src/main.ts+740−6
@@ -1,20 +1,147 @@
1+import { resolve } from "node:path";
2+
3+import * as BunContext from "@effect/platform-bun/BunContext";
14 import {
5+ ToolProviderError,
26 makeCloudflareWorkerLoaderRuntimeAdapter,
37 makeDenoSubprocessRuntimeAdapter,
48 makeLocalInProcessRuntimeAdapter,
9+ makeOpenApiToolProvider,
510 makeRuntimeAdapterRegistry,
611 makeToolProviderRegistry,
12+ openApiToolDescriptorsFromManifest,
713 ToolProviderRegistryService,
14+ type RuntimeRunnableTool,
15+ type ToolProvider,
816 } from "@executor-v2/engine";
917 import {
1018 handleMcpHttpRequest,
1119 type ExecuteToolInput,
1220 type ExecuteToolResult,
1321 } 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";
1444 import * as Effect from "effect/Effect";
1545 import * as Either from "effect/Either";
46+import * as Option from "effect/Option";
47+import * as Schema from "effect/Schema";
1648
1749 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+};
18145
19146 const runtimeRegistry = makeRuntimeAdapterRegistry([
20147 makeLocalInProcessRuntimeAdapter(),
@@ -22,11 +149,34 @@ const runtimeRegistry = makeRuntimeAdapterRegistry([ · 1 unmodified line
22149 makeCloudflareWorkerLoaderRuntimeAdapter(),
23150 ]);
24151
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+);
26164
27165 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+
28177 if (error instanceof Error) {
29− return error.message;
178+ const details = extractDetails(error);
179+ return details ? `${error.message}\n${details}` : error.message;
30180 }
31181
32182 if (
@@ -35,22 +185,596 @@ const errorToText = (error: unknown): string => { · 2 unmodified lines
35185 "message" in error &&
36186 typeof error.message === "string"
37187 ) {
38− return error.message;
188+ const details = extractDetails(error);
189+ return details ? `${error.message}\n${details}` : error.message;
39190 }
40191
41192 return String(error);
42193 };
43194
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+
44751 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+
45769 const result = await Effect.runPromise(
46770 Effect.either(
47771 runtimeRegistry
48772 .execute({
49773 runtimeKind: input.runtimeKind ?? "local-inproc",
50774 code: input.code,
51− tools: [],
775+ tools: runnableTools,
52776 })
53− .pipe(Effect.provideService(ToolProviderRegistryService, toolRegistry)),
777+ .pipe(Effect.provideService(ToolProviderRegistryService, providerRegistry)),
54778 ),
55779 );
56780
@@ -79,7 +803,16 @@ const server = Bun.serve({ · 22 unmodified lines
79803 port,
80804 routes: {
81805 "/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+ ),
83816 },
84817 "/mcp": {
85818 GET: handleMcp,
@@ -95,3 +828,4 @@ const server = Bun.serve({ · 9 unmodified lines
95828 });
96829
97830 console.log(`executor-v2 PM listening on http://127.0.0.1:${server.port}`);
831+console.log(`executor-v2 PM state dir: ${stateRootDir}`);
v2/bun.lock+5−0
@@ -25,8 +25,13 @@ · 24 unmodified lines
2525 "apps/pm": {
2626 "name": "@executor-v2/app-pm",
2727 "dependencies": {
28+ "@effect/platform-bun": "^0.86.0",
2829 "@executor-v2/engine": "workspace:*",
2930 "@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:*",
3035 "effect": "^3.19.8",
3136 },
3237 },
v2/packages/engine/README.md+1−0
@@ -6,6 +6,7 @@ Current scaffold includes: · 5 unmodified lines
66 - provider contracts (`ToolProvider`) and canonical tool descriptor model
77 - provider registry service (`ToolProviderRegistryService`) with `discover`/`invoke` routing
88 - OpenAPI provider invocation and manifest-to-descriptor conversion helpers
9+- source-config-aware OpenAPI auth header injection (`api_key` / `bearer`)
910 - minimal in-process JavaScript runner with `tools.*` proxy dispatch into provider registry
1011 - Deno subprocess runner with line-delimited JSON IPC for proxied `tools.*` calls
1112 - runtime adapter contract + registry (`local-inproc`, `deno-subprocess`, `cloudflare-worker-loader` scaffold)
v2/packages/engine/src/deno-subprocess-runner.ts+30−5
@@ -102,12 +102,36 @@ const missingToolBindingError = (toolId: string): DenoSubprocessRunnerError => · 101 unmodified lines
102102 details: null,
103103 });
104104
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({
107130 operation: "invoke_tool",
108− message: `Tool call returned error: ${toolId}`,
109− details: null,
131+ message: details ? `${baseMessage}\n${details}` : baseMessage,
132+ details,
110133 });
134+};
111135
112136 const parseWorkerMessageError = (
113137 line: string,
@@ -231,11 +255,12 @@ const handleToolCall = ( · 117 unmodified lines
231255 });
232256
233257 if (invokeResult.isError) {
258+ const error = toolCallFailedError(message.toolId, invokeResult.output);
234259 yield* writeMessage(stdin, {
235260 type: "tool_result",
236261 requestId: message.requestId,
237262 ok: false,
238− error: toolCallFailedError(message.toolId).message,
263+ error: error.message,
239264 });
240265 return;
241266 }
v2/packages/engine/src/deno-subprocess-worker.mjs+28−1
@@ -30,10 +30,37 @@ const createToolCaller = (toolId) => (args) => · 29 unmodified lines
3030 });
3131 });
3232
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+
3360 const runUserCode = async (code, toolIds) => {
3461 const tools = Object.create(null);
3562 for (const toolId of toolIds) {
36− tools[toolId] = createToolCaller(toolId);
63+ assignToolCaller(tools, toolId, createToolCaller(toolId));
3764 }
3865
3966 const execute = new Function(
v2/packages/engine/src/local-runner.ts+79−15
@@ -33,19 +33,53 @@ const duplicateToolIdError = (toolId: string): LocalCodeRunnerError => · 32 unmodified lines
3333 details: null,
3434 });
3535
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({
3861 operation: "invoke_tool",
39− message: `Tool call returned error: ${toolId}`,
40− details: null,
62+ message: details ? `${baseMessage}\n${details}` : baseMessage,
63+ details,
4164 });
65+};
4266
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({
4578 operation: "execute",
4679 message: "JavaScript execution failed",
47− details: cause instanceof Error ? cause.stack ?? cause.message : String(cause),
80+ details,
4881 });
82+};
4983
5084 const buildToolBindings = (
5185 tools: ReadonlyArray<RunnableTool>,
@@ -64,17 +98,47 @@ const buildToolBindings = ( · 12 unmodified lines
6498 return byToolId;
6599 });
66100
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+
67132 const runJavaScript = (
68133 code: string,
69− toolsObject: Record<string, (args: unknown) => Promise<unknown>>,
134+ toolsObject: Record<string, unknown>,
70135 ): Effect.Effect<unknown, LocalCodeRunnerError> =>
71136 Effect.tryPromise({
72137 try: async () => {
73138 const execute = new Function(
74139 "tools",
75140 `"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>;
78142
79143 return await execute(toolsObject);
80144 },
@@ -94,11 +158,10 @@ export const executeJavaScriptWithTools = ( · 13 unmodified lines
94158 const runPromise = Runtime.runPromise(runtime);
95159 const toolBindings = yield* buildToolBindings(input.tools);
96160
97− const toolsObject: Record<string, (args: unknown) => Promise<unknown>> =
98− Object.create(null);
161+ const toolsObject: Record<string, unknown> = Object.create(null);
99162
100163 for (const [toolId, binding] of toolBindings.entries()) {
101− toolsObject[toolId] = (args: unknown) =>
164+ assignToolBinding(toolsObject, toolId, (args: unknown) =>
102165 runPromise(
103166 registry
104167 .invoke({
@@ -109,11 +172,12 @@ export const executeJavaScriptWithTools = ( · 4 unmodified lines
109172 .pipe(
110173 Effect.flatMap((result) =>
111174 result.isError
112− ? toolCallFailedError(toolId)
175+ ? toolCallFailedError(toolId, result.output)
113176 : Effect.succeed(result.output),
114177 ),
115178 ),
116− );
179+ ),
180+ );
117181 }
118182
119183 return yield* runJavaScript(input.code, toolsObject);
v2/packages/engine/src/openapi-execution.test.ts+124−0
@@ -241,4 +241,128 @@ return await tools.getUser({ · 240 unmodified lines
241241 expect(testServer.requests[0]?.apiKey).toBe("sk_test");
242242 }),
243243 );
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+ );
244368 });
v2/packages/engine/src/openapi-provider.ts+79−0
@@ -1,9 +1,11 @@
11 import {
22 OpenApiInvocationPayloadSchema,
3+ OpenApiSourceConfigSchema,
34 OpenApiToolManifestSchema,
45 type CanonicalToolDescriptor,
56 type OpenApiCanonicalToolDescriptor,
67 type OpenApiInvocationPayload,
8+ type OpenApiSourceConfig,
79 type OpenApiToolManifest,
810 type Source,
911 } from "@executor-v2/schema";
@@ -27,6 +29,9 @@ const decodeOpenApiManifestJson = Schema.decodeUnknown( · 17 unmodified lines
2729 const decodeOpenApiInvocationPayload = Schema.decodeUnknown(
2830 OpenApiInvocationPayloadSchema,
2931 );
32+const decodeOpenApiSourceConfigSync = Schema.decodeUnknownSync(
33+ OpenApiSourceConfigSchema,
34+);
3035 const decodeOpenApiToolArgs = Schema.decodeUnknown(OpenApiToolArgsSchema);
3136 const encodeUnknownToJson = Schema.encode(Schema.parseJson(Schema.Unknown));
3237
@@ -63,6 +68,72 @@ const argsValueToString = (value: unknown): string => { · 30 unmodified lines
6368 return String(value);
6469 };
6570
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+
66137 const replacePathTemplate = (
67138 pathTemplate: string,
68139 args: OpenApiToolArgs,
@@ -118,6 +189,7 @@ const buildFetchRequest = ( · 49 unmodified lines
118189 const url = new URL(resolvedPath, source.endpoint);
119190
120191 const headers = new Headers();
192+ yield* applySourceConfiguredHeaders(source, headers);
121193 const cookieParts: Array<string> = [];
122194
123195 for (const parameter of payload.parameters) {
@@ -128,6 +200,13 @@ const buildFetchRequest = ( · 4 unmodified lines
128200 const parameterValue = args[parameter.name];
129201 if (parameterValue === undefined || parameterValue === null) {
130202 if (parameter.required) {
203+ if (
204+ parameter.location === "header" &&
205+ hasHeaderCaseInsensitive(headers, parameter.name)
206+ ) {
207+ continue;
208+ }
209+
131210 return yield* new ToolProviderError({
132211 operation: "invoke.validate_args",
133212 providerKind: "openapi",
v2/packages/engine/src/runtime-adapters.test.ts+53−0
@@ -35,6 +35,18 @@ const sumToolDescriptor: CanonicalToolDescriptor = decodeCanonicalToolDescriptor · 34 unmodified lines
3535 providerPayload: null,
3636 });
3737
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+
3850 describe("runtime adapters", () => {
3951 it.effect("executes with local-inproc adapter via runtime registry", () =>
4052 Effect.gen(function* () {
@@ -67,6 +79,47 @@ describe("runtime adapters", () => { · 26 unmodified lines
6779 }),
6880 );
6981
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+
70123 it.effect("returns typed not-implemented error for cloudflare adapter", () =>
71124 Effect.gen(function* () {
72125 const toolRegistry = makeToolProviderRegistry([]);
v2/packages/mcp-gateway/README.md+2−1
@@ -5,4 +5,5 @@ Bare-minimum MCP gateway for Executor v2. · 4 unmodified lines
55 Current scaffold includes:
66 - MCP server built with `@modelcontextprotocol/sdk` in `src/server.ts`
77 - 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.*`)
v2/packages/mcp-gateway/src/server.ts+12−46
@@ -24,18 +24,13 @@ export type McpGatewayOptions = { · 23 unmodified lines
2424 target: GatewayTarget;
2525 serverName?: string;
2626 serverVersion?: string;
27− execute?: (input: ExecuteToolInput) => Promise<ExecuteToolResult>;
27+ execute: (input: ExecuteToolInput) => Promise<ExecuteToolResult>;
2828 };
2929
3030 const DEFAULT_SERVER_NAME = "executor-v2";
3131 const DEFAULT_SERVER_VERSION = "0.0.0";
32−const STUB_TOOL_NAME = "executor.ping";
3332 const EXECUTE_TOOL_NAME = "executor.execute";
3433
35−const PingToolInput = z.object({
36− message: z.string().optional(),
37−});
38−
3934 const ExecuteToolInputSchema = z.object({
4035 code: z.string(),
4136 runtimeKind: z
@@ -49,60 +44,31 @@ const contentText = (value: unknown): string => { · 7 unmodified lines
4944 }
5045
5146 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+ }
5351 } catch {
54− return String(value);
52+ // fall through
5553 }
54+
55+ return String(value);
5656 };
5757
58−const createStubMcpServer = (options: McpGatewayOptions): McpServer => {
58+const createMcpServer = (options: McpGatewayOptions): McpServer => {
5959 const mcp = new McpServer({
6060 name: options.serverName ?? DEFAULT_SERVER_NAME,
6161 version: options.serverVersion ?? DEFAULT_SERVER_VERSION,
6262 });
6363
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−
8764 mcp.registerTool(
8865 EXECUTE_TOOL_NAME,
8966 {
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.*.",
9169 inputSchema: ExecuteToolInputSchema,
9270 },
9371 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−
10672 try {
10773 const result = await options.execute(input);
10874 return {
@@ -141,7 +107,7 @@ export const handleMcpHttpRequest = async ( · 32 unmodified lines
141107 sessionIdGenerator: undefined,
142108 enableJsonResponse: true,
143109 });
144− const mcp = createStubMcpServer(options);
110+ const mcp = createMcpServer(options);
145111
146112 try {
147113 await mcp.connect(transport);
v2/packages/schema/README.md+1−0
@@ -6,5 +6,6 @@ Current scaffold includes: · 5 unmodified lines
66 - branded domain IDs (`src/ids.ts`)
77 - shared enums and primitives (`src/enums.ts`, `src/common.ts`)
88 - core model schemas in `src/models/*`
9+- source config schema for provider/runtime auth metadata in `src/models/source-config.ts`
910 - core event envelope schema in `src/models/event-envelope.ts`
1011 - local snapshot/WAL schemas live in `@executor-v2/persistence-local`
v2/packages/schema/src/index.ts+1−0
@@ -5,6 +5,7 @@ export * from "./ids"; · 4 unmodified lines
55 export * from "./models/profile";
66 export * from "./models/workspace";
77 export * from "./models/source";
8+export * from "./models/source-config";
89 export * from "./models/tool-artifact";
910 export * from "./models/credential-ref";
1011 export * from "./models/oauth-token";
v2/packages/schema/src/models/source-config.ts+28−0
@@ -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;