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* () { diff --git a/packages/core/config/src/index.ts b/packages/core/config/src/index.ts index e5aecce92..c7f522e31 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, @@ -22,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 9c2e4ab80..6099f80fb 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. 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. +// --------------------------------------------------------------------------- + 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/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.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.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 841a563aa..54a45b35c 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; }; @@ -1086,12 +1066,27 @@ export const mcpPlugin = definePlugin( : {}), }; + const sourceName = input.name?.trim() || existing.name; + yield* ctx.storage.putSource({ namespace, scope, - name: input.name?.trim() || existing.name, + name: sourceName, config: updatedConfig, }); + + if (configFile) { + yield* configFile + .upsertSource( + toMcpConfigEntry(namespace, sourceName, { + ...updatedConfig, + scope, + name: sourceName, + namespace, + }), + ) + .pipe(Effect.withSpan("mcp.plugin.config_file.upsert")); + } }).pipe( Effect.withSpan("mcp.plugin.update_source", { attributes: { "mcp.source.namespace": namespace }, 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 // ---------------------------------------------------------------------------