diff --git a/packages/core/src/config/mcp.ts b/packages/core/src/config/mcp.ts index f3a5ac9b256b..03df85db99ea 100644 --- a/packages/core/src/config/mcp.ts +++ b/packages/core/src/config/mcp.ts @@ -31,6 +31,21 @@ export class OAuth extends Schema.Class("ConfigV2.MCP.OAuth")({ redirect_uri: Schema.String.pipe(Schema.optional), }) {} +export class Tls extends Schema.Class("ConfigV2.MCP.Tls")({ + ca_file: Schema.String.pipe(Schema.optional).annotate({ + description: + "Path to a custom CA certificate file (PEM format) to trust when connecting to this MCP server.", + }), + ca_pem: Schema.String.pipe(Schema.optional).annotate({ + description: + "Custom CA certificate content (PEM format) to trust when connecting to this MCP server.", + }), + fingerprint: Schema.String.pipe(Schema.optional).annotate({ + description: + "SHA256 fingerprint of the server certificate to trust. Format: 'SHA256:XX:XX:...' or 'XX:XX:...'.", + }), +}) {} + export class Remote extends Schema.Class("ConfigV2.MCP.Remote")({ type: Schema.Literal("remote"), url: Schema.String, @@ -38,6 +53,10 @@ export class Remote extends Schema.Class("ConfigV2.MCP.Remote")({ oauth: Schema.Union([OAuth, Schema.Literal(false)]).pipe(Schema.optional), disabled: Schema.Boolean.pipe(Schema.optional), timeout: Timeout.pipe(Schema.optional), + tls: Tls.pipe(Schema.optional).annotate({ + description: + "TLS trust configuration for this MCP server. Use to trust self-signed certificates, private CAs, or pin specific certificates.", + }), }) {} export const Server = Schema.Union([Local, Remote]).pipe(Schema.toTaggedUnion("type")) diff --git a/packages/core/src/v1/config/mcp.ts b/packages/core/src/v1/config/mcp.ts index 0a2aeff12fb0..c4aa4dc77770 100644 --- a/packages/core/src/v1/config/mcp.ts +++ b/packages/core/src/v1/config/mcp.ts @@ -41,6 +41,22 @@ export const OAuth = Schema.Struct({ }).annotate({ identifier: "McpOAuthConfig" }) export type OAuth = Schema.Schema.Type +export const Tls = Schema.Struct({ + caFile: Schema.optional(Schema.String).annotate({ + description: + "Path to a custom CA certificate file (PEM format) to trust when connecting to this MCP server. Only applies to this server; does not affect global TLS.", + }), + caPem: Schema.optional(Schema.String).annotate({ + description: + "Custom CA certificate content (PEM format) to trust when connecting to this MCP server. Useful for self-contained configurations where a separate file is impractical.", + }), + fingerprint: Schema.optional(Schema.String).annotate({ + description: + "SHA256 fingerprint of the server certificate to trust. Format: 'SHA256:XX:XX:...' or 'XX:XX:...'. The client verifies that the server certificate matches before trusting. Similar to SSH host key verification.", + }), +}).annotate({ identifier: "McpTlsConfig" }) +export type Tls = Schema.Schema.Type + export const Remote = Schema.Struct({ type: Schema.Literal("remote").annotate({ description: "Type of MCP server connection" }), url: Schema.String.annotate({ description: "URL of the remote MCP server" }), @@ -56,6 +72,9 @@ export const Remote = Schema.Struct({ timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), + tls: Schema.optional(Tls).annotate({ + description: "TLS trust configuration for this MCP server. Use to trust self-signed certificates, private CAs, or pin specific certificates.", + }), }).annotate({ identifier: "McpRemoteConfig" }) export type Remote = Schema.Schema.Type diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index c2d2ee2f3b73..d61b86330041 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -14,6 +14,8 @@ import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "@/config/config" import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp" import { InstanceRef } from "@/effect/instance-ref" +import { InstanceState } from "@/effect/instance-state" +import { buildTlsCa, createTlsFetch } from "../../mcp/tls" import { InstallationVersion } from "@opencode-ai/core/installation/version" import path from "path" import { Global } from "@opencode-ai/core/global" @@ -677,6 +679,17 @@ export const McpDebugCommand = effectCmd({ entry: auth.get(args.name), }) : undefined + let tlsFetch: typeof fetch | undefined + if (serverConfig && isMcpRemote(serverConfig) && serverConfig.tls) { + const directory = yield* InstanceState.directory + const url = new URL(serverConfig.url) + const result = yield* Effect.tryPromise({ + try: () => buildTlsCa(serverConfig.tls, directory, url), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }) + if (result instanceof Error) throw new Error(result.message) + if (result) tlsFetch = createTlsFetch(result) + } yield* Effect.promise(async () => { UI.empty() prompts.intro("MCP OAuth Debug") @@ -733,7 +746,7 @@ export const McpDebugCommand = effectCmd({ // Test basic HTTP connectivity first try { - const response = await fetch(serverConfig.url, { + const response = await (tlsFetch ?? fetch)(serverConfig.url, { method: "POST", headers: { ...serverConfig.headers, @@ -786,6 +799,7 @@ export const McpDebugCommand = effectCmd({ const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), { authProvider, requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined, + ...(tlsFetch ? { fetch: tlsFetch } : {}), }) try { diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 05f12fa2ee45..1bd0677b584b 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -34,6 +34,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { McpCatalog } from "./catalog" import { McpEvent } from "@opencode-ai/schema/mcp-event" import { McpBrowser } from "./browser" +import { buildTlsCa, createTlsFetch } from "./tls" const DEFAULT_TIMEOUT = 30_000 const CLIENT_OPTIONS = { @@ -266,12 +267,30 @@ const layer = Layer.effect( ) } + let tlsFetch: typeof fetch | undefined + + if (mcp.tls) { + const directory = yield* InstanceState.directory + const result = yield* Effect.tryPromise({ + try: () => buildTlsCa(mcp.tls, directory, url), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }) + if (result instanceof Error) { + return { + client: undefined as MCPClient | undefined, + status: { status: "failed" as const, error: result.message }, + } + } + if (result) tlsFetch = createTlsFetch(result) + } + const transports: Array<{ name: string; transport: TransportWithAuth }> = [ { name: "StreamableHTTP", transport: new StreamableHTTPClientTransport(url, { authProvider, requestInit: mcp.headers ? { headers: mcp.headers } : undefined, + ...(tlsFetch ? { fetch: tlsFetch } : {}), }), }, { @@ -279,6 +298,7 @@ const layer = Layer.effect( transport: new SSEClientTransport(url, { authProvider, requestInit: mcp.headers ? { headers: mcp.headers } : undefined, + ...(tlsFetch ? { fetch: tlsFetch } : {}), }), }, ] @@ -843,9 +863,22 @@ const layer = Layer.effect( auth, ) + let tlsFetch: typeof fetch | undefined + + if (mcpConfig.tls) { + const directory = yield* InstanceState.directory + const result = yield* Effect.tryPromise({ + try: () => buildTlsCa(mcpConfig.tls, directory, url), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }) + if (result instanceof Error) throw new Error(result.message) + if (result) tlsFetch = createTlsFetch(result) + } + const transport = new StreamableHTTPClientTransport(url, { authProvider, requestInit: mcpConfig.headers ? { headers: mcpConfig.headers } : undefined, + ...(tlsFetch ? { fetch: tlsFetch } : {}), }) const directory = yield* InstanceState.directory diff --git a/packages/opencode/src/mcp/tls.ts b/packages/opencode/src/mcp/tls.ts new file mode 100644 index 000000000000..c2c3cf038433 --- /dev/null +++ b/packages/opencode/src/mcp/tls.ts @@ -0,0 +1,241 @@ +import { closeSync, fstatSync, openSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import path from "node:path" +import tls from "node:tls" + +const PEM_CERT_HEADER = "-----BEGIN CERTIFICATE-----" +const PEM_CERT_FOOTER = "-----END CERTIFICATE-----" +const MAX_CA_FILE_SIZE = 1024 * 1024 +const FINGERPRINT_PATTERN = + /^(?:SHA256:)?(?:[0-9A-Fa-f]{2}:){31}[0-9A-Fa-f]{2}$|^(?:SHA256:)?[0-9A-Fa-f]{64}$/ + +export interface TlsConfig { + readonly caFile?: string + readonly caPem?: string + readonly fingerprint?: string +} + +/** + * Resolve a file path, expanding `~` to the user home directory. + * Absolute paths are returned as-is. Relative paths are resolved against the workspace directory. + * + * Path traversal via `..` is intentionally not restricted — the config author + * controls the CA file location. File content is always validated (must be PEM) + * and never exposed outside the TLS layer, so reading an unintended file is + * harmless beyond the CA trust it enables (which is the user's explicit intent). + */ +export function resolveFilePath(filePath: string, workspaceDir: string): string { + if (filePath.startsWith("~")) return path.join(homedir(), filePath.slice(2)) + if (path.isAbsolute(filePath)) return filePath + return path.resolve(workspaceDir, filePath) +} + +/** + * Validate that a string contains at least one valid PEM certificate block. + * Checks for the required BEGIN/END markers. Does not validate the certificate + * cryptographically — that is left to the TLS layer at connection time. + * + * Certificates may be concatenated (CA bundle), so multiple BEGIN/END pairs + * are accepted as long as the first one is well-formed. + */ +export function validatePemCert(content: string, source: string): void { + const beginIdx = content.indexOf(PEM_CERT_HEADER) + if (beginIdx === -1) throw new Error(`${source}: missing PEM certificate header ("${PEM_CERT_HEADER}")`) + const endIdx = content.indexOf(PEM_CERT_FOOTER, beginIdx) + if (endIdx === -1) throw new Error(`${source}: missing PEM certificate footer ("${PEM_CERT_FOOTER}")`) + const between = content.slice(beginIdx + PEM_CERT_HEADER.length, endIdx) + if (between.trim().length === 0) throw new Error(`${source}: empty PEM certificate body`) +} + +/** + * Validate a SHA256 fingerprint string. + * Accepts formats like: + * "SHA256:AA:BB:CC:DD:..." (with optional prefix and colons) + * "AA:BB:CC:DD:..." (with colons, no prefix) + * "AABBCCDD..." (bare hex, no colons) + * + * Returns the normalized lowercase hex string (64 chars, no prefix, no colons). + */ +export function validateFingerprint(raw: string): string { + const trimmed = raw.trim() + if (!FINGERPRINT_PATTERN.test(trimmed)) { + throw new Error( + `Invalid SHA256 fingerprint: "${trimmed}". Expected 64 hex characters, ` + + `optionally prefixed with "SHA256:" and separated by colons ` + + `(e.g. "SHA256:AA:BB:CC:DD:...").`, + ) + } + return trimmed.replace(/^SHA256:/i, "").replace(/:/g, "").toLowerCase() +} + +/** + * Read and validate a CA certificate file (PEM format). + * + * Opens the file once and uses the file descriptor for both stat and read, + * closing the TOCTOU window between validation and content retrieval. + * + * Throws with a descriptive error message for missing files, directories, + * non-certificate content, or files that are too large. + */ +export function readCaFile(filePath: string, workspaceDir: string): string { + const resolved = resolveFilePath(filePath, workspaceDir) + + let fd: number + try { + fd = openSync(resolved, "r") + } catch { + throw new Error(`CA file not found: ${filePath}`) + } + + try { + const stat = fstatSync(fd) + if (!stat.isFile()) throw new Error(`CA path is not a file: ${filePath}`) + if (stat.size > MAX_CA_FILE_SIZE) + throw new Error(`CA file too large (${stat.size} bytes, max ${MAX_CA_FILE_SIZE}): ${filePath}`) + + const content = readFileSync(fd, "utf-8").trim() + validatePemCert(content, filePath) + return content + } finally { + closeSync(fd) + } +} + +/** + * Build a TLS CA certificate string from the full MCP TLS configuration. + * + * Reads files, validates PEM content, and/or verifies fingerprint pinning + * as specified in the config. Returns an empty string when no TLS options + * are configured (meaning "use the system trust store"). + * + * Throws with descriptive messages for validation failures, missing files, + * or fingerprint mismatches so the caller can surface the specific error. + */ +export async function buildTlsCa(tls: TlsConfig, workspaceDir: string, url: URL): Promise { + let ca = "" + + if (tls.caPem) { + validatePemCert(tls.caPem, "caPem config entry") + ca += tls.caPem + } + + if (tls.caFile) { + ca += readCaFile(tls.caFile, workspaceDir) + } + + if (tls.fingerprint) { + const normalized = validateFingerprint(tls.fingerprint) + const port = Number(url.port) || 443 + const pem = await verifyServerFingerprint(url.hostname, port, normalized) + ca += pem + } + + return ca +} + +/** + * Convert a DER-encoded certificate to PEM format so it can be used as a trusted CA. + * Processes the raw bytes in chunks to avoid call-stack overflow on large certificates. + */ +export function derToPem(der: ArrayBuffer): string { + const bytes = new Uint8Array(der) + let base64 = "" + for (let i = 0; i < bytes.length; i += 4096) { + const chunk = bytes.subarray(i, i + 4096) + base64 += btoa(String.fromCharCode(...chunk)) + } + const lines = base64.match(/.{1,64}/g) ?? [] + return `-----BEGIN CERTIFICATE-----\n${lines.join("\n")}\n-----END CERTIFICATE-----\n` +} + +/** + * Pre-flight TLS connection to verify a server certificate fingerprint. + * + * Opens a raw TLS socket to the server, retrieves the presented certificate, + * computes its SHA256 fingerprint, and compares it with the expected value. + * If the fingerprint matches, the certificate is returned as a PEM string + * that can be used as a trusted CA for subsequent connections. + * + * This is the only place where certificate verification is temporarily + * relaxed (`rejectUnauthorized: false`) — and only so we can access the + * certificate to verify its fingerprint manually. After verification, + * the returned PEM is used as a strict trust anchor. + * The socket is destroyed immediately after the certificate is retrieved; + * no application data is exchanged over the unverified connection. + * + * @param expectedFingerprint Pre-normalized fingerprint (no prefix, no colons, lowercase) + */ +function verifyServerFingerprint( + hostname: string, + port: number, + expectedFingerprint: string, +): Promise { + return new Promise((resolve, reject) => { + const socket = tls.connect({ + host: hostname, + port, + rejectUnauthorized: false, + servername: hostname, + }) + + socket.once("secureConnect", () => { + const cert = socket.getPeerX509Certificate() + if (!cert) { + socket.destroy() + reject(new Error("No certificate presented by server")) + return + } + + const rawFingerprint = cert.fingerprint256 + if (!rawFingerprint) { + socket.destroy() + reject(new Error("Could not compute certificate fingerprint")) + return + } + + const certFingerprint = rawFingerprint.replace(/:/g, "").toLowerCase() + if (certFingerprint !== expectedFingerprint) { + socket.destroy() + reject( + new Error( + `Certificate fingerprint mismatch.\n` + + `Expected: ${expectedFingerprint}\n` + + `Got: ${rawFingerprint}`, + ), + ) + return + } + + const pem = derToPem(cert.raw) + socket.destroy() + resolve(pem) + }) + + socket.once("error", (err) => { + socket.destroy() + reject(new Error(`TLS pre-flight connection failed: ${err.message}`)) + }) + + socket.setTimeout(10_000, () => { + socket.destroy() + reject(new Error("TLS pre-flight connection timed out")) + }) + }) +} + +/** + * Create a custom `fetch` that trusts the given CA certificate(s) for all requests. + * + * This wraps the global `fetch` and injects the CA certificate via Bun's `tls.ca` + * option on every request. The trust is scoped to this fetch instance only and + * does not affect other connections in the process. + * + * Security note: the custom CA is trusted for all hostnames reached through this + * fetch instance, including redirect targets. This matches standard CA trust + * semantics (e.g. `curl --cacert`). If the MCP server redirects to a different + * host, the custom CA will also be trusted for that host. + */ +export function createTlsFetch(ca: string): typeof fetch { + const tlsOpt = { tls: { ca } } + return (input, init) => globalThis.fetch(input, { ...init, ...tlsOpt }) as Promise +} diff --git a/packages/opencode/test/mcp/tls.test.ts b/packages/opencode/test/mcp/tls.test.ts new file mode 100644 index 000000000000..38004c122dc6 --- /dev/null +++ b/packages/opencode/test/mcp/tls.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, test } from "bun:test" +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir as osTmpdir } from "node:os" +import path from "node:path" +import { + buildTlsCa, + createTlsFetch, + derToPem, + readCaFile, + resolveFilePath, + validateFingerprint, + validatePemCert, +} from "../../src/mcp/tls" + +const SAMPLE_CERT = `-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJALRNRw0Gx+9FMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMjQwMTAxMDAwMDAwWhcNMjUwMTAxMDAwMDAwWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA0Z3VS5JJcV8xQKNl8N3t9X5N0JF7J8L8HKt1qE4fG2mO5PqR7sTuVwXy +Z1aBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTjKvWxVyZaBcDeF +gHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmN +oPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqN +vWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaB +cDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjI +QIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQBoJmO5PqR7sTuVwXyZ1aBcDeFgHjIkL +mNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsT +qNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZ +aBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgH +jIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoP +qRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNXv +-----END CERTIFICATE----- +` + +const SAMPLE_CA_BUNDLE = `${SAMPLE_CERT}\n${SAMPLE_CERT}` + +// readCaFile trims trailing whitespace, so match against trimmed cert +const SAMPLE_CERT_TRIMMED = SAMPLE_CERT.trim() + +function tmpdir(): { path: string; [Symbol.dispose](): void } { + const dir = realpathSync(mkdtempSync(path.join(osTmpdir(), "opencode-test-tls-"))) + return { + path: dir, + [Symbol.dispose]() { + rmSync(dir, { recursive: true, force: true }) + }, + } +} + +function writeTempFile(dir: string, name: string, content: string) { + const filePath = path.join(dir, name) + writeFileSync(filePath, content) + return filePath +} + +describe("validatePemCert", () => { + test("accepts a valid PEM certificate", () => { + expect(() => validatePemCert(SAMPLE_CERT, "test")).not.toThrow() + }) + + test("accepts a CA bundle with multiple certificates", () => { + expect(() => validatePemCert(SAMPLE_CA_BUNDLE, "test")).not.toThrow() + }) + + test("rejects content without BEGIN marker", () => { + expect(() => validatePemCert("just some text", "test")).toThrow("missing PEM certificate header") + }) + + test("rejects content with BEGIN but no END marker", () => { + expect(() => + validatePemCert("-----BEGIN CERTIFICATE-----\nbase64data", "test"), + ).toThrow("missing PEM certificate footer") + }) + + test("rejects content with empty body between markers", () => { + expect(() => + validatePemCert("-----BEGIN CERTIFICATE-----\n\n-----END CERTIFICATE-----", "test"), + ).toThrow("empty PEM certificate body") + }) + + test("rejects non-PEM content masquerading with partial markers", () => { + expect(() => validatePemCert("-----BEGIN CERT-----not real-----END CERT-----", "test")).toThrow( + "missing PEM certificate header", + ) + }) + + test("includes the source name in error messages", () => { + expect(() => validatePemCert("bad content", "caPem config entry")).toThrow("caPem config entry") + }) +}) + +describe("validateFingerprint", () => { + test("accepts bare hex fingerprint", () => { + const result = validateFingerprint("a".repeat(64)) + expect(result).toBe("a".repeat(64)) + }) + + test("accepts colon-separated fingerprint", () => { + const result = validateFingerprint( + "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + ) + expect(result).toHaveLength(64) + expect(result).not.toContain(":") + expect(result).not.toContain("SHA256") + }) + + test("accepts SHA256: prefixed fingerprint", () => { + const result = validateFingerprint("SHA256:" + "a".repeat(64)) + expect(result).toBe("a".repeat(64)) + }) + + test("accepts SHA256: prefixed with colons", () => { + const result = validateFingerprint( + "SHA256:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + ) + expect(result).toHaveLength(64) + }) + + test("lowercases the result", () => { + const result = validateFingerprint( + "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + ) + expect(result).toBe(result.toLowerCase()) + }) + + test("trims whitespace", () => { + const result = validateFingerprint(" " + "a".repeat(64) + " ") + expect(result).toBe("a".repeat(64)) + }) + + test("rejects empty string", () => { + expect(() => validateFingerprint("")).toThrow("Invalid SHA256 fingerprint") + }) + + test("rejects wrong length hex", () => { + expect(() => validateFingerprint("a".repeat(63))).toThrow("Invalid SHA256 fingerprint") + expect(() => validateFingerprint("a".repeat(65))).toThrow("Invalid SHA256 fingerprint") + }) + + test("rejects non-hex characters", () => { + expect(() => validateFingerprint("g" + "a".repeat(63))).toThrow("Invalid SHA256 fingerprint") + }) + + test("rejects wrong prefix", () => { + expect(() => validateFingerprint("SHA1:" + "a".repeat(64))).toThrow("Invalid SHA256 fingerprint") + expect(() => validateFingerprint("MD5:" + "a".repeat(64))).toThrow("Invalid SHA256 fingerprint") + }) + + test("rejects SQL injection attempt in fingerprint", () => { + expect(() => validateFingerprint("' OR '1'='1")).toThrow("Invalid SHA256 fingerprint") + }) + + test("rejects shell injection attempt in fingerprint", () => { + expect(() => validateFingerprint("$(rm -rf /)")).toThrow("Invalid SHA256 fingerprint") + }) +}) + +describe("resolveFilePath", () => { + test("expands ~ to home directory", () => { + const result = resolveFilePath("~/test/file.pem", "/workspace") + expect(result).not.toContain("~") + expect(result).toContain("test") + }) + + test("returns absolute paths as-is", () => { + const result = resolveFilePath("/absolute/path/ca.pem", "/workspace") + expect(result).toBe("/absolute/path/ca.pem") + }) + + test("resolves relative paths against workspace", () => { + const result = resolveFilePath("certs/ca.pem", "/workspace") + expect(result).toBe(path.resolve("/workspace", "certs/ca.pem")) + }) +}) + +describe("readCaFile", () => { + test("reads a valid PEM file", () => { + using dir = tmpdir() + const filePath = writeTempFile(dir.path, "ca.pem", SAMPLE_CERT) + const content = readCaFile(filePath, dir.path) + expect(content).toBe(SAMPLE_CERT_TRIMMED) + }) + + test("resolves relative paths against workspace", () => { + using dir = tmpdir() + writeTempFile(dir.path, "ca.pem", SAMPLE_CERT) + const content = readCaFile("ca.pem", dir.path) + expect(content).toBe(SAMPLE_CERT_TRIMMED) + }) + + test("throws if file does not exist", () => { + expect(() => readCaFile("nonexistent.pem", "/workspace")).toThrow("CA file not found") + }) + + test("throws if path is a directory", () => { + using dir = tmpdir() + expect(() => readCaFile(dir.path, dir.path)).toThrow("CA path is not a file") + }) + + test("throws if file does not contain PEM cert", () => { + using dir = tmpdir() + const filePath = writeTempFile(dir.path, "bad.pem", "not a certificate") + expect(() => readCaFile(filePath, dir.path)).toThrow("missing PEM certificate header") + }) + + test("throws if file is empty", () => { + using dir = tmpdir() + const filePath = writeTempFile(dir.path, "empty.pem", "") + expect(() => readCaFile(filePath, dir.path)).toThrow() + }) + + test("includes original filePath in error messages", () => { + expect(() => readCaFile("nonexistent.pem", "/workspace")).toThrow("nonexistent.pem") + }) +}) + +describe("derToPem", () => { + test("converts DER bytes to PEM format", () => { + const der = new TextEncoder().encode("dummy-cert-bytes").buffer + const pem = derToPem(der) + expect(pem).toStartWith("-----BEGIN CERTIFICATE-----") + expect(pem).toEndWith("-----END CERTIFICATE-----\n") + }) + + test("output is valid base64 between markers", () => { + const raw = Uint8Array.from({ length: 256 }, (_, i) => i) + const pem = derToPem(raw.buffer) + const lines = pem.split("\n") + // PEM format: header, base64 body lines, footer, trailing empty line + const bodyLines = lines.slice(1, -2) + for (const line of bodyLines) { + expect(line).toMatch(/^[A-Za-z0-9+/=]{0,64}$/) + } + }) + + test("empty DER produces minimal valid PEM", () => { + const pem = derToPem(new ArrayBuffer(0)) + expect(pem).toBe("-----BEGIN CERTIFICATE-----\n\n-----END CERTIFICATE-----\n") + }) +}) + +describe("buildTlsCa", () => { + const url = new URL("https://example.com/mcp") + + test("returns empty string when no TLS options configured", async () => { + const ca = await buildTlsCa({}, "/workspace", url) + expect(ca).toBe("") + }) + + test("builds CA from caPem", async () => { + const ca = await buildTlsCa({ caPem: SAMPLE_CERT }, "/workspace", url) + expect(ca).toBe(SAMPLE_CERT) + }) + + test("builds CA from caFile", async () => { + using dir = tmpdir() + const filePath = writeTempFile(dir.path, "ca.pem", SAMPLE_CERT) + const ca = await buildTlsCa({ caFile: filePath }, dir.path, url) + expect(ca).toBe(SAMPLE_CERT_TRIMMED) + }) + + test("concatenates caPem and caFile", async () => { + using dir = tmpdir() + const filePath = writeTempFile(dir.path, "ca2.pem", SAMPLE_CERT) + const ca = await buildTlsCa({ caPem: SAMPLE_CERT, caFile: filePath }, dir.path, url) + // caPem is kept as-is, caFile content is trimmed by readCaFile + expect(ca).toBe(SAMPLE_CERT + SAMPLE_CERT_TRIMMED) + }) + + test("throws if caPem is not valid PEM", async () => { + await expect(buildTlsCa({ caPem: "not a cert" }, "/workspace", url)).rejects.toThrow( + "missing PEM certificate header", + ) + }) + + test("throws if caFile does not exist", async () => { + await expect(buildTlsCa({ caFile: "nonexistent.pem" }, "/workspace", url)).rejects.toThrow( + "CA file not found", + ) + }) + + test("throws if fingerprint is invalid format", async () => { + await expect( + buildTlsCa({ fingerprint: "not-a-fingerprint" }, "/workspace", url), + ).rejects.toThrow("Invalid SHA256 fingerprint") + }) +}) + +describe("createTlsFetch", () => { + test("returns a function", () => { + const customFetch = createTlsFetch(SAMPLE_CERT) + expect(typeof customFetch).toBe("function") + }) + + test("injects tls.ca into requests", async () => { + const customFetch = createTlsFetch(SAMPLE_CERT) + + let capturedInit: RequestInit & { tls?: { ca?: string } } | undefined + const originalFetch = globalThis.fetch + globalThis.fetch = ((_input: string | URL | Request, init?: RequestInit) => { + capturedInit = init as typeof capturedInit + return Promise.resolve(new Response("ok")) + }) as typeof fetch + + try { + await customFetch("https://example.com/test", { method: "POST" }) + expect(capturedInit).toBeDefined() + expect(capturedInit!.tls).toBeDefined() + expect(capturedInit!.tls!.ca).toBe(SAMPLE_CERT) + expect(capturedInit!.method).toBe("POST") + } finally { + globalThis.fetch = originalFetch + } + }) + + test("preserves original init when no init provided", async () => { + const customFetch = createTlsFetch(SAMPLE_CERT) + + let capturedInit: RequestInit & { tls?: { ca?: string } } | undefined + const originalFetch = globalThis.fetch + globalThis.fetch = ((_input: string | URL | Request, init?: RequestInit) => { + capturedInit = init as typeof capturedInit + return Promise.resolve(new Response("ok")) + }) as typeof fetch + + try { + await customFetch("https://example.com/test") + expect(capturedInit).toBeDefined() + expect(capturedInit!.tls).toBeDefined() + } finally { + globalThis.fetch = originalFetch + } + }) +}) diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index 215938ec3b11..1e147d8d6d96 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -161,6 +161,97 @@ The `url` is the URL of the remote MCP server and with the `headers` option you | `headers` | Object | | Headers to send with the request. | | `oauth` | Object | | OAuth authentication configuration. See [OAuth](#oauth) section below. | | `timeout` | Number | | Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds). | +| `tls` | Object | | TLS trust configuration for self-signed certs. See [TLS Trust](#tls-trust) below. | + +--- + +## TLS Trust + +Many MCP servers run on private infrastructure — network appliances, homelab devices, enterprise internal services — where certificates are self-signed or issued by a private CA. OpenCode supports per-server TLS trust so you don't need to disable verification globally. + +All three options below apply **only to the configured MCP server**. Global TLS is unaffected. + +--- + +### Custom CA file + +Point to a PEM-encoded CA certificate file. Relative paths resolve from the workspace, `~/` expands to your home directory. + +```json title="opencode.json" {7-9} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "opnsense": { + "type": "remote", + "url": "https://192.168.1.1/mcp", + "tls": { + "caFile": "~/.config/mcp/opnsense-ca.pem" + } + } + } +} +``` + +--- + +### Embedded CA certificate + +Embed the CA certificate directly in your config. Useful for appliances that generate a self-contained MCP configuration. + +```json title="opencode.json" {7-9} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "proxmox": { + "type": "remote", + "url": "https://192.168.1.10/mcp", + "tls": { + "caPem": "-----BEGIN CERTIFICATE-----\nMIIDXTCCAkWgAwIBAg...\n-----END CERTIFICATE-----" + } + } + } +} +``` + +--- + +### Certificate fingerprint pinning + +Pin a specific server certificate by its SHA256 fingerprint, similar to SSH host key verification. OpenCode connects to the server once to verify the fingerprint, then trusts that exact certificate. + +```json title="opencode.json" {7-9} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "truenas": { + "type": "remote", + "url": "https://192.168.1.20/mcp", + "tls": { + "fingerprint": "SHA256:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99" + } + } + } +} +``` + +You can get the fingerprint from your server with: + +```bash +openssl s_client -connect 192.168.1.20:443 /dev/null \ + | openssl x509 -noout -fingerprint -sha256 +``` + +The fingerprint accepts formats like `SHA256:AA:BB:CC:...`, `AA:BB:CC:...` (with colons), or `AABBCCDD...` (bare hex). + +--- + +#### TLS Options + +| Option | Type | Description | +| ------------- | ------ | ------------------------------------------------------------------------------------------------------------- | +| `caFile` | String | Path to a PEM-encoded CA certificate file. Relative paths resolve from the workspace. | +| `caPem` | String | Inline PEM-encoded CA certificate content. Useful for self-contained configurations. | +| `fingerprint` | String | SHA256 fingerprint of the server certificate to pin. The client verifies the cert matches before trusting it. | ---