From 46e56f70b1ac8db20dce419fc5d19b7b166d4e75 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Tue, 21 Apr 2026 17:22:00 +0530 Subject: [PATCH 1/6] refactor(config): promote McpConnectionAuth into @executor/config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime-shape auth type lived in @executor/plugin-mcp while its file-shape counterpart (McpAuthConfig) lived in @executor/config. Splitting the pair across packages made the plugin import SECRET_REF_PREFIX back from @executor/config just to serialize auth — an inversion that made the forward and inverse translators hard to keep in sync. Co-locate both shapes next to each other in @executor/config/schema.ts. The plugin re-exports McpConnectionAuth from its types barrel so existing downstream consumers (apps/local migrate-connections, apps/cloud migrate-mcp-connections) are unchanged. No behavior change. --- packages/core/config/src/index.ts | 1 + packages/core/config/src/schema.ts | 33 ++++++++++++++++++++++++--- packages/plugins/mcp/src/sdk/types.ts | 30 +++++++----------------- 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/packages/core/config/src/index.ts b/packages/core/config/src/index.ts index e5aecce92..f87262b06 100644 --- a/packages/core/config/src/index.ts +++ b/packages/core/config/src/index.ts @@ -6,6 +6,7 @@ export { McpRemoteSourceConfig, McpStdioSourceConfig, McpAuthConfig, + McpConnectionAuth, SecretMetadata, ConfigHeaderValue, SECRET_REF_PREFIX, diff --git a/packages/core/config/src/schema.ts b/packages/core/config/src/schema.ts index 9c2e4ab80..450f85058 100644 --- a/packages/core/config/src/schema.ts +++ b/packages/core/config/src/schema.ts @@ -49,6 +49,21 @@ export type GraphqlSourceConfig = typeof GraphqlSourceConfig.Type; const StringMap = Schema.Record({ key: Schema.String, value: Schema.String }); +// --------------------------------------------------------------------------- +// MCP connection auth — two shapes. +// +// `McpAuthConfig` is the file shape: header `secret` is a +// `secret-public-ref:` string so the file is human-readable and +// stable across machines. +// +// `McpConnectionAuth` is the runtime shape handed to the plugin: header +// `secretId` is the bare id. Translators between the two shapes live in +// `./translate` — plugins never touch the file prefix directly. +// +// `oauth2` is identical in both shapes today: a stable pointer to an SDK +// Connection (`ctx.connections`) holding the token material. +// --------------------------------------------------------------------------- + export const McpAuthConfig = Schema.Union( Schema.Struct({ kind: Schema.Literal("none") }), Schema.Struct({ @@ -59,14 +74,26 @@ export const McpAuthConfig = Schema.Union( }), Schema.Struct({ kind: Schema.Literal("oauth2"), - /** Stable id of the SDK Connection holding access + refresh token - * material. Scope shadowing means the same id resolves per-user - * via the executor's innermost-wins lookup. */ connectionId: Schema.String, }), ); export type McpAuthConfig = typeof McpAuthConfig.Type; +export const McpConnectionAuth = Schema.Union( + Schema.Struct({ kind: Schema.Literal("none") }), + Schema.Struct({ + kind: Schema.Literal("header"), + headerName: Schema.String, + secretId: Schema.String, + prefix: Schema.optional(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("oauth2"), + connectionId: Schema.String, + }), +); +export type McpConnectionAuth = typeof McpConnectionAuth.Type; + export const McpRemoteSourceConfig = Schema.Struct({ kind: Schema.Literal("mcp"), transport: Schema.Literal("remote"), diff --git a/packages/plugins/mcp/src/sdk/types.ts b/packages/plugins/mcp/src/sdk/types.ts index d59ba2918..19c63b485 100644 --- a/packages/plugins/mcp/src/sdk/types.ts +++ b/packages/plugins/mcp/src/sdk/types.ts @@ -1,5 +1,7 @@ import { Schema } from "effect"; +import { McpConnectionAuth } from "@executor/config"; + // --------------------------------------------------------------------------- // Remote transport type // --------------------------------------------------------------------------- @@ -12,34 +14,18 @@ export const McpTransport = Schema.Literal("streamable-http", "sse", "stdio", "a export type McpTransport = typeof McpTransport.Type; // --------------------------------------------------------------------------- -// Connection auth (only applies to remote sources) -// -// `oauth2` is a thin pointer to an SDK Connection (`ctx.connections`) — -// the access/refresh secrets, expiry, DCR client info, and authorization- -// server discovery URLs all live on the connection row. Scope shadowing -// means the same `connectionId` resolves per-user via the executor's -// innermost-wins lookup. +// Connection auth — runtime shape. Paired with `McpAuthConfig` (file +// shape); both live in `@executor/config` so the forward+inverse +// translators can own the `secret-public-ref:` prefix in one place. +// Re-exported here for existing downstream consumers. // --------------------------------------------------------------------------- +export { McpConnectionAuth }; + /** JSON object loosely typed — used for opaque OAuth state we just round-trip. */ const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown }); export { JsonObject as McpJsonObject }; -export const McpConnectionAuth = Schema.Union( - Schema.Struct({ kind: Schema.Literal("none") }), - Schema.Struct({ - kind: Schema.Literal("header"), - headerName: Schema.String, - secretId: Schema.String, - prefix: Schema.optional(Schema.String), - }), - Schema.Struct({ - kind: Schema.Literal("oauth2"), - connectionId: Schema.String, - }), -); -export type McpConnectionAuth = typeof McpConnectionAuth.Type; - // --------------------------------------------------------------------------- // Stored source data — discriminated union on transport // --------------------------------------------------------------------------- From 43c66b617926626893f757a270285f5fd809b4ff Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Tue, 21 Apr 2026 17:30:55 +0530 Subject: [PATCH 2/6] =?UTF-8?q?feat(config):=20paired=20transforms=20for?= =?UTF-8?q?=20file=20=E2=86=94=20runtime=20auth=20and=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates every file↔runtime conversion into a single module (`packages/core/config/src/transform.ts`) holding four paired functions: headerToConfigValue ↔ headerFromConfigValue headersToConfigValues ↔ headersFromConfigValues mcpAuthToConfig ↔ mcpAuthFromConfig `mcpAuthToConfig` moves out of the MCP plugin (where it duplicated the file format and had to import SECRET_REF_PREFIX back from the config package) and joins its inverse here. The forward header translators move out of `sink.ts`; `sink.ts` re-exports them for existing consumers. A roundtrip test suite exercises every discriminant of each union and asserts `toConfig ∘ fromConfig === identity` and vice versa. A new auth kind added on one side without its pair now fails the roundtrip at test time rather than at a user's boot. --- packages/core/config/src/index.ts | 10 +- packages/core/config/src/schema.ts | 4 +- packages/core/config/src/sink.ts | 27 +--- packages/core/config/src/transform.test.ts | 171 +++++++++++++++++++++ packages/core/config/src/transform.ts | 108 +++++++++++++ packages/plugins/mcp/src/sdk/plugin.ts | 24 +-- 6 files changed, 297 insertions(+), 47 deletions(-) create mode 100644 packages/core/config/src/transform.test.ts create mode 100644 packages/core/config/src/transform.ts diff --git a/packages/core/config/src/index.ts b/packages/core/config/src/index.ts index f87262b06..c7f522e31 100644 --- a/packages/core/config/src/index.ts +++ b/packages/core/config/src/index.ts @@ -23,8 +23,14 @@ export { } from "./write"; export type { ConfigFileSink, ConfigFileSinkOptions } from "./sink"; +export { makeFileConfigSink } from "./sink"; + export { - makeFileConfigSink, headerToConfigValue, headersToConfigValues, -} from "./sink"; + headerFromConfigValue, + headersFromConfigValues, + mcpAuthToConfig, + mcpAuthFromConfig, + type PluginHeaderValue, +} from "./transform"; diff --git a/packages/core/config/src/schema.ts b/packages/core/config/src/schema.ts index 450f85058..6099f80fb 100644 --- a/packages/core/config/src/schema.ts +++ b/packages/core/config/src/schema.ts @@ -57,8 +57,8 @@ const StringMap = Schema.Record({ key: Schema.String, value: Schema.String }); // stable across machines. // // `McpConnectionAuth` is the runtime shape handed to the plugin: header -// `secretId` is the bare id. Translators between the two shapes live in -// `./translate` — plugins never touch the file prefix directly. +// `secretId` is the bare id. Transforms between the two shapes live in +// `./transform` — plugins never touch the file prefix directly. // // `oauth2` is identical in both shapes today: a stable pointer to an SDK // Connection (`ctx.connections`) holding the token material. diff --git a/packages/core/config/src/sink.ts b/packages/core/config/src/sink.ts index 9e657b240..c95484dff 100644 --- a/packages/core/config/src/sink.ts +++ b/packages/core/config/src/sink.ts @@ -15,29 +15,14 @@ import { Effect } from "effect"; import type { Layer } from "effect"; import type { FileSystem } from "@effect/platform"; -import { SECRET_REF_PREFIX, type ConfigHeaderValue, type SourceConfig } from "./schema"; +import type { SourceConfig } from "./schema"; import { addSourceToConfig, removeSourceFromConfig } from "./write"; -// Translate a plugin-side header value (`{ secretId, prefix? }` for secret -// refs) into the config file's `secret-public-ref:` string form. -type PluginHeaderValue = string | { secretId: string; prefix?: string }; - -export const headerToConfigValue = ( - value: PluginHeaderValue, -): ConfigHeaderValue => { - if (typeof value === "string") return value; - const ref = `${SECRET_REF_PREFIX}${value.secretId}`; - return value.prefix ? { value: ref, prefix: value.prefix } : ref; -}; - -export const headersToConfigValues = ( - headers: Record | undefined, -): Record | undefined => { - if (!headers) return undefined; - const out: Record = {}; - for (const [k, v] of Object.entries(headers)) out[k] = headerToConfigValue(v); - return out; -}; +export { + headerToConfigValue, + headersToConfigValues, + type PluginHeaderValue, +} from "./transform"; export interface ConfigFileSink { readonly upsertSource: (source: SourceConfig) => Effect.Effect; diff --git a/packages/core/config/src/transform.test.ts b/packages/core/config/src/transform.test.ts new file mode 100644 index 000000000..20d5c0906 --- /dev/null +++ b/packages/core/config/src/transform.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "@effect/vitest"; + +import type { + ConfigHeaderValue, + McpAuthConfig, + McpConnectionAuth, +} from "./schema"; +import { SECRET_REF_PREFIX } from "./schema"; +import { + headerFromConfigValue, + headerToConfigValue, + headersFromConfigValues, + headersToConfigValues, + mcpAuthFromConfig, + mcpAuthToConfig, + type PluginHeaderValue, +} from "./transform"; + +// --------------------------------------------------------------------------- +// Each fixture covers every branch of the corresponding union so a new +// discriminant without paired updates fails the roundtrip at test time, +// not at a user's boot time. +// --------------------------------------------------------------------------- + +const runtimeAuths: readonly McpConnectionAuth[] = [ + { kind: "none" }, + { + kind: "header", + headerName: "Authorization", + secretId: "posthog-api-key", + prefix: "Bearer ", + }, + { + kind: "header", + headerName: "X-Api-Key", + secretId: "plain-key", + }, + { kind: "oauth2", connectionId: "mcp-oauth2-linear" }, +]; + +const fileAuths: readonly McpAuthConfig[] = [ + { kind: "none" }, + { + kind: "header", + headerName: "Authorization", + secret: `${SECRET_REF_PREFIX}posthog-api-key`, + prefix: "Bearer ", + }, + { + kind: "header", + headerName: "X-Api-Key", + secret: `${SECRET_REF_PREFIX}plain-key`, + }, + { kind: "oauth2", connectionId: "mcp-oauth2-linear" }, +]; + +const runtimeHeaders: readonly PluginHeaderValue[] = [ + "static-value", + { secretId: "axiom-api-key" }, + { secretId: "axiom-api-key", prefix: "Bearer " }, +]; + +const fileHeaders: readonly ConfigHeaderValue[] = [ + "static-value", + `${SECRET_REF_PREFIX}axiom-api-key`, + { value: `${SECRET_REF_PREFIX}axiom-api-key`, prefix: "Bearer " }, +]; + +describe("mcp auth transform", () => { + it("mcpAuthToConfig ∘ mcpAuthFromConfig is identity on file shapes", () => { + for (const file of fileAuths) { + expect(mcpAuthToConfig(mcpAuthFromConfig(file))).toEqual(file); + } + }); + + it("mcpAuthFromConfig ∘ mcpAuthToConfig is identity on runtime shapes", () => { + for (const runtime of runtimeAuths) { + expect(mcpAuthFromConfig(mcpAuthToConfig(runtime))).toEqual(runtime); + } + }); + + it("strips secret-public-ref prefix on header inbound", () => { + expect( + mcpAuthFromConfig({ + kind: "header", + headerName: "Authorization", + secret: `${SECRET_REF_PREFIX}posthog-api-key`, + prefix: "Bearer ", + }), + ).toEqual({ + kind: "header", + headerName: "Authorization", + secretId: "posthog-api-key", + prefix: "Bearer ", + }); + }); + + it("adds secret-public-ref prefix on header outbound", () => { + expect( + mcpAuthToConfig({ + kind: "header", + headerName: "Authorization", + secretId: "posthog-api-key", + }), + ).toEqual({ + kind: "header", + headerName: "Authorization", + secret: `${SECRET_REF_PREFIX}posthog-api-key`, + prefix: undefined, + }); + }); + + it("preserves oauth2 connectionId without transformation", () => { + const runtime: McpConnectionAuth = { + kind: "oauth2", + connectionId: "mcp-oauth2-posthog", + }; + expect(mcpAuthToConfig(runtime)).toEqual(runtime); + expect(mcpAuthFromConfig(mcpAuthToConfig(runtime)!)).toEqual(runtime); + }); + + it("treats undefined as undefined on both sides", () => { + expect(mcpAuthToConfig(undefined)).toBeUndefined(); + expect(mcpAuthFromConfig(undefined)).toBeUndefined(); + }); +}); + +describe("header transform", () => { + it("headerToConfigValue ∘ headerFromConfigValue is identity on file shapes", () => { + for (const file of fileHeaders) { + expect(headerToConfigValue(headerFromConfigValue(file))).toEqual(file); + } + }); + + it("headerFromConfigValue ∘ headerToConfigValue is identity on runtime shapes", () => { + for (const runtime of runtimeHeaders) { + expect(headerFromConfigValue(headerToConfigValue(runtime))).toEqual(runtime); + } + }); + + it("strips prefix from bare string secret ref", () => { + expect(headerFromConfigValue(`${SECRET_REF_PREFIX}my-key`)).toEqual({ + secretId: "my-key", + }); + }); + + it("preserves literal string headers", () => { + expect(headerFromConfigValue("literal")).toBe("literal"); + expect(headerToConfigValue("literal")).toBe("literal"); + }); + + it("headersToConfigValues / headersFromConfigValues roundtrip a record", () => { + const file: Record = { + Authorization: { value: `${SECRET_REF_PREFIX}api-key`, prefix: "Bearer " }, + "X-Static": "literal", + "X-Bare-Ref": `${SECRET_REF_PREFIX}bare`, + }; + const runtime = headersFromConfigValues(file)!; + expect(runtime).toEqual({ + Authorization: { secretId: "api-key", prefix: "Bearer " }, + "X-Static": "literal", + "X-Bare-Ref": { secretId: "bare" }, + }); + expect(headersToConfigValues(runtime)).toEqual(file); + }); + + it("treats undefined records as undefined", () => { + expect(headersToConfigValues(undefined)).toBeUndefined(); + expect(headersFromConfigValues(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/core/config/src/transform.ts b/packages/core/config/src/transform.ts new file mode 100644 index 000000000..546774520 --- /dev/null +++ b/packages/core/config/src/transform.ts @@ -0,0 +1,108 @@ +// --------------------------------------------------------------------------- +// Transforms between file shapes (McpAuthConfig, ConfigHeaderValue) and +// runtime shapes (McpConnectionAuth, PluginHeaderValue). +// +// Paired: `xToConfig` writes the file, `xFromConfig` reads it. Kept in a +// single file so a new auth kind or header form has to touch both +// halves — and the roundtrip tests in `transform.test.ts` prove the +// pair is a true inverse. +// --------------------------------------------------------------------------- + +import { + SECRET_REF_PREFIX, + type ConfigHeaderValue, + type McpAuthConfig, + type McpConnectionAuth, +} from "./schema"; + +// --------------------------------------------------------------------------- +// Headers +// --------------------------------------------------------------------------- + +/** Runtime shape a plugin hands to its `addSource` / `addSpec` call — either + * a literal header value or a reference to a secret plus optional prefix. */ +export type PluginHeaderValue = string | { secretId: string; prefix?: string }; + +export const headerToConfigValue = ( + value: PluginHeaderValue, +): ConfigHeaderValue => { + if (typeof value === "string") return value; + const ref = `${SECRET_REF_PREFIX}${value.secretId}`; + return value.prefix ? { value: ref, prefix: value.prefix } : ref; +}; + +export const headersToConfigValues = ( + headers: Record | undefined, +): Record | undefined => { + if (!headers) return undefined; + const out: Record = {}; + for (const [k, v] of Object.entries(headers)) out[k] = headerToConfigValue(v); + return out; +}; + +const stripSecretRef = (value: string): string => + value.startsWith(SECRET_REF_PREFIX) + ? value.slice(SECRET_REF_PREFIX.length) + : value; + +export const headerFromConfigValue = ( + value: ConfigHeaderValue, +): PluginHeaderValue => { + if (typeof value === "string") { + return value.startsWith(SECRET_REF_PREFIX) + ? { secretId: stripSecretRef(value) } + : value; + } + if (value.value.startsWith(SECRET_REF_PREFIX)) { + return { secretId: stripSecretRef(value.value), prefix: value.prefix }; + } + return value.value; +}; + +export const headersFromConfigValues = ( + headers: Record | undefined, +): Record | undefined => { + if (!headers) return undefined; + const out: Record = {}; + for (const [k, v] of Object.entries(headers)) out[k] = headerFromConfigValue(v); + return out; +}; + +// --------------------------------------------------------------------------- +// MCP connection auth +// +// `none` and `oauth2` are identical across shapes — oauth2 stores only a +// stable `connectionId`, with token material off on the Connection row. +// `header` differs: file `secret` is `secret-public-ref:`; runtime +// `secretId` is bare. +// --------------------------------------------------------------------------- + +export const mcpAuthToConfig = ( + auth: McpConnectionAuth | undefined, +): McpAuthConfig | undefined => { + if (!auth) return undefined; + if (auth.kind === "header") { + return { + kind: "header", + headerName: auth.headerName, + secret: `${SECRET_REF_PREFIX}${auth.secretId}`, + prefix: auth.prefix, + }; + } + return auth; +}; + +export const mcpAuthFromConfig = ( + auth: McpAuthConfig | undefined, +): McpConnectionAuth | undefined => { + if (!auth) return undefined; + if (auth.kind === "header") { + return { + kind: "header", + headerName: auth.headerName, + secretId: stripSecretRef(auth.secret), + prefix: auth.prefix, + }; + } + return auth; +}; diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 841a563aa..48ea6a9e0 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -58,9 +58,8 @@ import { } from "./types"; import { - SECRET_REF_PREFIX, + mcpAuthToConfig, type ConfigFileSink, - type McpAuthConfig, type McpRemoteSourceConfig as McpRemoteConfigEntry, type McpStdioSourceConfig as McpStdioConfigEntry, type SourceConfig, @@ -497,25 +496,6 @@ export interface McpPluginOptions { readonly configFile?: ConfigFileSink; } -const secretRef = (id: string): string => `${SECRET_REF_PREFIX}${id}`; - -const authToConfig = (auth: McpConnectionAuth | undefined): McpAuthConfig | undefined => { - if (!auth) return undefined; - if (auth.kind === "none") return { kind: "none" }; - if (auth.kind === "header") { - return { - kind: "header", - headerName: auth.headerName, - secret: secretRef(auth.secretId), - prefix: auth.prefix, - }; - } - return { - kind: "oauth2", - connectionId: auth.connectionId, - }; -}; - const toMcpConfigEntry = ( namespace: string, sourceName: string, @@ -543,7 +523,7 @@ const toMcpConfigEntry = ( queryParams: config.queryParams, headers: config.headers, namespace, - auth: authToConfig(config.auth), + auth: mcpAuthToConfig(config.auth), }; return entry; }; From dbf28d722d97796c6cc654edc480b09357e19a68 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Tue, 21 Apr 2026 17:39:41 +0530 Subject: [PATCH 3/6] fix(local): replay MCP auth during config sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boot-time sync in apps/local silently dropped `auth` when replaying remote MCP sources from executor.jsonc. A file with `auth.kind = "header"` (or any auth other than "none") round-tripped through syncFromConfig would land in the DB with no auth, because the mcp.addSource call in `addSourceFromConfig` omitted the field. The plugin itself serializes auth correctly on write-back, so the drop was asymmetric: UI/updateSource writes reached the file, but next boot wiped the runtime state. Wires the file→runtime transform (`mcpAuthFromConfig`) added in the preceding commit into the remote MCP branch, and swaps the local header-translation helper for the shared `headersFromConfigValues`. The executor parameter is narrowed from `LocalExecutor` to `Pick` so tests can construct a minimal executor without pulling the full plugin set. Integration test covers the regression end-to-end: write an executor.jsonc with header auth, run syncFromConfig against an in-memory executor, assert `mcp.getSource(...)` returns the stored row with the runtime `secretId` (bare, no `secret-public-ref:` prefix). `kind: "none"` is covered alongside for parity; oauth2 replay is covered at the transform level in @executor/config where its runtime shape is identity, and the integration path would require seeding a Connection fixture that adds more setup than the coverage warrants. --- apps/local/src/server/config-sync.test.ts | 161 ++++++++++++++++++++++ apps/local/src/server/config-sync.ts | 56 ++------ 2 files changed, 175 insertions(+), 42 deletions(-) create mode 100644 apps/local/src/server/config-sync.test.ts diff --git a/apps/local/src/server/config-sync.test.ts b/apps/local/src/server/config-sync.test.ts new file mode 100644 index 000000000..16d79f6eb --- /dev/null +++ b/apps/local/src/server/config-sync.test.ts @@ -0,0 +1,161 @@ +// --------------------------------------------------------------------------- +// Integration test for boot-time config sync. +// +// Drives `syncFromConfig` against a real in-memory executor and asserts +// the replayed source lands in the DB with the correct runtime shape — +// specifically that remote MCP auth makes it through the file→runtime +// transform intact. Covers the regression class where an auth field is +// silently dropped somewhere along the replay path. +// +// `mcp.addSource` for remote sources persists the row even when auth +// resolution or tool discovery fails (see #364), so we don't need to +// seed secrets or stand up a real MCP server — an unreachable endpoint +// is enough to assert on the stored auth shape. +// --------------------------------------------------------------------------- + +import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { SECRET_REF_PREFIX, type ExecutorFileConfig } from "@executor/config"; +import { createExecutor, makeTestConfig } from "@executor/sdk"; +import { mcpPlugin } from "@executor/plugin-mcp"; +import { openApiPlugin } from "@executor/plugin-openapi"; +import { graphqlPlugin } from "@executor/plugin-graphql"; + +import { syncFromConfig } from "./config-sync"; + +const UNREACHABLE = "http://127.0.0.1:1/mcp"; +const TEST_SCOPE = "test-scope"; + +let workDir: string; + +beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), "exec-config-sync-")); +}); + +afterEach(() => { + rmSync(workDir, { recursive: true, force: true }); +}); + +const writeConfig = (config: ExecutorFileConfig): string => { + const path = join(workDir, "executor.jsonc"); + writeFileSync(path, JSON.stringify(config, null, 2)); + return path; +}; + +const makeExecutor = () => + createExecutor( + makeTestConfig({ + plugins: [mcpPlugin(), openApiPlugin(), graphqlPlugin()] as const, + }), + ); + +describe("syncFromConfig", () => { + it.effect("replays remote MCP header auth with the secret-ref prefix stripped", () => + Effect.gen(function* () { + const configPath = writeConfig({ + sources: [ + { + kind: "mcp", + transport: "remote", + name: "PostHog", + endpoint: UNREACHABLE, + namespace: "posthog", + auth: { + kind: "header", + headerName: "Authorization", + secret: `${SECRET_REF_PREFIX}posthog-api-key`, + prefix: "Bearer ", + }, + }, + ], + }); + const executor = yield* makeExecutor(); + + yield* syncFromConfig(executor, configPath); + + const stored = yield* executor.mcp.getSource("posthog", TEST_SCOPE); + expect(stored).not.toBeNull(); + expect(stored!.config).toMatchObject({ + transport: "remote", + endpoint: UNREACHABLE, + auth: { + kind: "header", + headerName: "Authorization", + secretId: "posthog-api-key", + prefix: "Bearer ", + }, + }); + }), + ); + + it.effect("replays remote MCP oauth2 auth preserving connectionId", () => + Effect.gen(function* () { + const configPath = writeConfig({ + sources: [ + { + kind: "mcp", + transport: "remote", + name: "Linear", + endpoint: UNREACHABLE, + namespace: "linear", + auth: { kind: "oauth2", connectionId: "mcp-oauth2-linear" }, + }, + ], + }); + const executor = yield* makeExecutor(); + + yield* syncFromConfig(executor, configPath); + + const stored = yield* executor.mcp.getSource("linear", TEST_SCOPE); + expect(stored).not.toBeNull(); + expect(stored!.config).toMatchObject({ + transport: "remote", + auth: { kind: "oauth2", connectionId: "mcp-oauth2-linear" }, + }); + }), + ); + + it.effect("preserves kind:none auth on replay", () => + Effect.gen(function* () { + const configPath = writeConfig({ + sources: [ + { + kind: "mcp", + transport: "remote", + name: "DeepWiki", + endpoint: UNREACHABLE, + namespace: "devin", + auth: { kind: "none" }, + }, + ], + }); + const executor = yield* makeExecutor(); + + yield* syncFromConfig(executor, configPath); + + const stored = yield* executor.mcp.getSource("devin", TEST_SCOPE); + expect(stored!.config).toMatchObject({ + transport: "remote", + auth: { kind: "none" }, + }); + }), + ); + + it.effect("skips a missing config file without error", () => + Effect.gen(function* () { + const executor = yield* makeExecutor(); + const missing = join(workDir, "does-not-exist.jsonc"); + + yield* syncFromConfig(executor, missing); + + // No MCP source should be created from the missing file. Plugins + // may seed their own static control sources — filter to MCP only. + const sources = yield* executor.sources.list(); + expect(sources.filter((s) => s.kind === "mcp")).toHaveLength(0); + }), + ); +}); diff --git a/apps/local/src/server/config-sync.ts b/apps/local/src/server/config-sync.ts index 22b36266c..1f5cd4c0c 100644 --- a/apps/local/src/server/config-sync.ts +++ b/apps/local/src/server/config-sync.ts @@ -10,48 +10,19 @@ import { join } from "node:path"; import * as fs from "node:fs"; import * as jsonc from "jsonc-parser"; -import type { - SourceConfig, - ExecutorFileConfig, - ConfigHeaderValue, +import type { SourceConfig, ExecutorFileConfig } from "@executor/config"; +import { + headersFromConfigValues, + mcpAuthFromConfig, } from "@executor/config"; -import { SECRET_REF_PREFIX } from "@executor/config"; import type { LocalExecutor } from "./executor"; -// --------------------------------------------------------------------------- -// Header translation: config format → plugin format -// --------------------------------------------------------------------------- - -const translateHeader = ( - value: ConfigHeaderValue, -): string | { secretId: string; prefix?: string } => { - if (typeof value === "string") { - if (value.startsWith(SECRET_REF_PREFIX)) { - return { secretId: value.slice(SECRET_REF_PREFIX.length) }; - } - return value; - } - // Object form: { value, prefix? } - if (typeof value.value === "string" && value.value.startsWith(SECRET_REF_PREFIX)) { - return { - secretId: value.value.slice(SECRET_REF_PREFIX.length), - prefix: value.prefix, - }; - } - return value.value; -}; - -const translateHeaders = ( - headers: Record | undefined, -): Record | undefined => { - if (!headers) return undefined; - const out: Record = {}; - for (const [k, v] of Object.entries(headers)) { - out[k] = translateHeader(v); - } - return out; -}; +// Narrow, non-exported view of the executor — tests can provide a +// minimal `createExecutor({ plugins: [mcpPlugin(), …] })` without +// loading the full LocalExecutor plugin set (keychain/1password/etc.). +// Derived via `Pick` so there's no signature restatement. +type ConfigSyncTarget = Pick; // --------------------------------------------------------------------------- // Config path resolution @@ -81,7 +52,7 @@ const loadConfigSync = (path: string): ExecutorFileConfig | null => { // --------------------------------------------------------------------------- const addSourceFromConfig = ( - executor: LocalExecutor, + executor: ConfigSyncTarget, source: SourceConfig, ): Effect.Effect => { // `executor.jsonc` is a single-scope artifact today — the file isn't @@ -96,7 +67,7 @@ const addSourceFromConfig = ( scope, baseUrl: source.baseUrl, namespace: source.namespace, - headers: translateHeaders(source.headers), + headers: headersFromConfigValues(source.headers), }).pipe(Effect.asVoid); case "graphql": @@ -104,7 +75,7 @@ const addSourceFromConfig = ( endpoint: source.endpoint, scope, namespace: source.namespace, - headers: translateHeaders(source.headers) as Record | undefined, + headers: headersFromConfigValues(source.headers) as Record | undefined, }).pipe(Effect.asVoid); case "mcp": @@ -128,6 +99,7 @@ const addSourceFromConfig = ( remoteTransport: source.remoteTransport, queryParams: source.queryParams, headers: source.headers, + auth: mcpAuthFromConfig(source.auth), namespace: source.namespace, }).pipe(Effect.asVoid); } @@ -138,7 +110,7 @@ const addSourceFromConfig = ( * Each source is added independently — if one fails, the rest still load. */ export const syncFromConfig = ( - executor: LocalExecutor, + executor: ConfigSyncTarget, configPath: string, ): Effect.Effect => Effect.gen(function* () { From 670d40f02c761bcc0845a5cf85033711513776de Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Tue, 28 Apr 2026 14:19:33 +0530 Subject: [PATCH 4/6] fix(mcp): persist source auth updates to config --- packages/plugins/mcp/src/sdk/plugin.test.ts | Bin 21730 -> 23149 bytes packages/plugins/mcp/src/sdk/plugin.ts | 22 +++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 1ec118c85376a019076e7d5a24cd7180f725abf9..cbda2e80040e1348cba3ea571787e1267ad0aca5 100644 GIT binary patch delta 759 zcmaJ;-D(p-6lOt`v_`75Xf@iyigXt?X_6|!hDbCh5e&8FMo4q9ZqH`7?anMSv!NyA zDds7>6Y7muzKEiDFZu$4GrOBcD>xU!{CwwpKj+(9 Date: Tue, 28 Apr 2026 14:25:11 +0530 Subject: [PATCH 5/6] test(mcp): make source auth update test readable --- packages/plugins/mcp/src/sdk/plugin.test.ts | Bin 23149 -> 23272 bytes packages/plugins/mcp/src/sdk/plugin.ts | 7 +------ 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index cbda2e80040e1348cba3ea571787e1267ad0aca5..e62d962da40dd0a80cdeee55e3e2e7ee62a42d73 100644 GIT binary patch delta 156 zcmaF6h4IB!#tog!3c;? Date: Tue, 28 Apr 2026 14:54:49 +0530 Subject: [PATCH 6/6] test(mcp): move config sink regression out of binary diff --- .../mcp/src/sdk/plugin.config-file.test.ts | 53 ++++++++++++++++++ packages/plugins/mcp/src/sdk/plugin.test.ts | Bin 23272 -> 21730 bytes 2 files changed, 53 insertions(+) create mode 100644 packages/plugins/mcp/src/sdk/plugin.config-file.test.ts diff --git a/packages/plugins/mcp/src/sdk/plugin.config-file.test.ts b/packages/plugins/mcp/src/sdk/plugin.config-file.test.ts new file mode 100644 index 000000000..966bf7d5a --- /dev/null +++ b/packages/plugins/mcp/src/sdk/plugin.config-file.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "@effect/vitest"; +import type { ConfigFileSink, SourceConfig } from "@executor/config"; +import { createExecutor, makeTestConfig } from "@executor/sdk"; +import { Effect } from "effect"; + +import { mcpPlugin } from "./plugin"; + +describe("mcpPlugin config file sink", () => { + it.effect("updateSource mirrors remote auth changes to the config file sink", () => + Effect.gen(function* () { + const upserts: SourceConfig[] = []; + const configFile: ConfigFileSink = { + upsertSource: (source) => + Effect.sync(() => { + upserts.push(source); + }), + removeSource: () => Effect.void, + }; + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [mcpPlugin({ configFile })] as const, + }), + ); + + yield* executor.mcp + .addSource({ + transport: "remote", + scope: "test-scope", + name: "Sentry MCP", + endpoint: "http://127.0.0.1:1/sentry-mcp", + remoteTransport: "auto", + namespace: "sentry", + auth: { kind: "none" }, + }) + .pipe(Effect.either); + upserts.length = 0; + + yield* executor.mcp.updateSource("sentry", "test-scope", { + auth: { kind: "oauth2", connectionId: "mcp-oauth2-sentry" }, + }); + + expect(upserts).toHaveLength(1); + expect(upserts[0]).toMatchObject({ + kind: "mcp", + transport: "remote", + name: "Sentry MCP", + endpoint: "http://127.0.0.1:1/sentry-mcp", + namespace: "sentry", + auth: { kind: "oauth2", connectionId: "mcp-oauth2-sentry" }, + }); + }), + ); +}); diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index e62d962da40dd0a80cdeee55e3e2e7ee62a42d73..1ec118c85376a019076e7d5a24cd7180f725abf9 100644 GIT binary patch delta 53 zcmaE{mGRL^#trg}n^PH8m?m#zzQf2cc?nAzkW^$%29mQ`>zS1ploso;8BE?FAGGFW`gyMx)zKEiT7lIf10)n$YX>4tsi(zNJ^PTUUGyC;T;`_VA+vP;vvR&da zV0$ixG1Q4;T7w7H4(?jcwgO$^dj^hfVFIQ{Y(Q1Kiw&O3GRQ`E> zp1)s7uiBR95s!k0ZNf0rNoma8gD5bo?Xmb82_aWCR|;V`ETVM&zH{gPt6qnQr$>* zvBSJQXw}z{GRO6Y(pehb@Nuo0i#|mmvK#C{er55mY+KG&8IVI9jQBvyUydMC^*h)Z zh@dN=^6Mq#Zb*p~Knzosrv`DcIL)#-!b*a32`M~r|5_>*A@nb#u0D+i$OT54`UpP` z=SW3)`XqSQ6@f@Gtf%$^WJ_npu>Jf+P?-&lE!c(|SpQkQebLcv@rG!Y2`?7qbR-}j d<9=l`(m%QUNB^Y*{nHpH{NoM0|KZNf