Skip to content
Open
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
26 changes: 25 additions & 1 deletion packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,11 +649,26 @@ 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 a shell command,
// resolved on each (re)connect so callers can refresh expiring bearer tokens
// without restarting the session (e.g. `az account get-access-token`).
Comment on lines +652 to +654
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 @@ -1440,6 +1455,15 @@ export namespace Config {
} else if (entry.url && typeof entry.url === "string") {
const transformed: Record<string, any> = { type: "remote", url: entry.url }
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") {
Comment on lines 1457 to +1462
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
servers[name] = transformed
Expand Down
172 changes: 162 additions & 10 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import { Config } from "../config/config"
import { Log } from "../util/log"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod/v4"
// altimate_change start — needed to resolve `headersCommand` for remote MCP
// servers that require bearer tokens with short TTLs (Microsoft Fabric, etc.)
import { execFile } from "node:child_process"
import { promisify } from "node:util"
const execFileAsync = promisify(execFile)
// altimate_change end
import { Instance } from "../project/instance"
import { Installation } from "../installation"
import { withTimeout } from "@/util/timeout"
Expand Down Expand Up @@ -121,6 +127,118 @@ export namespace MCP {
const toolListCache = new Map<string, MCPToolDef[]>()
// altimate_change end

// altimate_change start — Microsoft Fabric Core MCP returns `null` for
// `tool.annotations.{readOnlyHint,destructiveHint,idempotentHint,openWorldHint}`,
// which the SDK's `ListToolsResultSchema` (z.boolean().optional()) rejects via
// Zod, blocking listTools() entirely. We accept `null` as "hint absent" by
// calling `client.request()` with a permissive schema in place of the SDK's
// strict one. See https://github.com/AltimateAI/altimate-code/issues/792.
const LenientToolAnnotationsSchema = z
.object({
title: z.string().optional(),
readOnlyHint: z.boolean().nullable().optional(),
destructiveHint: z.boolean().nullable().optional(),
idempotentHint: z.boolean().nullable().optional(),
openWorldHint: z.boolean().nullable().optional(),
})
.loose()

const LenientToolSchema = z
.object({
name: z.string(),
title: z.string().optional(),
description: z.string().optional(),
inputSchema: z.any(),
outputSchema: z.any().optional(),
annotations: LenientToolAnnotationsSchema.optional(),
_meta: z.record(z.string(), z.unknown()).optional(),
})
.loose()

const LenientListToolsResultSchema = z
.object({
tools: z.array(LenientToolSchema),
nextCursor: z.string().optional(),
_meta: z.record(z.string(), z.unknown()).optional(),
})
.loose()

function isSchemaError(err: unknown): boolean {
if (!err) return false
if (err instanceof Error && (err.name === "ZodError" || err.constructor?.name === "$ZodError")) return true
if (typeof err === "object" && err !== null && "issues" in err) return true
Comment on lines +168 to +169
return false
}

/**
* Calls the SDK's strict `listTools()` first; on a Zod schema-validation
* failure (e.g. server emits non-spec values like `null` annotation hints),
* retries via `client.request()` with a permissive schema. This keeps the
* fast path unchanged for compliant servers while letting non-compliant
* ones (Microsoft Fabric, etc.) still register their tools.
*/
async function listToolsLenient(client: MCPClient): Promise<{ tools: MCPToolDef[] }> {
try {
return await client.listTools()
} catch (err) {
if (!isSchemaError(err)) throw err
log.info("listTools strict schema rejected response, retrying with lenient schema", {
error: err instanceof Error ? err.message.slice(0, 200) : String(err).slice(0, 200),
})
const result = await client.request(
{ method: "tools/list", params: {} },
LenientListToolsResultSchema as any,
)
return result as { tools: MCPToolDef[] }
}
}

/** @internal — exported only for unit tests. Prefer using `tools()` in production code. */
export const _testing = {
LenientListToolsResultSchema,
isSchemaError,
resolveHeadersCommand: (spec: Record<string, string[]> | undefined, key = "test") =>
resolveHeadersCommand(spec, key),
hasAuthorizationHeader,
}
// altimate_change end

// altimate_change start — resolve dynamic header values produced by shell
// commands (e.g. `az account get-access-token`). Runs via execFile (not a
// shell) so values aren't subject to shell injection. Re-runs on every
// connect so expiring bearer tokens refresh without manual config edits.
// See https://github.com/AltimateAI/altimate-code/issues/791.
async function resolveHeadersCommand(
spec: Record<string, string[]> | undefined,
serverKey: string,
): Promise<Record<string, string>> {
if (!spec) return {}
const out: Record<string, string> = {}
for (const [name, argv] of Object.entries(spec)) {
if (!Array.isArray(argv) || argv.length === 0) {
throw new Error(`headersCommand[${name}] must be a non-empty argv array`)
}
const [cmd, ...args] = argv
const { stdout } = await execFileAsync(cmd, args, {
encoding: "utf-8",
maxBuffer: 1024 * 1024,
timeout: 30_000,
})
Comment on lines +217 to +226
const value = stdout.trim()
if (!value) {
throw new Error(`headersCommand[${name}] produced empty output`)
}
log.info("resolved dynamic header", { server: serverKey, header: name })
out[name] = value
}
return out
}

function hasAuthorizationHeader(headers: Record<string, string>): boolean {
return Object.keys(headers).some((k) => k.toLowerCase() === "authorization")
}
// altimate_change end

// Register notification handlers for MCP client
function registerNotificationHandlers(client: MCPClient, serverName: string) {
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
Expand Down Expand Up @@ -364,8 +482,36 @@ export namespace MCP {
let connectedTransport: "stdio" | "sse" | "streamable-http" | undefined = undefined

if (mcp.type === "remote") {
// OAuth is enabled by default for remote servers unless explicitly disabled with oauth: false
const oauthDisabled = mcp.oauth === false
// altimate_change start — resolve dynamic headers (e.g. bearer tokens
// produced by `az account get-access-token`) before constructing
// transports. Failure to resolve aborts the connect attempt with a
// clear error so the user sees `failed: headersCommand[...] failed`
// in `mcp list` rather than a generic transport error.
let dynamicHeaders: Record<string, string> = {}
try {
dynamicHeaders = await resolveHeadersCommand(mcp.headersCommand, key)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
log.error("headersCommand resolution failed", { key, error: message })
return {
mcpClient: undefined,
status: { status: "failed" as const, error: `headersCommand failed: ${message}` },
Comment on lines +495 to +498
}
}
const mergedHeaders: Record<string, string> = { ...(mcp.headers ?? {}), ...dynamicHeaders }
// altimate_change end

// altimate_change start — OAuth is enabled by default for remote servers,
// BUT if the user provided an explicit Authorization header (statically or
// via headersCommand) and didn't ask for OAuth, skip OAuth so the bearer
// header isn't pre-empted by an OAuth flow that fails (e.g. Microsoft
// Entra ID rejects RFC 7591 dynamic client registration). See #792.
const oauthExplicitlyDisabled = mcp.oauth === false
const oauthExplicitlyConfigured = typeof mcp.oauth === "object"
const oauthDisabled =
oauthExplicitlyDisabled ||
(!oauthExplicitlyConfigured && hasAuthorizationHeader(mergedHeaders))
// altimate_change end
const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined
let authProvider: McpOAuthProvider | undefined

Expand All @@ -387,22 +533,25 @@ export namespace MCP {
)
}

// altimate_change start — pass merged (static + dynamic) headers to transports
const requestInit = Object.keys(mergedHeaders).length > 0 ? { headers: mergedHeaders } : undefined
const transports: Array<{ name: string; transport: TransportWithAuth }> = [
{
name: "StreamableHTTP",
transport: new StreamableHTTPClientTransport(new URL(mcp.url), {
authProvider,
requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
requestInit,
}),
},
{
name: "SSE",
transport: new SSEClientTransport(new URL(mcp.url), {
authProvider,
requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
requestInit,
}),
},
]
// altimate_change end

let lastError: Error | undefined
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
Expand All @@ -429,8 +578,10 @@ export namespace MCP {
duration_ms: Date.now() - connectStart,
})
// altimate_change start — bridge merge: prefetch tool list synchronously
// for cache so MCP.tools() doesn't re-call listTools.
const toolsList = await client.listTools().catch(() => undefined)
// for cache so MCP.tools() doesn't re-call listTools. Use lenient
// schema so servers that emit `null` annotation hints (e.g. Fabric)
// don't trip Zod validation. See #792.
const toolsList = await listToolsLenient(client).catch(() => undefined)
if (toolsList) toolListCache.set(key, toolsList.tools)
// altimate_change end
// Census: collect resource counts (fire-and-forget, never block connect)
Expand Down Expand Up @@ -567,8 +718,9 @@ export namespace MCP {
duration_ms: Date.now() - localConnectStart,
})
// altimate_change start — bridge merge: prefetch tool list synchronously
// for cache so MCP.tools() doesn't re-call listTools.
const toolsListSync = await client.listTools().catch(() => undefined)
// for cache so MCP.tools() doesn't re-call listTools. Use lenient schema
// so non-compliant annotation hints (`null`) don't fail validation.
const toolsListSync = await listToolsLenient(client).catch(() => undefined)
if (toolsListSync) toolListCache.set(key, toolsListSync.tools)
// altimate_change end
// Census: collect resource counts (fire-and-forget, never block connect)
Expand Down Expand Up @@ -635,7 +787,7 @@ export namespace MCP {
const cachedTools = toolListCache.get(key)
const result = cachedTools
? { tools: cachedTools }
: await withTimeout(mcpClient.listTools(), mcp.timeout ?? DEFAULT_TIMEOUT).catch((err) => {
: await withTimeout(listToolsLenient(mcpClient), mcp.timeout ?? DEFAULT_TIMEOUT).catch((err) => {
log.error("failed to get tools from client", { key, error: err })
return undefined
})
Expand Down Expand Up @@ -784,7 +936,7 @@ export namespace MCP {
if (cached) {
return { clientName, client, toolsResult: { tools: cached } }
}
const toolsResult = await client.listTools().catch((e) => {
const toolsResult = await listToolsLenient(client).catch((e) => {
log.error("failed to get tools", { clientName, error: e.message })
const failedStatus = {
status: "failed" as const,
Expand Down
Loading
Loading