From 70563f8c91249546fb92af2f80c9ad7ea7530290 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Fri, 8 May 2026 11:40:29 +0530 Subject: [PATCH 1/2] fix(local): replay MCP auth during config sync Boot-time addSourceFromConfig was forwarding endpoint/headers/queryParams from executor.jsonc into executor.mcp.addSource(...) but silently dropping the auth block. Keychain-backed remote MCP sources started unauthenticated on every boot, the SSE handshake fired with no Authorization header, and upstreams rejected with the "Failed connecting via sse" error class. Add an inline mcpAuthFromConfig converter that strips the secret-public-ref: prefix from file-shape auth into runtime McpConnectionAuth.secretId, and thread it through the MCP remote addSource call. none and oauth2 are identity passthroughs (oauth2's runtime extras are optional, so the file shape is structurally valid as runtime auth). New config-sync.test.ts covers all three auth kinds against an unreachable endpoint, which still persists the source row so the stored auth shape can be asserted without seeding secrets or running an MCP server. --- apps/local/src/server/config-sync.test.ts | Bin 0 -> 6616 bytes apps/local/src/server/config-sync.ts | 33 +++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) 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 0000000000000000000000000000000000000000..78776ad2eba5255d1426049a5f73cfc157852cef GIT binary patch literal 6616 zcmds5ZExE~67FaJiaDVls6wJH`Er1+Y3n!&;x>+r$hm7#1Qt!MBt{gMx4W{fGYs61 zxL)b8%#*x{SC3}^WoC(%}eOJmz;7fH)2yX_5^Q0JF=4Hmh z^8yBE8sRtUUz;2uLfjFlGF6$V;GEb|2tL9Ik2R?q_+H1#B?h4)L7uUJ^c-gcenzD^)>6MMtBvD@)58zvZ=U1e(|;Vln!LaGY4Qudo^tMF>(rxA z{yx_$Rh~eTLCvrniUCixrTivS^So4N9@hN4PFCt1SrHHTjdxD8o*8|WXUYr?9`wv+ ztCm{?#EIS}pg+`{!f_BP8A7TPUW**Q1_AAbFy<0Q+=jfqJH;D!OK)|W6t!E;aq*}= zk59mU)cl8c?xZw_C+ozM&FEuE{zqtUbHe~bT$f6vCxMq-dipHHUe7t^EDGZqd( zjyXh8k4tyxG1Kce+Ii_jt+m-oz-$P>3yS*m;yb$K9C$lC4?rp7`Xs5o6`};Mc*6WH zg3emB3Q-?ZHw)O!`iNF)JEq>+pWEY<5iul~sK(^XM#(#Xd`wZE;SeqtZ;${k=AcGm zqBo4WIGTNZ>jm$=`uaA{?q+@Z{(CAnMbQ_1w$XQ$6gXt3)|)_dU%A-J2Ffu|aqpM` zd38PjEO{4>%T%Ep9QsTTMEsuie$ZBc0HKS6@R+sXJ<*{t$u{P$7>U^kxQ+UKR6-R+ zt0uE-E&oV{^9%`E#kMMP7xljC4d{9Q*JmF&h$~KR{R4j9m=aXVnJJSsT*TJ9ok$%3 zB5%8237gvJ4f%^C#(CD1#L#+5usyUQu`T2etteS=M7;;mZGDi)*I>6}`n4{>kG%7) zkKDkD1LT9QwOPH&%{UY;-b%ONHV`aggMt_$(qiz4*)|eVj`{=YF5J8%{_50WU5Tzb_=2H%P$6a3-0C>jJaj zlrFbNT_TTe!|2A@e1xTlG5_{gH;34b!tyQK-@di*1ge-y~TP` z9JgCh_W<5+tAq=;S3$}0o)R7($9wjo4~WGQyuD)ygB{28ohO*yArxqn|2yHC!drVy)1Zu6@=y4W0`JO|)&>>i-?|)<3jR8C3yGf?Qvt#;6 zZClRzF2)&L-dymoaN&3wYYEh(3YkvtmJoy(1h2YyYxz^VJI0_b_caFXrd*Z!8uv8Y z9gTM8?=Z4^Cl`S!4+jtV?75SpQsc}wL+*0>p$BGof{?pbw5#D(Ae$u1{XiOP){jf= zVy9oKcOb%DZaYGMiN*Ln1ToyE%hStG7@k`}`CWuQ0l@pvdK^}t2`Pcow9w2LUjG9S z75aM+HVsN@Xh@?*2vH<0YOVOHX-V@D&*h5gmd4*hL6*y;T&V1iOQ&yfPf1J*KU|1U zB$GwjY+5{|BV@d$PYEC5get4AU!f5UWo;PLP?H!wr}gl~TqexdHUKC)YS|O03qaem zslnAgCU?qgETclyv_8Is96fa9Y+*{MbEidanCv+J{0>oaN04jd__6D&=-?Ch^M>2L zO)gadC~WWr1b4<{T@?zTUw4w9q$xfS`77?&U!yv=-Hzq3FWidv$sZlA?b2N=T7|1; ztxmuu2Fm9_=g=Qc{>YA1B)1m^x)&LG)*t$2&=IVMZSftm4?cG-Yj1c8Y_fwYzp3zP z#~y+|{D9b6@)a2E3A6~mR5|5tn zZQ_q{lX#_6_0N2j@4@dgAlz<#nYzl`^Jk4k25p#eIBKnsvqTFEU(nV9WBnPB6T`; 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); } From 015bad080154bda4b72514e4a1a9750d69bb028f Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Fri, 8 May 2026 11:40:45 +0530 Subject: [PATCH 2/2] fix(mcp): persist source auth updates to config file updateSource wrote new auth to the DB but never to executor.jsonc, so the next boot replayed the file's stale auth (or none) and silently overwrote the DB. The smoking gun: after signing into Sentry via the UI, the mcp-oauth2-sentry connection survived in keychain with valid tokens, but the next reboot cleared the source row's auth_connection_id because the file never reflected the link. Mirror the existing addSource/removeSource pattern by calling configFile.upsertSource(toMcpConfigEntry(...)) after putSource. The existing toMcpConfigEntry/authToConfig path handles the runtime->file conversion (writes secret-public-ref: back), so the file stays authoritative for boot replay. Test exercises updateSource against a stub ConfigFileSink and asserts the upsert payload uses secret-public-ref: in file shape. --- packages/plugins/mcp/src/sdk/plugin.test.ts | 82 ++++++++++++++ packages/plugins/mcp/src/sdk/plugin.ts | 118 +++++++++++++++++++- 2 files changed, 199 insertions(+), 1 deletion(-) 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 },