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..78776ad2e Binary files /dev/null and b/apps/local/src/server/config-sync.test.ts differ diff --git a/apps/local/src/server/config-sync.ts b/apps/local/src/server/config-sync.ts index f1576c33a..19e678ccd 100644 --- a/apps/local/src/server/config-sync.ts +++ b/apps/local/src/server/config-sync.ts @@ -10,9 +10,15 @@ import { join } from "node:path"; import * as fs from "node:fs"; import * as jsonc from "jsonc-parser"; -import type { SourceConfig, ExecutorFileConfig, ConfigHeaderValue } from "@executor-js/config"; +import type { + SourceConfig, + ExecutorFileConfig, + ConfigHeaderValue, + McpAuthConfig, +} from "@executor-js/config"; import { SECRET_REF_PREFIX } from "@executor-js/config"; import type { ScopeId } from "@executor-js/sdk"; +import type { McpConnectionAuthInput } from "@executor-js/plugin-mcp"; import type { LocalExecutor } from "./executor"; @@ -50,6 +56,27 @@ const translateHeaders = ( return out; }; +// --------------------------------------------------------------------------- +// MCP auth translation: file format → addSource input format +// +// File `auth.secret` is `secret-public-ref:`; the plugin's addSource +// accepts the legacy `{kind: "header", secretId, ...}` shape via +// `McpConnectionAuthInput` and internally creates the matching +// credential_binding row. `none` and `oauth2` (which carries `connectionId`) +// are also valid `McpConnectionAuthInput` variants. +// --------------------------------------------------------------------------- + +const mcpAuthFromConfig = ( + auth: McpAuthConfig | undefined, +): McpConnectionAuthInput | undefined => { + if (!auth) return undefined; + if (auth.kind !== "header") return auth; + const secretId = auth.secret.startsWith(SECRET_REF_PREFIX) + ? auth.secret.slice(SECRET_REF_PREFIX.length) + : auth.secret; + return { kind: "header", headerName: auth.headerName, secretId, prefix: auth.prefix }; +}; + // --------------------------------------------------------------------------- // Config path resolution // --------------------------------------------------------------------------- @@ -90,6 +117,7 @@ const addSourceFromConfig = ( baseUrl: source.baseUrl, namespace: source.namespace, headers: translateHeaders(source.headers), + credentialTargetScope: targetScope, }) .pipe(Effect.asVoid); @@ -100,6 +128,7 @@ const addSourceFromConfig = ( scope: targetScope, namespace: source.namespace, headers: translateHeaders(source.headers) as Record | undefined, + credentialTargetScope: targetScope, }) .pipe(Effect.asVoid); @@ -127,7 +156,9 @@ const addSourceFromConfig = ( remoteTransport: source.remoteTransport, queryParams: source.queryParams, headers: source.headers, + auth: mcpAuthFromConfig(source.auth), namespace: source.namespace, + credentialTargetScope: targetScope, }) .pipe(Effect.asVoid); } diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 5869ecf0d..4d34c7959 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -454,6 +454,88 @@ describe("mcpPlugin", () => { }), ); + // ------------------------------------------------------------------------- + // updateSource must persist auth changes to the config file too — + // otherwise the next boot replays the file's stale auth and silently + // overwrites the DB. Symmetric with addSource/removeSource which + // already write through. + // ------------------------------------------------------------------------- + + it.effect("updateSource writes auth changes back to the config file", () => + Effect.gen(function* () { + const calls: Array<{ op: "upsert" | "remove"; payload: unknown }> = []; + const stubSink = { + upsertSource: (source: unknown) => + Effect.sync(() => { + calls.push({ op: "upsert", payload: source }); + }), + removeSource: (namespace: string) => + Effect.sync(() => { + calls.push({ op: "remove", payload: namespace }); + }), + }; + + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [makeMemorySecretsPlugin()(), mcpPlugin({ configFile: stubSink })] as const, + }), + ); + + for (const id of ["sentry-token-old", "sentry-token-new"]) { + yield* executor.secrets.set({ + id: SecretId.make(id), + scope: ScopeId.make("test-scope"), + name: id, + value: `value-${id}`, + provider: "memory", + }); + } + + yield* executor.mcp + .addSource({ + transport: "remote", + scope: "test-scope", + name: "Sentry", + endpoint: "http://127.0.0.1:1/sentry-mcp", + namespace: "sentry", + credentialTargetScope: "test-scope", + auth: { + kind: "header", + headerName: "Authorization", + secretId: "sentry-token-old", + prefix: "Bearer ", + }, + }) + .pipe(Effect.result); + + calls.length = 0; // ignore the addSource upsert; we're asserting on update + + yield* executor.mcp.updateSource("sentry", "test-scope", { + credentialTargetScope: ScopeId.make("test-scope"), + auth: { + kind: "header", + headerName: "Authorization", + secretId: "sentry-token-new", + prefix: "Bearer ", + }, + }); + + const upserts = calls.filter((c) => c.op === "upsert"); + expect(upserts).toHaveLength(1); + expect(upserts[0]!.payload).toMatchObject({ + kind: "mcp", + transport: "remote", + namespace: "sentry", + auth: { + kind: "header", + headerName: "Authorization", + secret: "secret-public-ref:sentry-token-new", + prefix: "Bearer ", + }, + }); + }), + ); + // ------------------------------------------------------------------------- // Deferred OAuth — admin saves a source with `{kind: "oauth2", // connectionId}` before any user has signed in, so the row lands in diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index f2ac0eb71..879cb8dcb 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -20,6 +20,7 @@ import { ConfiguredCredentialBinding, ConnectionId, type CredentialBindingRef, + type CredentialBindingValue, ScopeId, SecretId, SourceDetectionResult, @@ -912,6 +913,102 @@ const authToConfig = (auth: McpConnectionAuthInput | undefined): McpAuthConfig | }; }; +// --------------------------------------------------------------------------- +// Storage-form → input-form reconstruction +// +// `toMcpConfigEntry` consumes the `McpSourceConfig` *input* shape — the +// legacy form with `secretId` / `connectionId`, which `authToConfig` and +// `plainStringMap` know how to render into the file. Stored remote data +// is in slot form (`secretSlot`, `{kind: "binding", slot}`), so writing +// the file from a stored row needs the slot → secret/connection lookups +// realized first. Walk the source's `credential_binding` rows and rebuild +// the input shape; any slot whose binding is missing is dropped. +// --------------------------------------------------------------------------- + +const toCredentialInput = ( + bySlot: Map, + configured: ConfiguredMcpCredentialValue, +): McpCredentialInput | undefined => { + if (typeof configured === "string") return configured; + const value = bySlot.get(configured.slot); + if (!value) return undefined; + if (value.kind === "secret") { + return { + secretId: value.secretId, + ...(configured.prefix ? { prefix: configured.prefix } : {}), + }; + } + if (value.kind === "text") return value.text; + // headers / queryParams cannot reference connections — only auth can. + return undefined; +}; + +const toCredentialInputMap = ( + bySlot: Map, + values: Record | undefined, +): Record | undefined => { + if (!values) return undefined; + const out: Record = {}; + for (const [name, configured] of Object.entries(values)) { + const input = toCredentialInput(bySlot, configured); + if (input !== undefined) out[name] = input; + } + return Object.keys(out).length > 0 ? out : undefined; +}; + +const toAuthInput = ( + bySlot: Map, + auth: McpConnectionAuth, +): McpConnectionAuthInput | undefined => { + if (auth.kind === "none") return { kind: "none" }; + if (auth.kind === "header") { + const value = bySlot.get(auth.secretSlot); + if (value?.kind !== "secret") return undefined; + return { + kind: "header", + headerName: auth.headerName, + secretId: value.secretId, + prefix: auth.prefix, + }; + } + const connection = bySlot.get(auth.connectionSlot); + if (connection?.kind !== "connection") return undefined; + return { kind: "oauth2", connectionId: connection.connectionId }; +}; + +const inputFormFromStored = ( + bindings: ReadonlyArray, + stored: McpStoredSourceData, + scope: string, + sourceName: string, + namespace: string, +): McpSourceConfig => { + if (stored.transport === "stdio") { + return { + transport: "stdio", + scope, + name: sourceName, + namespace, + command: stored.command, + args: stored.args ? [...stored.args] : undefined, + env: stored.env, + cwd: stored.cwd, + }; + } + const bySlot = new Map(bindings.map((b) => [b.slotKey, b.value] as const)); + return { + transport: "remote", + scope, + name: sourceName, + namespace, + endpoint: stored.endpoint, + remoteTransport: stored.remoteTransport, + headers: toCredentialInputMap(bySlot, stored.headers), + queryParams: toCredentialInputMap(bySlot, stored.queryParams), + auth: toAuthInput(bySlot, stored.auth), + }; +}; + const toMcpConfigEntry = ( namespace: string, sourceName: string, @@ -1455,6 +1552,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { ...(canonicalAuth ? { auth: canonicalAuth.auth } : {}), ...(canonicalQueryParams ? { queryParams: canonicalQueryParams.values } : {}), }; + const sourceName = input.name?.trim() || existing.name; const affectedPrefixes = [ ...(input.headers !== undefined ? ["header:"] : []), @@ -1467,7 +1565,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { yield* ctx.storage.putSource({ namespace, scope, - name: input.name?.trim() || existing.name, + name: sourceName, config: updatedConfig, }); if (affectedPrefixes.length > 0 || directBindings.length > 0) { @@ -1485,6 +1583,24 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { } }), ); + + if (configFile) { + const bindings = yield* ctx.credentialBindings.listForSource({ + pluginId: MCP_PLUGIN_ID, + sourceId: namespace, + sourceScope: ScopeId.make(scope), + }); + const inputForm = inputFormFromStored( + bindings, + updatedConfig, + scope, + sourceName, + namespace, + ); + yield* configFile + .upsertSource(toMcpConfigEntry(namespace, sourceName, inputForm)) + .pipe(Effect.withSpan("mcp.plugin.config_file.upsert")); + } }).pipe( Effect.withSpan("mcp.plugin.update_source", { attributes: { "mcp.source.namespace": namespace },