Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -652,11 +652,28 @@ export namespace Config {
url: z.string().describe("URL of the remote MCP server"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"),
// altimate_change start — dynamic header values produced by an argv command
// (run via execFile, not a shell — no shell interpolation unless the user
// explicitly invokes one like `sh -c`), resolved on each (re)connect so
// callers can refresh expiring bearer tokens without restarting the session
// (e.g. `az account get-access-token`).
headersCommand: z
.record(z.string(), z.array(z.string()).nonempty())
.optional()
.describe(
"Headers whose values are produced by running a command (argv form: [cmd, ...args]). " +
"stdout is trimmed and used as the header value. Resolved on every connect so tokens " +
"with short TTLs (e.g. Microsoft Entra ID bearer tokens for Fabric) refresh automatically. " +
"Values from headersCommand override matching keys in `headers`.",
),
// altimate_change end
oauth: z
.union([McpOAuth, z.literal(false)])
.optional()
.describe(
"OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.",
"OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection. " +
"When `headers.Authorization` or `headersCommand.Authorization` is set and `oauth` is not specified, " +
"OAuth is disabled automatically so a static bearer token isn't overridden by a competing OAuth flow.",
),
timeout: z
.number()
Expand Down Expand Up @@ -1451,7 +1468,22 @@ export namespace Config {
servers[name] = transformed
} else if (entry.url && typeof entry.url === "string") {
const transformed: Record<string, any> = { type: "remote", url: entry.url }
// Copy `headers` / `headersCommand` through as-is — including malformed
// array shapes. The downstream `Info.safeParse` validates `mcp` against
// the McpRemote schema, and `z.record(...)` rejects an array with an
// actionable `invalid_type` error. Stripping arrays here would instead
// drop the field silently and connect a header-less server with no
// feedback to the user. See #791 / #792.
if (entry.headers && typeof entry.headers === "object") transformed.headers = entry.headers
// altimate_change start — preserve fields that the original normalizer dropped
// silently. Without these passes, a user-supplied `oauth: false` or
// `headersCommand` would be reconstructed-away, leaving the runtime
// believing the config was bare. See #791 / #792.
if (entry.headersCommand && typeof entry.headersCommand === "object") {
transformed.headersCommand = entry.headersCommand
}
if (entry.oauth !== undefined) transformed.oauth = entry.oauth
// altimate_change end
if (typeof entry.timeout === "number") transformed.timeout = entry.timeout
if (typeof entry.enabled === "boolean") transformed.enabled = entry.enabled
if (typeof entry.updatedAt === "string") transformed.updatedAt = entry.updatedAt
Expand Down
42 changes: 42 additions & 0 deletions packages/opencode/src/mcp/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,48 @@ function transform(
})
// altimate_change end
}
// altimate_change start — preserve bearer-auth fields so a discovered
// server's `headersCommand` / `oauth` isn't dropped before reaching the
// runtime, silently connecting with no auth. Unlike config.ts
// `normalizeMcpConfig` (which passes malformed shapes through so the user's
// own file fails `Info.safeParse` with an actionable error), discovery
// ingests FOREIGN config files: only shapes our McpRemote schema accepts
// are preserved, and foreign dialects (e.g. Gemini CLI's
// `oauth: { enabled: true }`) are dropped as before. Discovered entries are
// merged into the runtime config after validation and can be persisted to
// opencode.json via `mcp-discover add`, so an unvalidated pass-through
// would poison the config file and fail every subsequent load.
// Validators mirror the McpRemote schema: `headersCommand` is a record of
// non-empty string argv arrays; `oauth` is `false` or a strict object of
// optional string fields clientId/clientSecret/scope. (Schemas can't be
// imported as values here — config.ts dynamically imports this module, and
// the Config import above is type-only to avoid a static cycle.)
// See #791 / #792.
const headersCommand = entry.headersCommand
if (headersCommand !== undefined) {
const valid =
headersCommand !== null &&
typeof headersCommand === "object" &&
!Array.isArray(headersCommand) &&
Object.values(headersCommand).every(
(argv) => Array.isArray(argv) && argv.length > 0 && argv.every((part: unknown) => typeof part === "string"),
)
if (valid) result.headersCommand = headersCommand
else log.debug("dropping unrecognized headersCommand from discovered server", context)
}
const oauth = entry.oauth
if (oauth !== undefined) {
const oauthStringFields = ["clientId", "clientSecret", "scope"]
const valid =
oauth === false ||
(oauth !== null &&
typeof oauth === "object" &&
!Array.isArray(oauth) &&
Object.entries(oauth).every(([k, v]) => oauthStringFields.includes(k) && typeof v === "string"))
if (valid) result.oauth = oauth
else log.debug("dropping unrecognized oauth config from discovered server", context)
}
// altimate_change end
if (typeof entry.timeout === "number") result.timeout = entry.timeout
if (typeof entry.enabled === "boolean") result.enabled = entry.enabled
return result as Config.Mcp
Expand Down
Loading
Loading