Skip to content

Commit eb2a2d9

Browse files
suryaiyer95claude
andcommitted
fix: [#791][#792] address second-round review on bearer-auth MCP support
- `discover.ts`: preserve `headersCommand` / `oauth` in the discovery normalizer (mirrors `normalizeMcpConfig`) so auto-discovered servers from `.mcp.json` / `.gemini/settings.json` keep their bearer-token config instead of silently connecting with no auth - `supportsOAuth()`: mirror `create()`'s OAuth auto-disable — return `false` when an `Authorization` header is present (statically or via `headersCommand`) and `oauth` is not explicitly configured, so the auth routes no longer start OAuth flows the connection ignores - `mergeHeaders()`: merge static + dynamic headers case-insensitively (HTTP header names are case-insensitive) so `headersCommand` overrides a static header differing only in casing instead of sending duplicates - `resolveHeadersCommand()`: run the composed failure message through `Telemetry.maskString()` before it reaches logs / `status.error`, so a token echoed in argv or printed to stderr by a verbose auth CLI is redacted - tests: discovery round-trip for `headersCommand`/`oauth`, `supportsOAuth` matrix, case-insensitive merge (unit + transport-level), stderr masking Co-Authored-By: Claude Fable 5 <[email protected]>
1 parent 3157a5a commit eb2a2d9

5 files changed

Lines changed: 206 additions & 4 deletions

File tree

‎packages/opencode/src/mcp/discover.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,17 @@ function transform(
8989
})
9090
// altimate_change end
9191
}
92+
// altimate_change start — preserve bearer-auth fields, mirroring config.ts
93+
// `normalizeMcpConfig`. Without these passes a discovered server's
94+
// `headersCommand` / `oauth` would be dropped before reaching the runtime,
95+
// silently connecting with no auth (and the loss could be persisted back to
96+
// disk via mcp-discover). Copied as-is — argv values are executed via
97+
// execFile at connect time, so no env-var resolution applies here, and
98+
// malformed shapes are rejected downstream by the McpRemote schema with an
99+
// actionable error. See #791 / #792.
100+
if (entry.headersCommand && typeof entry.headersCommand === "object") result.headersCommand = entry.headersCommand
101+
if (entry.oauth !== undefined) result.oauth = entry.oauth
102+
// altimate_change end
92103
if (typeof entry.timeout === "number") result.timeout = entry.timeout
93104
if (typeof entry.enabled === "boolean") result.enabled = entry.enabled
94105
return result as Config.Mcp

‎packages/opencode/src/mcp/index.ts‎

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ export namespace MCP {
209209
resolveHeadersCommand: (spec: Record<string, string[]> | undefined, key = "test") =>
210210
resolveHeadersCommand(spec, key),
211211
hasAuthorizationHeader,
212+
mergeHeaders,
212213
}
213214
// altimate_change end
214215

@@ -242,10 +243,14 @@ export namespace MCP {
242243
// command (ENOENT, timeout, non-zero exit) rather than a bare error.
243244
// On a non-zero exit the actionable reason lives on `err.stderr` (e.g.
244245
// `az`'s "run 'az login'"), which `err.message` omits — append it.
246+
// The composed message is masked before it escapes: execFile's message
247+
// echoes the full argv and an auth CLI run with --verbose can print the
248+
// token to stderr, and this string reaches logs and the status API.
249+
// Over-masking (quoted spans become `?`) is the correct failure mode.
245250
const e = err as { message?: string; stderr?: string }
246251
const stderr = typeof e.stderr === "string" ? e.stderr.trim().slice(0, 500) : ""
247252
const base = err instanceof Error ? err.message : String(err)
248-
const message = stderr ? `${base}: ${stderr}` : base
253+
const message = Telemetry.maskString(stderr ? `${base}: ${stderr}` : base)
249254
throw new Error(`headersCommand[${name}] failed: ${message}`)
250255
}
251256
const value = stdout.trim()
@@ -258,9 +263,30 @@ export namespace MCP {
258263
return out
259264
}
260265

261-
function hasAuthorizationHeader(headers: Record<string, string>): boolean {
266+
// Accepts any header-shaped record (static `headers` values are strings,
267+
// `headersCommand` values are argv arrays) — only key names are inspected.
268+
function hasAuthorizationHeader(headers: Record<string, unknown>): boolean {
262269
return Object.keys(headers).some((k) => k.toLowerCase() === "authorization")
263270
}
271+
272+
// HTTP header names are case-insensitive, so a dynamic header must replace a
273+
// static one that differs only in casing (`headers.Authorization` +
274+
// `headersCommand.authorization` would otherwise both be sent — duplicate
275+
// credentials some servers reject). Dynamic values win under the documented
276+
// contract: "Values from headersCommand override matching keys in `headers`."
277+
function mergeHeaders(
278+
staticHeaders: Record<string, string>,
279+
dynamicHeaders: Record<string, string>,
280+
): Record<string, string> {
281+
const merged: Record<string, string> = { ...staticHeaders }
282+
for (const [name, value] of Object.entries(dynamicHeaders)) {
283+
for (const existing of Object.keys(merged)) {
284+
if (existing.toLowerCase() === name.toLowerCase()) delete merged[existing]
285+
}
286+
merged[name] = value
287+
}
288+
return merged
289+
}
264290
// altimate_change end
265291

266292
// Register notification handlers for MCP client
@@ -522,7 +548,7 @@ export namespace MCP {
522548
status: { status: "failed" as const, error: message },
523549
}
524550
}
525-
const mergedHeaders: Record<string, string> = { ...(mcp.headers ?? {}), ...dynamicHeaders }
551+
const mergedHeaders: Record<string, string> = mergeHeaders(mcp.headers ?? {}, dynamicHeaders)
526552
// altimate_change end
527553

528554
// altimate_change start — OAuth is enabled by default for remote servers,
@@ -1348,7 +1374,19 @@ export namespace MCP {
13481374
const mcpConfig = cfg.mcp?.[mcpName]
13491375
if (!mcpConfig) return false
13501376
if (!isMcpConfigured(mcpConfig)) return false
1351-
return mcpConfig.type === "remote" && mcpConfig.oauth !== false
1377+
if (mcpConfig.type !== "remote") return false
1378+
if (mcpConfig.oauth === false) return false
1379+
if (typeof mcpConfig.oauth === "object") return true
1380+
// altimate_change start — mirror `create()`'s auto-disable: when the user
1381+
// provided an Authorization header (statically or via `headersCommand`) and
1382+
// didn't explicitly configure OAuth, connect-time skips OAuth — so the auth
1383+
// API surface must not advertise it, or `POST /:name/auth` would start an
1384+
// OAuth flow whose tokens the connection never uses. `headersCommand` is
1385+
// not resolved here (that would execute the command); the presence of an
1386+
// Authorization key in its spec is enough to know connect-time disables
1387+
// OAuth. See #792.
1388+
return !hasAuthorizationHeader({ ...(mcpConfig.headers ?? {}), ...(mcpConfig.headersCommand ?? {}) })
1389+
// altimate_change end
13521390
}
13531391

13541392
/**

‎packages/opencode/test/mcp/discover.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,44 @@ describe("discoverExternalMcp", () => {
191191
})
192192
})
193193

194+
// altimate_change start — discovery must preserve bearer-auth fields
195+
// (headersCommand / oauth) the same way config.ts `normalizeMcpConfig` does,
196+
// or auto-discovered servers silently connect with no auth. See #791 / #792.
197+
test("remote: headersCommand and oauth are preserved", async () => {
198+
await writeFile(
199+
path.join(tempDir, ".mcp.json"),
200+
JSON.stringify({
201+
mcpServers: {
202+
fabric: {
203+
url: "https://api.fabric.microsoft.com/v1/mcp/core",
204+
headersCommand: {
205+
Authorization: ["az", "account", "get-access-token"],
206+
},
207+
},
208+
"no-oauth": {
209+
url: "https://example.com/mcp",
210+
headers: { Authorization: "Bearer token" },
211+
oauth: false,
212+
},
213+
},
214+
}),
215+
)
216+
217+
const { servers: result } = await discoverExternalMcp(tempDir)
218+
expect(result["fabric"]).toMatchObject({
219+
type: "remote",
220+
url: "https://api.fabric.microsoft.com/v1/mcp/core",
221+
headersCommand: { Authorization: ["az", "account", "get-access-token"] },
222+
})
223+
expect(result["no-oauth"]).toMatchObject({
224+
type: "remote",
225+
url: "https://example.com/mcp",
226+
headers: { Authorization: "Bearer token" },
227+
oauth: false,
228+
})
229+
})
230+
// altimate_change end
231+
194232
test("env → environment rename", async () => {
195233
await writeFile(
196234
path.join(tempDir, ".mcp.json"),

‎packages/opencode/test/mcp/headers.test.ts‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,101 @@ test("OAuth still attaches when Authorization header is present but oauth is exp
178178
},
179179
})
180180
})
181+
test("headersCommand overrides a static header that differs only in casing", async () => {
182+
await using tmp = await tmpdir()
183+
await Instance.provide({
184+
directory: tmp.path,
185+
fn: async () => {
186+
transportCalls.length = 0
187+
await MCP.add("case-merge-server", {
188+
type: "remote",
189+
url: "https://example.com/mcp",
190+
headers: { authorization: "Bearer stale-static", "X-Other": "keep" },
191+
headersCommand: {
192+
Authorization: ["printf", "Bearer fresh-dynamic"],
193+
},
194+
}).catch(() => {})
195+
196+
expect(transportCalls.length).toBeGreaterThanOrEqual(1)
197+
for (const call of transportCalls) {
198+
// HTTP header names are case-insensitive: only the dynamic value may
199+
// survive, or two Authorization headers would be sent on the wire.
200+
expect(call.options.requestInit?.headers).toEqual({
201+
Authorization: "Bearer fresh-dynamic",
202+
"X-Other": "keep",
203+
})
204+
expect(call.options.authProvider).toBeUndefined()
205+
}
206+
},
207+
})
208+
})
209+
210+
test("mergeHeaders: dynamic value wins over static key differing only in casing", () => {
211+
expect(
212+
MCP._testing.mergeHeaders({ authorization: "Bearer stale", "X-Other": "keep" }, { Authorization: "Bearer fresh" }),
213+
).toEqual({
214+
Authorization: "Bearer fresh",
215+
"X-Other": "keep",
216+
})
217+
})
218+
219+
// Covers the auth API surface: `supportsOAuth()` must agree with `create()`'s
220+
// auto-disable, or `POST /:name/auth` would start an OAuth flow whose tokens
221+
// the bearer connection never uses.
222+
test("supportsOAuth mirrors the OAuth auto-disable for bearer-auth servers", async () => {
223+
await using tmp = await tmpdir({
224+
init: async (dir) => {
225+
await Bun.write(
226+
`${dir}/opencode.json`,
227+
JSON.stringify({
228+
$schema: "https://altimate.ai/config.json",
229+
mcp: {
230+
"bearer-static": {
231+
type: "remote",
232+
url: "https://example.com/mcp",
233+
headers: { Authorization: "Bearer static-token" },
234+
},
235+
"bearer-cmd": {
236+
type: "remote",
237+
url: "https://example.com/mcp",
238+
headersCommand: { authorization: ["printf", "Bearer dynamic-token"] },
239+
},
240+
"explicit-oauth": {
241+
type: "remote",
242+
url: "https://example.com/mcp",
243+
headers: { Authorization: "Bearer fallback" },
244+
oauth: { clientId: "client-xyz" },
245+
},
246+
"plain-remote": {
247+
type: "remote",
248+
url: "https://example.com/mcp",
249+
},
250+
"oauth-off": {
251+
type: "remote",
252+
url: "https://example.com/mcp",
253+
oauth: false,
254+
},
255+
},
256+
}),
257+
)
258+
},
259+
})
260+
261+
await Instance.provide({
262+
directory: tmp.path,
263+
fn: async () => {
264+
// Bearer present (statically or via headersCommand), oauth unspecified —
265+
// connect-time auto-disables OAuth, so the API must not advertise it.
266+
expect(await MCP.supportsOAuth("bearer-static")).toBe(false)
267+
expect(await MCP.supportsOAuth("bearer-cmd")).toBe(false)
268+
// Explicit opt-in wins even with a bearer header present.
269+
expect(await MCP.supportsOAuth("explicit-oauth")).toBe(true)
270+
// Defaults unchanged.
271+
expect(await MCP.supportsOAuth("plain-remote")).toBe(true)
272+
expect(await MCP.supportsOAuth("oauth-off")).toBe(false)
273+
},
274+
})
275+
})
181276
// altimate_change end
182277

183278
test("headers are passed to transports when oauth is explicitly disabled", async () => {

‎packages/opencode/test/mcp/mcp-bearer-auth.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,26 @@ describe("resolveHeadersCommand helper", () => {
242242
})
243243
expect(result.X).toBe("$(whoami); rm -rf /")
244244
})
245+
246+
test("masks bearer tokens leaked to stderr in the failure message", async () => {
247+
// An auth CLI run with --verbose/--debug can print the token to stderr,
248+
// and the failure message reaches logs and the status API. The composed
249+
// message must redact token-shaped values (via Telemetry.maskString).
250+
const { MCP } = await import("../../src/mcp")
251+
const token = "sTLeakedTokenValue0123456789abcdef"
252+
let message = ""
253+
try {
254+
await MCP._testing.resolveHeadersCommand({
255+
Authorization: ["sh", "-c", `echo DEBUG: authorization: Bearer ${token} >&2; exit 1`],
256+
})
257+
throw new Error("expected resolveHeadersCommand to reject")
258+
} catch (err) {
259+
message = err instanceof Error ? err.message : String(err)
260+
}
261+
expect(message).toMatch(/headersCommand\[Authorization\] failed:/)
262+
expect(message).toContain("Bearer ***")
263+
expect(message).not.toContain(token)
264+
})
245265
})
246266

247267
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)