History

Update tool source auth and runtime loading

Rhys Sullivan committed 8 months ago

Commit 5e836b31 parent 5a76db716 files · +387 −101Browse files

Unified · Split

16 files
  1. executor/packages/core/src/function-builders.ts +18 −0
  2. executor/packages/core/src/managed-runtime.ts +1 −1
  3. executor/packages/core/src/managed/runtime-bootstrap.ts +1 −7
  4. executor/packages/core/src/openapi-compaction.ts +8 −1
  5. executor/packages/core/src/openapi-typing.test.ts +186 −0
  6. executor/packages/core/src/openapi/schema-hints.ts +80 −10
  7. executor/packages/core/src/openapi/tool-builder.ts +2 −2
  8. executor/packages/core/src/tool-source-loaders/graphql-loader.ts +2 −2
  9. executor/packages/core/src/tool-source-loaders/mcp-loader.ts +2 −2
  10. executor/packages/core/src/tool-source-loaders/openapi-loader.ts +2 −2
  11. executor/packages/core/src/tool-sources.ts +7 −14
  12. executor/packages/core/src/tool/source-auth.test.ts +30 −21
  13. executor/packages/core/src/tool/source-auth.ts +39 −33
  14. executor/packages/core/src/tool/source-serialization.ts +0 −1
  15. executor/packages/core/src/tool/source-types.ts +3 −3
  16. executor/packages/core/src/types.ts +6 −2
executor/packages/core/src/function-builders.ts+18−0
@@ -4,6 +4,7 @@ import { · 3 unmodified lines
44 customMutation as convexCustomMutation,
55 customQuery as convexCustomQuery,
66 } from "convex-helpers/server/customFunctions";
7+import type { Id } from "../../database/convex/_generated/dataModel.d.ts";
78 import { internal as internalApi } from "../../database/convex/_generated/api";
89 import { action, internalQuery, mutation, query } from "../../database/convex/_generated/server";
910 import {
@@ -14,6 +15,16 @@ import { · 4 unmodified lines
1415 resolveAccountForRequest,
1516 } from "./identity";
1617
18+export type WorkspaceActionContext = {
19+ workspaceId: Id<"workspaces">;
20+ accountId: Id<"accounts">;
21+ provider: string;
22+ providerAccountId: string;
23+ role: string;
24+ sessionId?: string;
25+ clientId: "web" | "mcp";
26+};
27+
1728 export type OpenApiMethod = "GET" | "POST";
1829
1930 type QueryMethodOptions = {
@@ -71,6 +82,11 @@ async function requireAccountFromSession( · 51 unmodified lines
7182 return account;
7283 }
7384
85+function clientIdForSession(sessionId?: string): "web" | "mcp" {
86+ const normalizedSessionId = sessionId?.trim() ?? "";
87+ return normalizedSessionId.startsWith("mcp_") ? "mcp" : "web";
88+}
89+
7490 export const customQuery = convexCustomQuery(query, {
7591 args: {},
7692 input: async (_ctx, _args, _options: QueryMethodOptions) => ({
@@ -112,6 +128,8 @@ export const workspaceAction = convexCustomAction(action, { · 35 unmodified lines
112128 ctx: {
113129 ...access,
114130 workspaceId: args.workspaceId,
131+ sessionId: args.sessionId,
132+ clientId: clientIdForSession(args.sessionId),
115133 },
116134 args: {},
117135 };
executor/packages/core/src/managed-runtime.ts+1−1
@@ -9,7 +9,7 @@ import { runProcess } from "./managed/runtime-process"; · 8 unmodified lines
99
1010 const managedAnonymousAuthEnvFileName = "managed-anonymous-auth.json";
1111
12−type ManagedAnonymousAuthEnv = {
12+export type ManagedAnonymousAuthEnv = {
1313 ANONYMOUS_AUTH_PRIVATE_KEY_PEM: string;
1414 ANONYMOUS_AUTH_PUBLIC_KEY_PEM: string;
1515 MCP_API_KEY_SECRET: string;
executor/packages/core/src/managed/runtime-bootstrap.ts+1−7
@@ -4,7 +4,7 @@ import path from "node:path"; · 3 unmodified lines
44 import { generateKeyPairSync } from "node:crypto";
55 import { z } from "zod";
66
7−import type { ManagedRuntimeInfo } from "../managed-runtime";
7+import type { ManagedAnonymousAuthEnv, ManagedRuntimeInfo } from "../managed-runtime";
88 import { managedRuntimeVersions, pathExists } from "./runtime-info";
99 import { ensureConvexCliRuntime } from "./runtime-installation";
1010 import { runProcess } from "./runtime-process";
@@ -40,12 +40,6 @@ const BOOTSTRAP_REQUIRED_DEPENDENCIES: BootstrapDependency[] = [ · 29 unmodified lines
4040
4141 const managedAnonymousAuthEnvFileName = "managed-anonymous-auth.json";
4242
43−type ManagedAnonymousAuthEnv = {
44− ANONYMOUS_AUTH_PRIVATE_KEY_PEM: string;
45− ANONYMOUS_AUTH_PUBLIC_KEY_PEM: string;
46− MCP_API_KEY_SECRET: string;
47−};
48−
4943 function trimEnv(name: string): string | null {
5044 const value = process.env[name]?.trim();
5145 return value && value.length > 0 ? value : null;
executor/packages/core/src/openapi-compaction.ts+8−1
@@ -93,11 +93,18 @@ export function compactOpenApiPaths( · 92 unmodified lines
9393 ? toRecordOrEmpty(parsed.data.examples)
9494 : undefined;
9595
96+ const parameterSchema = includeParameterSchemas
97+ ? parameterSchemaFromEntry(entry)
98+ : {};
99+ const resolvedParameterSchema = includeParameterSchemas && resolveSchemaRefs
100+ ? resolveSchemaRef(parameterSchema, compSchemas)
101+ : parameterSchema;
102+
96103 return {
97104 name: parsed.data.name,
98105 in: location,
99106 required: location === "path" ? true : (parsed.data.required ?? false),
100− schema: includeParameterSchemas ? parameterSchemaFromEntry(entry) : {},
107+ schema: resolvedParameterSchema,
101108 ...(description.length > 0 ? { description } : {}),
102109 ...(parsed.data.deprecated !== undefined ? { deprecated: parsed.data.deprecated } : {}),
103110 ...(style.length > 0 ? { style } : {}),
executor/packages/core/src/openapi-typing.test.ts+186−0
@@ -196,6 +196,86 @@ describe("OpenAPI schema-first typing", () => { · 195 unmodified lines
196196 expect(fieldsParam?.examples).toEqual({ minimal: { value: ["name"] } });
197197 });
198198
199+ test("full profile resolves component refs inside parameter schemas", async () => {
200+ const spec: Record<string, unknown> = {
201+ openapi: "3.0.3",
202+ info: { title: "Include API", version: "1.0.0" },
203+ servers: [{ url: "https://api.example.com" }],
204+ components: {
205+ schemas: {
206+ IncludeEnum: {
207+ type: "string",
208+ enum: ["message.input_image.image_url", "message.output_text.logprobs"],
209+ },
210+ },
211+ },
212+ paths: {
213+ "/conversations/{conversation_id}/items/{item_id}": {
214+ get: {
215+ operationId: "getConversationItem",
216+ tags: ["conversations"],
217+ parameters: [
218+ { name: "conversation_id", in: "path", required: true, schema: { type: "string" } },
219+ { name: "item_id", in: "path", required: true, schema: { type: "string" } },
220+ {
221+ name: "include",
222+ in: "query",
223+ schema: {
224+ type: "array",
225+ items: { $ref: "#/components/schemas/IncludeEnum" },
226+ },
227+ },
228+ ],
229+ responses: {
230+ "200": {
231+ description: "ok",
232+ content: {
233+ "application/json": {
234+ schema: {
235+ type: "object",
236+ properties: {
237+ id: { type: "string" },
238+ },
239+ },
240+ },
241+ },
242+ },
243+ },
244+ },
245+ },
246+ },
247+ };
248+
249+ const prepared = await prepareOpenApiSpec(spec, "include", { includeDts: false, profile: "full" });
250+ const tools = buildOpenApiToolsFromPrepared(
251+ { type: "openapi", name: "include", spec, baseUrl: "https://api.example.com" },
252+ prepared,
253+ );
254+
255+ const getConversationItem = tools.find((tool) => tool.typing?.typedRef?.operationId === "getConversationItem");
256+ expect(getConversationItem).toBeDefined();
257+
258+ const inputSchema = (getConversationItem!.typing?.inputSchema ?? {}) as {
259+ properties?: Record<string, {
260+ properties?: Record<string, Record<string, unknown>>;
261+ }>;
262+ };
263+ const includeSchema = inputSchema.properties?.query?.properties?.include as {
264+ type?: string;
265+ items?: {
266+ type?: string;
267+ enum?: unknown[];
268+ };
269+ } | undefined;
270+
271+ expect(includeSchema?.type).toBe("array");
272+ expect(includeSchema?.items?.type).toBe("string");
273+ expect(includeSchema?.items?.enum).toEqual([
274+ "message.input_image.image_url",
275+ "message.output_text.logprobs",
276+ ]);
277+ });
278+
199279 test("full profile with dts sets typedRef for OpenAPI operations", async () => {
200280 const spec = makeLargeSpec(3);
201281 const prepared = await prepareOpenApiSpec(spec, "large", { includeDts: true, profile: "full" });
@@ -288,6 +368,112 @@ describe("OpenAPI schema-first typing", () => { · 86 unmodified lines
288368 expect(prepared.refHintTable?.DeepMeta).toContain("field_0");
289369 });
290370
371+ test("full profile resolves nested oneOf component refs for output schemas", async () => {
372+ const spec: Record<string, unknown> = {
373+ openapi: "3.0.3",
374+ info: { title: "Conversation API", version: "1.0.0" },
375+ servers: [{ url: "https://api.example.com" }],
376+ components: {
377+ schemas: {
378+ InputTextPart: {
379+ type: "object",
380+ properties: {
381+ type: { type: "string", enum: ["input_text"] },
382+ text: { type: "string" },
383+ },
384+ required: ["type", "text"],
385+ },
386+ OutputTextPart: {
387+ type: "object",
388+ properties: {
389+ type: { type: "string", enum: ["output_text"] },
390+ text: { type: "string" },
391+ },
392+ required: ["type", "text"],
393+ },
394+ MessageItem: {
395+ type: "object",
396+ properties: {
397+ type: { type: "string", enum: ["message"] },
398+ id: { type: "string" },
399+ content: {
400+ type: "array",
401+ items: {
402+ oneOf: [
403+ { $ref: "#/components/schemas/InputTextPart" },
404+ { $ref: "#/components/schemas/OutputTextPart" },
405+ ],
406+ },
407+ },
408+ },
409+ required: ["type", "id", "content"],
410+ },
411+ FunctionCallItem: {
412+ type: "object",
413+ properties: {
414+ type: { type: "string", enum: ["function_call"] },
415+ id: { type: "string" },
416+ call_id: { type: "string" },
417+ },
418+ required: ["type", "id", "call_id"],
419+ },
420+ },
421+ },
422+ paths: {
423+ "/conversations/{conversation_id}/items/{item_id}": {
424+ get: {
425+ operationId: "getConversationItem",
426+ tags: ["conversations"],
427+ parameters: [
428+ { name: "conversation_id", in: "path", required: true, schema: { type: "string" } },
429+ { name: "item_id", in: "path", required: true, schema: { type: "string" } },
430+ ],
431+ responses: {
432+ "200": {
433+ description: "ok",
434+ content: {
435+ "application/json": {
436+ schema: {
437+ oneOf: [
438+ { $ref: "#/components/schemas/MessageItem" },
439+ { $ref: "#/components/schemas/FunctionCallItem" },
440+ ],
441+ },
442+ },
443+ },
444+ },
445+ },
446+ },
447+ },
448+ },
449+ };
450+
451+ const prepared = await prepareOpenApiSpec(spec, "conversation", { includeDts: false, profile: "full" });
452+ const tools = buildOpenApiToolsFromPrepared(
453+ { type: "openapi", name: "conversation", spec, baseUrl: "https://api.example.com" },
454+ prepared,
455+ );
456+
457+ const getConversationItem = tools.find((tool) => tool.typing?.typedRef?.operationId === "getConversationItem");
458+ expect(getConversationItem).toBeDefined();
459+
460+ const outputSchema = (getConversationItem!.typing?.outputSchema ?? {}) as {
461+ oneOf?: Array<Record<string, unknown>>;
462+ };
463+ expect(Array.isArray(outputSchema.oneOf)).toBe(true);
464+
465+ const variants = outputSchema.oneOf ?? [];
466+ const variantPropertyLists = variants.map((variant) => Object.keys((variant.properties as Record<string, unknown>) ?? {}));
467+ expect(variantPropertyLists.some((keys) => keys.includes("content"))).toBe(true);
468+ expect(variantPropertyLists.some((keys) => keys.includes("call_id"))).toBe(true);
469+
470+ const messageVariant = variants.find((variant) => Object.keys((variant.properties as Record<string, unknown>) ?? {}).includes("content"));
471+ const messageProperties = (messageVariant?.properties as Record<string, unknown>) ?? {};
472+ const contentProperty = (messageProperties.content as Record<string, unknown>) ?? {};
473+ const itemOneOf = (((contentProperty.items as Record<string, unknown>)?.oneOf) ?? []) as Array<Record<string, unknown>>;
474+ expect(itemOneOf.some((entry) => Object.keys((entry.properties as Record<string, unknown>) ?? {}).includes("text"))).toBe(true);
475+ });
476+
291477 test("OpenAPI input hints compact allOf object intersections", async () => {
292478 const spec: Record<string, unknown> = {
293479 openapi: "3.0.3",
executor/packages/core/src/openapi/schema-hints.ts+80−10
@@ -7,6 +7,22 @@ function toRecordOrEmpty(value: unknown): Record<string, unknown> { · 6 unmodified lines
77 }
88
99 const COMPONENT_REF_INLINE_DEPTH = 2;
10+const MAX_SCHEMA_REF_RESOLVE_DEPTH = 10;
11+const schemaChildKeys = [
12+ "items",
13+ "additionalItems",
14+ "additionalProperties",
15+ "contains",
16+ "not",
17+ "if",
18+ "then",
19+ "else",
20+ "propertyNames",
21+ "unevaluatedProperties",
22+ "unevaluatedItems",
23+] as const;
24+const schemaArrayChildKeys = ["allOf", "anyOf", "oneOf", "prefixItems"] as const;
25+const schemaMapChildKeys = ["properties", "patternProperties", "dependentSchemas", "definitions", "$defs"] as const;
1026 const openApiParameterMetadataSchema = z.object({
1127 type: z.string().optional(),
1228 enum: z.array(z.unknown()).optional(),
@@ -101,18 +117,72 @@ export function resolveSchemaRef( · 88 unmodified lines
101117 schema: Record<string, unknown>,
102118 componentSchemas: Record<string, unknown>,
103119 ): Record<string, unknown> {
104− const ref = typeof schema.$ref === "string" ? schema.$ref : "";
105120 const prefix = "#/components/schemas/";
106− if (!ref.startsWith(prefix)) {
107− return schema;
108− }
109121
110− const key = ref.slice(prefix.length);
111− const resolved = toRecordOrEmpty(componentSchemas[key]);
112− if (Object.keys(resolved).length === 0) {
113− return schema;
114− }
115− return resolved;
122+ const resolveNode = (
123+ value: unknown,
124+ depth: number,
125+ activeRefs: Set<string>,
126+ ): unknown => {
127+ if (!value || typeof value !== "object") {
128+ return value;
129+ }
130+ if (depth > MAX_SCHEMA_REF_RESOLVE_DEPTH) {
131+ return value;
132+ }
133+
134+ const shape = toRecordOrEmpty(value);
135+ const ref = typeof shape.$ref === "string" ? shape.$ref : "";
136+ if (ref.startsWith(prefix) && !activeRefs.has(ref)) {
137+ const key = ref.slice(prefix.length);
138+ const resolved = toRecordOrEmpty(componentSchemas[key]);
139+ if (Object.keys(resolved).length > 0) {
140+ const siblingEntries = Object.entries(shape).filter(([entryKey]) => entryKey !== "$ref");
141+ const merged = siblingEntries.length > 0
142+ ? {
143+ ...resolved,
144+ ...Object.fromEntries(siblingEntries),
145+ }
146+ : resolved;
147+ const nextRefs = new Set(activeRefs);
148+ nextRefs.add(ref);
149+ return resolveNode(merged, depth + 1, nextRefs);
150+ }
151+ }
152+
153+ const nextShape: Record<string, unknown> = { ...shape };
154+
155+ for (const key of schemaChildKeys) {
156+ const child = shape[key];
157+ if (child && typeof child === "object") {
158+ nextShape[key] = resolveNode(child, depth + 1, activeRefs);
159+ }
160+ }
161+
162+ for (const key of schemaArrayChildKeys) {
163+ const child = shape[key];
164+ if (Array.isArray(child)) {
165+ nextShape[key] = child.map((entry) => resolveNode(entry, depth + 1, activeRefs));
166+ }
167+ }
168+
169+ for (const key of schemaMapChildKeys) {
170+ const childMap = toRecordOrEmpty(shape[key]);
171+ if (Object.keys(childMap).length === 0) {
172+ continue;
173+ }
174+ nextShape[key] = Object.fromEntries(
175+ Object.entries(childMap).map(([childKey, childValue]) => [
176+ childKey,
177+ resolveNode(childValue, depth + 1, activeRefs),
178+ ]),
179+ );
180+ }
181+
182+ return nextShape;
183+ };
184+
185+ return toRecordOrEmpty(resolveNode(schema, 0, new Set()));
116186 }
117187
118188 export function resolveRequestBodyRef(
executor/packages/core/src/openapi/tool-builder.ts+2−2
@@ -8,7 +8,7 @@ import { · 7 unmodified lines
88 responseTypeHintFromSchema,
99 } from "./schema-hints";
1010 import { buildOpenApiToolPath } from "./tool-path";
11−import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";
11+import { buildCredentialSpec, getCredentialSourceKey } from "../tool/source-auth";
1212 import { executeOpenApiRequest } from "../tool/source-execution";
1313 import type { OpenApiToolSourceConfig, PreparedOpenApiSpec } from "../tool/source-types";
1414 import type { ToolDefinition } from "../types";
@@ -127,7 +127,7 @@ export function buildOpenApiToolsFromPrepared( · 112 unmodified lines
127127 }
128128
129129 const effectiveAuth = config.auth ?? prepared.inferredAuth;
130− const authHeaders = buildStaticAuthHeaders(effectiveAuth);
130+ const authHeaders = {};
131131 const sourceLabel = `openapi:${config.name}`;
132132 const credentialSourceKey = getCredentialSourceKey(config);
133133 const credentialSpec = buildCredentialSpec(credentialSourceKey, effectiveAuth);
executor/packages/core/src/tool-source-loaders/graphql-loader.ts+2−2
@@ -16,7 +16,7 @@ import { · 15 unmodified lines
1616 executeGraphqlRequest,
1717 type GraphqlExecutionEnvelope,
1818 } from "../tool/source-execution";
19−import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";
19+import { buildCredentialSpec, getCredentialSourceKey } from "../tool/source-auth";
2020 import { sanitizeSegment } from "../tool/path-utils";
2121 import type { GraphqlToolSourceConfig } from "../tool/source-types";
2222 import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";
@@ -388,7 +388,7 @@ function toGraphqlEnvelope(value: unknown): GraphqlExecutionEnvelope { · 365 unmodified lines
388388 }
389389
390390 export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDefinition[]> {
391− const authHeaders = buildStaticAuthHeaders(config.auth);
391+ const authHeaders = {};
392392 const sourceKey = `graphql:${config.name}`;
393393 const credentialSpec = buildCredentialSpec(getCredentialSourceKey(config), config.auth);
394394 const sourceName = sanitizeSegment(config.name);
executor/packages/core/src/tool-source-loaders/mcp-loader.ts+2−2
@@ -1,6 +1,6 @@
11 import { z } from "zod";
22 import { connectMcp, extractMcpResult } from "../mcp-runtime";
3−import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";
3+import { buildCredentialSpec, getCredentialSourceKey } from "../tool/source-auth";
44 import { callMcpToolWithReconnect } from "../tool/source-execution";
55 import { sanitizeSegment } from "../tool/path-utils";
66 import type { McpToolSourceConfig } from "../tool/source-types";
@@ -40,7 +40,7 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 33 unmodified lines
4040 Object.entries(config.queryParams).map(([key, value]) => [key, String(value)]),
4141 )
4242 : undefined;
43− const authHeaders = buildStaticAuthHeaders(config.auth);
43+ const authHeaders = {};
4444 const discoveryHeaders = {
4545 ...authHeaders,
4646 ...(config.discoveryHeaders ?? {}),
executor/packages/core/src/tool-source-loaders/openapi-loader.ts+2−2
@@ -1,6 +1,6 @@
11 import { z } from "zod";
22 import { buildOpenApiToolsFromPrepared } from "../openapi/tool-builder";
3−import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";
3+import { buildCredentialSpec, getCredentialSourceKey } from "../tool/source-auth";
44 import {
55 buildPostmanToolPath,
66 extractPostmanBody,
@@ -119,7 +119,7 @@ async function loadPostmanCollectionTools( · 112 unmodified lines
119119 }
120120
121121 const sourceLabel = `catalog:${config.name}`;
122− const authHeaders = buildStaticAuthHeaders(config.auth);
122+ const authHeaders = {};
123123 const credentialSourceKey = getCredentialSourceKey(config);
124124 const credentialSpec = buildCredentialSpec(credentialSourceKey, config.auth);
125125 const readMethods = new Set(["get", "head", "options"]);
executor/packages/core/src/tool-sources.ts+7−14
@@ -26,15 +26,6 @@ export { parseGraphqlOperationPaths } from "./graphql/operation-paths"; · 25 unmodified lines
2626 export { rehydrateTools, serializeTools, type SerializedTool } from "./tool/source-serialization";
2727 export { buildOpenApiToolsFromPrepared } from "./openapi/tool-builder";
2828
29−export interface CompiledToolSourceArtifact {
30− version: "v1";
31− sourceType: ExternalToolSourceConfig["type"];
32− sourceName: string;
33− openApiSourceKey?: string;
34− openApiRefHintTable?: Record<string, string>;
35− tools: SerializedTool[];
36−}
37−
3829 const compiledToolSourceArtifactSchema = z.object({
3930 version: z.literal("v1"),
4031 sourceType: z.enum(["mcp", "openapi", "graphql"]),
@@ -44,6 +35,12 @@ const compiledToolSourceArtifactSchema = z.object({ · 3 unmodified lines
4435 tools: z.array(z.unknown()),
4536 });
4637
38+type CompiledToolSourceArtifactEnvelope = z.infer<typeof compiledToolSourceArtifactSchema>;
39+
40+export type CompiledToolSourceArtifact = Omit<CompiledToolSourceArtifactEnvelope, "tools"> & {
41+ tools: SerializedTool[];
42+};
43+
4744 export function parseCompiledToolSourceArtifact(value: unknown): Result<CompiledToolSourceArtifact, Error> {
4845 const parsedArtifact = compiledToolSourceArtifactSchema.safeParse(value);
4946 if (!parsedArtifact.success) {
@@ -60,11 +57,7 @@ export function parseCompiledToolSourceArtifact(value: unknown): Result<Compiled · 10 unmodified lines
6057 }
6158
6259 return Result.ok({
63− version: parsedArtifact.data.version,
64− sourceType: parsedArtifact.data.sourceType,
65− sourceName: parsedArtifact.data.sourceName,
66− openApiSourceKey: parsedArtifact.data.openApiSourceKey,
67− openApiRefHintTable: parsedArtifact.data.openApiRefHintTable,
60+ ...parsedArtifact.data,
6861 tools,
6962 });
7063 }
executor/packages/core/src/tool/source-auth.test.ts+30−21
@@ -2,22 +2,21 @@ import { expect, test } from "bun:test"; · 1 unmodified line
22 import {
33 buildCredentialAuthHeaders,
44 buildCredentialSpec,
5− buildStaticAuthHeaders,
6− readCredentialOverrideHeaders,
5+ normalizeCredentialAdditionalHeaders,
6+ readCredentialAdditionalHeaders,
77 } from "./source-auth";
88
9−test("buildStaticAuthHeaders returns static bearer header", () => {
10− const headers = buildStaticAuthHeaders({
11− type: "bearer",
12− mode: "static",
13− token: "secret-token",
14− });
15−
16− expect(headers).toEqual({ authorization: "Bearer secret-token" });
17−});
18−
19−test("buildCredentialSpec omits static auth and preserves account mode", () => {
9+test("buildCredentialSpec defaults mode to workspace and preserves account mode", () => {
2010 expect(buildCredentialSpec("source:test", { type: "none" })).toBeUndefined();
11+ expect(
12+ buildCredentialSpec("source:test", {
13+ type: "bearer",
14+ }),
15+ ).toEqual({
16+ sourceKey: "source:test",
17+ mode: "workspace",
18+ authType: "bearer",
19+ });
2120 expect(
2221 buildCredentialSpec("source:test", {
2322 type: "apiKey",
@@ -61,17 +60,27 @@ test("buildCredentialAuthHeaders supports basic auth aliases", () => { · 37 unmodified lines
6160 });
6261 });
6362
64−test("readCredentialOverrideHeaders trims keys and coerces values", () => {
65− const headers = readCredentialOverrideHeaders({
66− headers: {
67− " x-trace-id ": "trace-1",
68− "": "ignored",
69− "x-retry": 2,
70− },
71− });
63+test("readCredentialAdditionalHeaders trims keys and filters reserved names", () => {
64+ const headers = readCredentialAdditionalHeaders([
65+ { name: " x-trace-id ", value: "trace-1" },
66+ { name: "", value: "ignored" },
67+ { name: "authorization", value: "blocked" },
68+ { name: "x-retry", value: 2 },
69+ ]);
7270
7371 expect(headers).toEqual({
7472 "x-trace-id": "trace-1",
7573 "x-retry": "2",
7674 });
7775 });
76+
77+test("normalizeCredentialAdditionalHeaders deduplicates by header name", () => {
78+ const headers = normalizeCredentialAdditionalHeaders([
79+ { name: "x-tenant-id", value: "acme" },
80+ { name: "X-TENANT-ID", value: "acme-2" },
81+ ]);
82+
83+ expect(headers).toEqual([
84+ { name: "X-TENANT-ID", value: "acme-2" },
85+ ]);
86+});
executor/packages/core/src/tool/source-auth.ts+39−33
@@ -1,16 +1,25 @@
11 import type { OpenApiAuth } from "./source-types";
2−import type { ToolCredentialSpec } from "../types";
2+import type { CredentialAdditionalHeader, ToolCredentialAuthType, ToolCredentialSpec } from "../types";
33 import { z } from "zod";
44
55 export type CredentialHeaderAuthSpec = {
6− authType: "bearer" | "apiKey" | "basic";
6+ authType: ToolCredentialAuthType;
77 headerName?: string;
88 };
99
1010 const secretRecordSchema = z.record(z.unknown());
11−const credentialOverrideHeadersSchema = z.object({
12− headers: z.record(z.coerce.string()).optional(),
13−});
11+const credentialAdditionalHeadersSchema = z.array(z.object({
12+ name: z.string(),
13+ value: z.coerce.string(),
14+}));
15+const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
16+const RESERVED_HEADER_NAMES = new Set([
17+ "authorization",
18+ "cookie",
19+ "host",
20+ "content-length",
21+ "transfer-encoding",
22+]);
1423
1524 function toRecord(value: unknown): Record<string, unknown> {
1625 const parsed = secretRecordSchema.safeParse(value);
@@ -70,46 +79,43 @@ export function buildCredentialAuthHeaders( · 53 unmodified lines
7079 return { authorization: `Basic ${encoded}` };
7180 }
7281
73−export function readCredentialOverrideHeaders(value: unknown): Record<string, string> {
74− const parsed = credentialOverrideHeadersSchema.safeParse(value);
75− const rawHeaders = parsed.success ? (parsed.data.headers ?? {}) : {};
82+export function normalizeCredentialAdditionalHeaders(value: unknown): CredentialAdditionalHeader[] {
83+ const parsed = credentialAdditionalHeadersSchema.safeParse(value);
84+ const rawHeaders = parsed.success ? parsed.data : [];
7685
77− const normalized: Record<string, string> = {};
78− for (const [rawKey, rawValue] of Object.entries(rawHeaders)) {
79− const key = rawKey.trim();
80− if (!key) continue;
81− normalized[key] = rawValue;
86+ const dedupedByName = new Map<string, CredentialAdditionalHeader>();
87+ for (const entry of rawHeaders) {
88+ const name = entry.name.trim();
89+ const value = entry.value.trim();
90+ if (!name || !value) continue;
91+
92+ const normalizedName = name.toLowerCase();
93+ if (!HEADER_NAME_PATTERN.test(name)) continue;
94+ if (RESERVED_HEADER_NAMES.has(normalizedName)) continue;
95+
96+ dedupedByName.set(normalizedName, {
97+ name,
98+ value,
99+ });
82100 }
83101
84− return normalized;
102+ return [...dedupedByName.values()];
85103 }
86104
87−export function buildStaticAuthHeaders(auth?: OpenApiAuth): Record<string, string> {
88− if (!auth || auth.type === "none") return {};
89− const mode = auth.mode ?? "static";
90− if (mode !== "static") return {};
91−
92− if (auth.type === "basic") {
93− const username = auth.username ?? "";
94− const password = auth.password ?? "";
95− if (!username && !password) return {};
96− const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
97− return { authorization: `Basic ${encoded}` };
98− }
105+export function readCredentialAdditionalHeaders(value: unknown): Record<string, string> {
106+ const headers = normalizeCredentialAdditionalHeaders(value);
99107
100− if (auth.type === "bearer") {
101− if (!auth.token) return {};
102− return { authorization: `Bearer ${auth.token}` };
108+ const normalized: Record<string, string> = {};
109+ for (const header of headers) {
110+ normalized[header.name] = header.value;
103111 }
104112
105− if (!auth.value) return {};
106− return { [auth.header]: auth.value };
113+ return normalized;
107114 }
108115
109116 export function buildCredentialSpec(sourceKey: string, auth?: OpenApiAuth): ToolCredentialSpec | undefined {
110117 if (!auth || auth.type === "none") return undefined;
111− const mode = auth.mode ?? "static";
112− if (mode === "static") return undefined;
118+ const mode = auth.mode ?? "workspace";
113119
114120 if (auth.type === "bearer") {
115121 return {
executor/packages/core/src/tool/source-serialization.ts+0−1
@@ -90,7 +90,6 @@ const toolCredentialSpecSchema = z.object({ · 89 unmodified lines
9090 mode: z.enum(CREDENTIAL_SCOPE_TYPES),
9191 authType: z.enum(TOOL_CREDENTIAL_AUTH_TYPES),
9292 headerName: z.string().optional(),
93− staticSecretJson: z.record(z.unknown()).optional(),
9493 });
9594
9695 const invalidSerializedToolFallbackSchema = z.object({
executor/packages/core/src/tool/source-types.ts+3−3
@@ -16,9 +16,9 @@ export interface McpToolSourceConfig { · 15 unmodified lines
1616
1717 export type OpenApiAuth =
1818 | { type: "none" }
19− | { type: "basic"; mode?: "static" | "account" | "workspace" | "organization"; username?: string; password?: string }
20− | { type: "bearer"; mode?: "static" | "account" | "workspace" | "organization"; token?: string }
21− | { type: "apiKey"; mode?: "static" | "account" | "workspace" | "organization"; header: string; value?: string };
19+ | { type: "basic"; mode?: "account" | "workspace" | "organization" }
20+ | { type: "bearer"; mode?: "account" | "workspace" | "organization" }
21+ | { type: "apiKey"; mode?: "account" | "workspace" | "organization"; header: string };
2222
2323 export interface OpenApiToolSourceConfig {
2424 type: "openapi";
executor/packages/core/src/types.ts+6−2
@@ -74,6 +74,11 @@ export type CredentialScope = CredentialScopeType; · 73 unmodified lines
7474 export const CREDENTIAL_PROVIDERS = ["local-convex", "workos-vault"] as const;
7575 export type CredentialProvider = (typeof CREDENTIAL_PROVIDERS)[number];
7676
77+export interface CredentialAdditionalHeader {
78+ name: string;
79+ value: string;
80+}
81+
7782 export const TOOL_APPROVAL_MODES = ["auto", "required"] as const;
7883 export type ToolApprovalMode = (typeof TOOL_APPROVAL_MODES)[number];
7984
@@ -255,7 +260,7 @@ export interface CredentialRecord { · 175 unmodified lines
255260 organizationId: Id<"organizations">;
256261 workspaceId?: Id<"workspaces">;
257262 sourceKey: string;
258− overridesJson?: Record<string, unknown>;
263+ additionalHeaders: CredentialAdditionalHeader[];
259264 boundAuthFingerprint?: string;
260265 provider: CredentialProvider;
261266 secretJson: Record<string, unknown>;
@@ -402,7 +407,6 @@ export interface ToolCredentialSpec { · 140 unmodified lines
402407 mode: CredentialScope;
403408 authType: ToolCredentialAuthType;
404409 headerName?: string;
405− staticSecretJson?: Record<string, unknown>;
406410 }
407411
408412 export interface ResolvedToolCredential {