diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index 950804b87276..ba7a0e5e0c31 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -300,16 +300,22 @@ export const connect = Effect.fnUntraced(function* ( } const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js")) const fetch = yield* McpOAuth.loggedFetch({ server, directory }) + const { McpTls } = yield* Effect.promise(() => import("./tls.js")) // Servers that bundle their own Code Mode (Cloudflare and others) expose raw tools when asked // with ?codemode=false, which is what our Code Mode wants. The configured URL stays the OAuth identity. const url = new URL(config.url) const addedCodemode = config.codemode !== false && !url.searchParams.has("codemode") && !rejectsUnknownQuery(url) if (addedCodemode) url.searchParams.set("codemode", "false") + // A missing CA file or a fingerprint mismatch fails the connect instead of the first request. + const tls = config.tls + const resolved = tls + ? yield* Effect.tryPromise({ try: () => McpTls.resolveTls(tls, directory, url), catch: toError }) + : undefined const open = Effect.fnUntraced(function* (url: URL) { session.transport = new StreamableHTTPClientTransport(url, { requestInit: config.headers ? { headers: config.headers } : undefined, authProvider, - fetch, + fetch: resolved ? McpTls.withTls(resolved, fetch) : fetch, }) return yield* initialize(session.transport) }) diff --git a/packages/core/src/mcp/tls.ts b/packages/core/src/mcp/tls.ts new file mode 100644 index 000000000000..6d87be1cf3c5 --- /dev/null +++ b/packages/core/src/mcp/tls.ts @@ -0,0 +1,246 @@ +export * as McpTls from "./tls.js" + +import { createHash } from "node:crypto" +import { closeSync, fstatSync, openSync, readFileSync } from "node:fs" +import { isIP } from "node:net" +import { homedir } from "node:os" +import path from "node:path" +import tls, { type PeerCertificate } from "node:tls" +import type { ConfigMCP } from "@opencode/schema/config/mcp" + +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}$/ + +/** + * Resolve a CA file path: `~` expands to the home directory, absolute paths pass through, + * and relative paths resolve from the workspace directory. + */ +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 content holds at least one well-formed PEM certificate block. Cryptographic + * validation is the TLS layer's job at connection time; certificates may be concatenated, so + * only the first BEGIN/END pair must be complete. + */ +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}")`) + if (content.slice(beginIdx + PEM_CERT_HEADER.length, endIdx).trim().length === 0) + throw new Error(`${source}: empty PEM certificate body`) +} + +/** + * Validate a SHA256 fingerprint and normalize it to lowercase hex without prefix or 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. The file is opened once and the descriptor is used + * for both stat and read, so it cannot change between validation and content retrieval. + */ +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) + } +} + +/** + * The trust rules a server's connections are subject to. `ca` is a PEM trust anchor bundle; + * `fingerprint` pins one exact certificate, whose SHA256 fingerprint is re-checked on every + * handshake. + */ +export type ResolvedTls = { + readonly ca: string + readonly fingerprint?: string +} + +/** + * Resolve a server's TLS config into the trust rules for its connections. An undefined result + * means "use the system trust store". A pinned fingerprint opens one pre-flight TLS connection; + * the presented certificate must match it exactly and becomes the trust anchor for the real + * connection, so chain verification only accepts that certificate. Name matching is deliberately + * not enforced: LAN appliance certificates often omit the IP SANs a hostname check would require, + * and the fingerprint already identifies the certificate the user pinned. + */ +export async function resolveTls( + config: ConfigMCP.Tls, + workspaceDir: string, + url: URL, +): Promise { + if (config.fingerprint !== undefined) { + const expected = validateFingerprint(config.fingerprint) + const cert = await probeCertificate(url.hostname, port(url)) + if (cert.fingerprint !== expected) + throw new Error(`Certificate fingerprint mismatch.\nExpected: ${expected}\nGot: ${cert.fingerprint}`) + return { ca: cert.pem, fingerprint: expected } + } + if (config.ca_pem !== undefined) validatePemCert(config.ca_pem, "ca_pem config entry") + const ca = [ + config.ca_pem, + config.ca_file !== undefined ? readCaFile(config.ca_file, workspaceDir) : undefined, + ] + .filter((part): part is string => part !== undefined) + .join("") + return ca === "" ? undefined : { ca } +} + +/** + * Wrap a fetch so its requests apply the resolved TLS trust rules. The trust is scoped to the + * returned fetch and does not affect other connections, including redirects it follows. A pinned + * fingerprint anchors chain verification to the pinned certificate itself, and the identity hook + * replaces the hostname check while re-checking the fingerprint on every handshake. + */ +export function withTls( + resolved: ResolvedTls, + base: (url: string | URL, init?: RequestInit) => Promise, +): (url: string | URL, init?: RequestInit) => Promise { + const fingerprint = resolved.fingerprint + if (fingerprint !== undefined) { + return (url, init) => + base(url, { + ...init, + tls: { + ca: resolved.ca, + // Replaces the hostname check: returning undefined accepts the certificate (even one + // without an IP SAN), returning an Error rejects it. + checkServerIdentity: (_hostname: string, cert: PeerCertificate) => { + const presented = createHash("sha256").update(cert.raw).digest("hex") + if (presented !== fingerprint) + return new Error(`Certificate fingerprint mismatch.\nExpected: ${fingerprint}\nGot: ${presented}`) + }, + }, + } as BunFetchRequestInit) + } + return (url, init) => base(url, { ...init, tls: { ca: resolved.ca } } as BunFetchRequestInit) +} + +/** + * True when a connection failure is a TLS certificate verification error rather than a network, + * protocol, or HTTP failure. Current runtimes report these as message-only errors ("self signed + * certificate", "ERR_TLS_CERT_ALTNAME_INVALID", "certificate has expired"); Node-style codes are + * matched as well for the runtimes that expose them. + */ +export function isTlsCertificateError(error: unknown): boolean { + const patterns = [ + /self[- ]signed/i, + /ERR_TLS_CERT_ALTNAME_INVALID|CERT_ALTNAME/i, + /UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER_CERT_LOCALLY|unable to verify/i, + /CERT_HAS_EXPIRED|certificate has expired/i, + /DEPTH_ZERO/i, + ] + let current: unknown = error + for (let depth = 0; depth < 5; depth++) { + if (!(current instanceof Error)) break + const code = (current as { code?: unknown }).code + const text = `${code === undefined ? "" : `${code} `}${current.message}` + if (patterns.some((pattern) => pattern.test(text))) return true + current = (current as { cause?: unknown }).cause + } + return false +} + +export interface Certificate { + /** The presented certificate as PEM, usable as a trust anchor. */ + readonly pem: string + /** SHA256 fingerprint, lowercase hex without colons. */ + readonly fingerprint: string + /** Distinguished name of the certificate subject, e.g. "CN=OPNsense, O=Foo Corp". */ + readonly subject: string + /** Expiry as epoch milliseconds. */ + readonly validTo: number +} + +/** + * Read the certificate a server presents without validating it against any trust store. The + * socket is destroyed before any application data is sent. + */ +export function probeCertificate(hostname: string, portNumber: number): Promise { + return new Promise((resolve, reject) => { + // SNI rejects IP literals as server names, but LAN appliances are exactly the servers this + // feature targets, so omit the server name for IP addresses. + const bare = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname + const socket = tls.connect({ + host: hostname, + port: portNumber, + rejectUnauthorized: false, + ...(isIP(bare) === 0 ? { servername: hostname } : {}), + }) + const fail = (message: string) => { + socket.destroy() + reject(new Error(message)) + } + socket.once("secureConnect", () => { + const cert = socket.getPeerX509Certificate() + if (!cert) return fail("No certificate presented by server") + const presented = cert.fingerprint256 + if (!presented) return fail("Could not compute certificate fingerprint") + const validTo = new Date(cert.validTo).getTime() + if (Number.isNaN(validTo)) return fail("Could not parse certificate validity") + socket.destroy() + resolve({ + pem: derToPem(cert.raw), + fingerprint: presented.replace(/:/g, "").toLowerCase(), + subject: cert.subject, + validTo, + }) + }) + socket.once("error", (err: Error) => { + 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")) + }) + }) +} + +/** + * The port of a server URL, with the scheme default when none is stated. + */ +export function port(url: URL): number { + return Number(url.port) || (url.protocol === "https:" ? 443 : 80) +} + +/** + * Convert a DER certificate to PEM so it can be used as a trust anchor. Bytes are base64-encoded + * in chunks to keep the spread argument small on large certificates. + */ +export function derToPem(der: ArrayBuffer): string { + const bytes = new Uint8Array(der) + const chunks: string[] = [] + for (let i = 0; i < bytes.length; i += 4096) chunks.push(btoa(String.fromCharCode(...bytes.subarray(i, i + 4096)))) + const lines = chunks.join("").match(/.{1,64}/g) ?? [] + return `-----BEGIN CERTIFICATE-----\n${lines.join("\n")}\n-----END CERTIFICATE-----\n` +} diff --git a/packages/core/test/fixture/tls-server.ts b/packages/core/test/fixture/tls-server.ts new file mode 100644 index 000000000000..b84ca22e7d68 --- /dev/null +++ b/packages/core/test/fixture/tls-server.ts @@ -0,0 +1,41 @@ +import { readFileSync } from "node:fs" +import path from "node:path" +import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server" + +export const TLS_FIXTURE_DIR = path.join(import.meta.dir, "tls") + +// Long-lived self-signed certificate for 127.0.0.1 and localhost. +export const CERT = readFileSync(path.join(TLS_FIXTURE_DIR, "localhost.crt"), "utf-8") +export const KEY = readFileSync(path.join(TLS_FIXTURE_DIR, "localhost.key"), "utf-8") +// Appliance-style certificate: a CN and no subjectAltName. A hostname or IP name check fails +// against it, which is exactly why the fingerprint is the identity check when it is pinned. +export const CERT_APPLIANCE = readFileSync(path.join(TLS_FIXTURE_DIR, "appliance.crt"), "utf-8") +export const KEY_APPLIANCE = readFileSync(path.join(TLS_FIXTURE_DIR, "appliance.key"), "utf-8") + +export function startTlsMcpServer(options: { cert?: string; key?: string; port?: number } = {}) { + const cert = options.cert ?? CERT + const key = options.key ?? KEY + const protocol = new Server({ name: "tls-test", version: "1.0.0" }, { capabilities: { tools: {} } }) + protocol.setRequestHandler("tools/list", () => + Promise.resolve({ tools: [{ name: "echo", inputSchema: { type: "object", properties: {} } }] }), + ) + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() }) + return protocol.connect(transport).then(() => { + const http = Bun.serve({ + port: options.port ?? 0, + hostname: "127.0.0.1", + tls: { cert, key }, + fetch: (request) => transport.handleRequest(request), + }) + const port = http.port + if (port === undefined) throw new Error("TLS server did not bind a port") + return { + url: `${http.url}/mcp`, + port, + close: async () => { + await protocol.close().catch(() => {}) + http.stop(true) + }, + } + }) +} diff --git a/packages/core/test/fixture/tls/appliance.crt b/packages/core/test/fixture/tls/appliance.crt new file mode 100644 index 000000000000..02cf5f321735 --- /dev/null +++ b/packages/core/test/fixture/tls/appliance.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDFTCCAf2gAwIBAgIUHT9Q3Me6KRf/symqQZXz7N440RowDQYJKoZIhvcNAQEL +BQAwGjEYMBYGA1UEAwwPYXBwbGlhbmNlLmxvY2FsMB4XDTI2MTAyOTIwMDAyMFoX +DTM2MTAyNjIwMDAyMFowGjEYMBYGA1UEAwwPYXBwbGlhbmNlLmxvY2FsMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAys+pDvBb235QEzENdwA0lWeeGVL7 +oJOv3A9lC1m172GZp0xHdvlyke/CwptopRQjr5+3aUq7sIDw4phSSRUfCtTFkbC2 +/AQ8ztnXhVcERuHz+uNZfUUnEpQUQiNmUDxACkuijAvCBlub/wj/VN2LJr2OeW3V +3D+KfW+f4ZtHCXPuDRGVBLqd21PgQJTRUTjXp+tUEp/B3Wr4bd2TPqzpIxJGM9/l +UOybgDFbz6k+3taOPQn9y5IcXU+8GD/AFbt6kxX7AXGvDGPe9hxop5uZxEKJhfQ5 +XPPLlx1oXxbyGZxelwwUkjLgiXJW3ekFjYIOaIcEbe+nlkf+9+JnTxG3mwIDAQAB +o1MwUTAdBgNVHQ4EFgQUZE/eBykoTexgw0u6lIj4mka7+OowHwYDVR0jBBgwFoAU +ZE/eBykoTexgw0u6lIj4mka7+OowDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B +AQsFAAOCAQEAFcV0CZbU7nt3tJ/sSn4p4X71v5gKBPUASk/Y/RFhZ2xnwAk4Cgs2 +RCOlzCObF4+ZHYYVBaavTOtb4DWzd5jBeFQdzbYM+YWoQoujlDDxG+aerNMiMhnV +oD9J1vQSgRhHcYI2KJdxAi1cQK0JsA+xZ82Z6vup5KGLgUoZWd1pZKUdG1Mnlie7 +1LPDCtVMDZvGFiJJdqI+GFiYahQ1w5R1uuuPuFujHiNK5QLQINq+K9eyDq2HSacU +mKFSKrTfa5z+rYye7E37JBJ62l1G7q0kGS90e4tQJCckAl7Om5fLdZnqlw/TcjNA +sA1yTeWDYZ7UCwD+NzZml13aRc5H70brxw== +-----END CERTIFICATE----- diff --git a/packages/core/test/fixture/tls/appliance.key b/packages/core/test/fixture/tls/appliance.key new file mode 100644 index 000000000000..55ee1d2dbfce --- /dev/null +++ b/packages/core/test/fixture/tls/appliance.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDKz6kO8FvbflAT +MQ13ADSVZ54ZUvugk6/cD2ULWbXvYZmnTEd2+XKR78LCm2ilFCOvn7dpSruwgPDi +mFJJFR8K1MWRsLb8BDzO2deFVwRG4fP641l9RScSlBRCI2ZQPEAKS6KMC8IGW5v/ +CP9U3YsmvY55bdXcP4p9b5/hm0cJc+4NEZUEup3bU+BAlNFRONen61QSn8Hdavht +3ZM+rOkjEkYz3+VQ7JuAMVvPqT7e1o49Cf3LkhxdT7wYP8AVu3qTFfsBca8MY972 +HGinm5nEQomF9Dlc88uXHWhfFvIZnF6XDBSSMuCJclbd6QWNgg5ohwRt76eWR/73 +4mdPEbebAgMBAAECggEAGUzJcq8iTmHV/Gbonpk8dgzciJ00/aWe0Q1slftlekzL +HbcizSE2HSVzPtKjPcCi6QQ8HZrwigazxhipXyuO2UuMr+2DDjI7IbTEMYKW6n7C +cGBPKBTp2GP3mP6Qg17YwFAKIX1QAXoJHkoRjBUtCDKqZNSCUrzselRj9gFoXbHK +5jtjTVEZSs3smTM3crlRizdKNvzP+cqx4pac8kPjuDH9yT8oicg+Bnmt4/GAZsWp +brSYai7HScudx0A+IDLuKfP9tYVeIoe4ebgpyxamWDjrwVRpn5tDgE84A2knwVlN +kMSQ8ySleziCRZm1Z3GRdMv0W9zhI/6TmPXsctjtgQKBgQD+Mz5P72usOFdvEUem +sCid8SeKxeu/YM0ODbEOFqQRNf732ZJ05Q6NeJHmbA19DULCSYYgNiA+yxfA1Qet +BlbN7JBuuVRbUGOjkrYCOHHSwWdOoQLRXZ0VvwRyt/nRVxkhF9u+B7ESg5vgmkj0 +m/g0qWVl5uDhH+R4wRFkTp4sgQKBgQDMP0VHmPefvYhmIJsRnKPPq8934H//9ntj +KC+qgEOmXthIESgq7IIkLU1AEyMg3GTeHfGFKaC590TZTsXdKBDyk4KBampmOlqJ +6F3Z7LL2SEDjfd7q1qaiMWE8VoY5n0yeBGP3a8kx1l4QPiVlqAAnjFQjVP8OVHRK +H4jWg1gGGwKBgG9L1F5/Py9aQ73XXP24ZzAv49Y4re1O3rjmebznYNJAR/AQjLUt +jN+YCJlQed9krPQP6O+1gwy7Mx00qHWzLtJeXyZ7clnAqr91fsceG4fhMfAVQvyT +9jENP09TCcgEUHZS0d0z1O18PgVqx8KBf3zfJgwluBQZOJk+PvUiDtMBAoGAG9PO ++5dCvZfUGp38oqSlyOiOprWGmvgZDYRvIhrBnMw1SXRU9awtewJVERLKaXK9p2mK ++2qVhGDFF5pc6OnJjVM9p6Y5At6WcU2gY82VAGsNtDD1CyfdoiF6Lv9W4Ejs5maQ +vCgKTGj5fdsJu1TtWHYNDJdK/MfS5DpxqVBR0NsCgYBqLps6f/ZnHkam5xhcXwuF +eQILtW470j00ZiY8eW3vsO3jDaJY4kKDqCg/vJvCvrJeI0Arz5xfK3U+0nT4/3OM +BxKm9KiZFy1FNMc4pQeJN4u1M60McZREw//ncU+B/lOTQ2RhOEDB79+nKhuezjkC +vI9mk1tEhXtadS6g1C83zQ== +-----END PRIVATE KEY----- diff --git a/packages/core/test/fixture/tls/localhost.crt b/packages/core/test/fixture/tls/localhost.crt new file mode 100644 index 000000000000..08a7c33aa30c --- /dev/null +++ b/packages/core/test/fixture/tls/localhost.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJTCCAg2gAwIBAgIUTKIB22rVLojTO840h8mzTuRnOxIwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MTAyODA4MzQ0N1oXDTM2MTAy +NTA4MzQ0N1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA27Z4pxp0GVVWzwFQT4I+AGNb13g8tRSoOK7hvZ7TFH2v +kcBC+N7rrdlH/5a2lFGdoFKV0Mo2eV1Sge4pHqohzeadCSc/aYQEUJc6hmHVmZ8q +OIgSZJU/JsMRr7J8cBOm9saulzJh2KIqiRnqukBScob3A8ILwQXCbi/cHJIXV2tc +f/Lum7g9QPJvahQ1zOVUuasiYrKwaeKI22WoBnYKJQbXTx53vJPsCtRVl4sSCkFK +Bw08KlLAgxm3iMF/W1UgwLQNRCVwCzHg3IMM7Cufk/ZdYrq79W+dPQtO1hSwXtx/ +cNzEM33+6HxMQbJqt0SD6f7fIGFTtqhLEd6RoZTzgwIDAQABo28wbTAdBgNVHQ4E +FgQU5yrZI6/BOM0LRqzxe/8i28xbj1gwHwYDVR0jBBgwFoAU5yrZI6/BOM0LRqzx +e/8i28xbj1gwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARhwR/AAABgglsb2Nh +bGhvc3QwDQYJKoZIhvcNAQELBQADggEBALGiM6XviFYv4a75Om8rPGFiLD9DNPe0 +Rxr0gOpaMvXheJQTMFkrM8sBqt0Y8gL2JT8ZYcEKXZSlh6n0cGvh5wyPsvsNA7A1 +kABIfh0Yf2Znp2E/mgpF4kdRWgbRvv+77llTBJNdBViGIWNHzZH92q1UGWPs9JKD +sttvK02rP+Xt7GXqMERwlAvAC0RZ6RpV46QfG0SuKCnV369WQRDt5BtilRaePnFk +U4dq7nK2dq+rWu6OuysOQHyeEP7FBhxdQlTnpjXnOmAjcZihfm/Hl2cvubhlvBM5 +yfO7vILtaXtrd82xBmUGOSzuRtBPwlsbXkhz/pupxJZtC4xhO9t+Qgo= +-----END CERTIFICATE----- diff --git a/packages/core/test/fixture/tls/localhost.key b/packages/core/test/fixture/tls/localhost.key new file mode 100644 index 000000000000..f984539aa644 --- /dev/null +++ b/packages/core/test/fixture/tls/localhost.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDbtninGnQZVVbP +AVBPgj4AY1vXeDy1FKg4ruG9ntMUfa+RwEL43uut2Uf/lraUUZ2gUpXQyjZ5XVKB +7ikeqiHN5p0JJz9phARQlzqGYdWZnyo4iBJklT8mwxGvsnxwE6b2xq6XMmHYoiqJ +Geq6QFJyhvcDwgvBBcJuL9wckhdXa1x/8u6buD1A8m9qFDXM5VS5qyJisrBp4ojb +ZagGdgolBtdPHne8k+wK1FWXixIKQUoHDTwqUsCDGbeIwX9bVSDAtA1EJXALMeDc +gwzsK5+T9l1iurv1b509C07WFLBe3H9w3MQzff7ofExBsmq3RIPp/t8gYVO2qEsR +3pGhlPODAgMBAAECggEAK4b1VSmc+Y3aEk2Q0wea7+m/Pqsv37H1d7FRwIawC52w +xB/CRNqo9zqWFHEPv8eBgksKKiB9dULDP0Fm/Q+4fbmjv7X6TpWcYzjqm8fTEMmj +DCsM9cdJiQzZOqUSGQQTrk0DNqoBD3FlGt87DOYET3MkiaaOPJG/CKRLp49bt3IM +EIaSOdpGRFNUv1uxn9ZzLAWAglJ2DbhKMqJxQvP0KevICmkZzVbn7NLF/3tm7tCd +9Ty8U1baCPu9YjHfMDJQ/l447dguBX0dWYkggyGA/xszyvKSdhjqRRjj0m/zL1Kc +0UYKPWdmGGgJGXp3zP5zrbIQymBdCO0m8k8FZCY42QKBgQD0aAvM5VYDSqG7y6De +P6mwnLOvu/4zugL3Xj2I7KiQ9fRNxGK9yZJuN8xVJUn4JI/Yb22wqTchz2QldLZn +pwqchVOLEMC4476yfplOF9jpMaJ5B5YzVeSd8JmqzKxiPPca8/cqmwCnhdMTuuhe +rD/vq5m3QJQrtrPwmbJpkPfQCQKBgQDmIo6xydrEFwjC8giSrvAzzcS7+TuGDxxy +fCDvwvQRWFJ4sxUAk/HIC7Ru7+y49mmWn9JihTcoXJ9K63M96kRmgSH1mFqX3oXO +rPhdaSs+5RbRzxVS1LufcUreQ/PkQ8E1XZ4+IMhvUPPIIy+Q6z0JSG0rzxkHwZrH +scHx9QlyKwKBgAgssrhXs1jI1kABv4+vcW2G0DvAffAc+JxteoFp6PlwQIuZVfQj +GdK16w+t/5tdHbKPh4uxHTb0SNU34iH1ZFV34CN2m/uTIn213krOYWLrVhPWPR/a +b5sYwQsFbX6bq2grYMj8BjmUMEsopT0FZd4OT0Xh0xLjOYGCooUFZESBAoGAdKyM +f6KA4O10qbEfm45JhVoLJenDvcGngHtiD2xdZPXMShkwyqw25l7YyW2mLFfxTEYW +2ZAEWNgsgkVAleFRcE6Dswnc1CYi3JdhC6qoHCj1GsXMX8QuozVC6dC1s8KOD8o4 +0fK464a1MYB1OxmoRi6L+qvyWrZTDG8YjtzWPyUCgYAqtgHYqhy/AOs6LEtw6N3y +5WNXRZQR4I67skMvoH/tnHcdNJYVwdbnGw53cXQeHRXAOyJ4jV+HS5Uaxhgg8Kq0 +FC3xP6WR0K7eipwV4cPUgEXJjwaDkJyMjMD0Ap1Uo98Msl7VVh8hruyhNuNkCLI2 +WWaeHbjRsUb/Vei41whlIg== +-----END PRIVATE KEY----- diff --git a/packages/core/test/mcp-tls.test.ts b/packages/core/test/mcp-tls.test.ts new file mode 100644 index 000000000000..2a49381e3c3e --- /dev/null +++ b/packages/core/test/mcp-tls.test.ts @@ -0,0 +1,513 @@ +import { createHash } from "node:crypto" +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir as osTmpdir } from "node:os" +import path from "node:path" +import type { PeerCertificate } from "node:tls" +import { Buffer } from "node:buffer" +import { describe, expect, test } from "bun:test" +import { Cause, Effect, Exit } from "effect" +import { ConfigMCP } from "@opencode/schema/config/mcp" +import { McpClient } from "@opencode/core/mcp/client" +import { McpTls } from "@opencode/core/mcp/tls" +import { hostEnvironmentLayer } from "./fixture/environment" +import { CERT, CERT_APPLIANCE, KEY, KEY_APPLIANCE, startTlsMcpServer, TLS_FIXTURE_DIR } from "./fixture/tls-server" + +const SAMPLE_CERT = `-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJALRNRw0Gx+9FMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMjQwMTAxMDAwMDAwWhcNMjUwMTAxMDAwMDAwWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA0Z3VS5JJcV8xQKNl8N3t9X5N0JF7J8L8HKt1qE4fG2mO5PqR7sTuVwXy +Z1aBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTjKvWxVyZaBcDeF +gHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmN +oPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqN +vWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaB +cDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjI +QIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQBoJmO5PqR7sTuVwXyZ1aBcDeFgHjIkL +mNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsT +qNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNvWxVyZ +aBcDeFgHjIkLmNoPqRsTqNvWxVyZaBcDeFgHjIkLmNoPqRsTqNXv +-----END CERTIFICATE----- +` + +// readCaFile trims trailing whitespace, so match against the 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(() => McpTls.validatePemCert(SAMPLE_CERT, "test")).not.toThrow() + }) + + test("accepts a CA bundle with multiple certificates", () => { + expect(() => McpTls.validatePemCert(`${SAMPLE_CERT}\n${SAMPLE_CERT}`, "test")).not.toThrow() + }) + + test("rejects content without BEGIN marker", () => { + expect(() => McpTls.validatePemCert("just some text", "test")).toThrow("missing PEM certificate header") + }) + + test("rejects content with BEGIN but no END marker", () => { + expect(() => McpTls.validatePemCert("-----BEGIN CERTIFICATE-----\nbase64data", "test")).toThrow( + "missing PEM certificate footer", + ) + }) + + test("rejects content with empty body between markers", () => { + expect(() => McpTls.validatePemCert("-----BEGIN CERTIFICATE-----\n\n-----END CERTIFICATE-----", "test")).toThrow( + "empty PEM certificate body", + ) + }) + + test("rejects partial markers", () => { + expect(() => McpTls.validatePemCert("-----BEGIN CERT-----not real-----END CERT-----", "test")).toThrow( + "missing PEM certificate header", + ) + }) + + test("includes the source name in error messages", () => { + expect(() => McpTls.validatePemCert("bad content", "ca_pem config entry")).toThrow("ca_pem config entry") + }) +}) + +describe("validateFingerprint", () => { + test("accepts bare hex fingerprint", () => { + expect(McpTls.validateFingerprint("a".repeat(64))).toBe("a".repeat(64)) + }) + + test("accepts colon-separated fingerprint", () => { + const result = McpTls.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(":") + }) + + test("accepts SHA256: prefixed fingerprint with or without colons", () => { + expect(McpTls.validateFingerprint("SHA256:" + "a".repeat(64))).toBe("a".repeat(64)) + expect( + McpTls.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", + ), + ).toHaveLength(64) + }) + + test("lowercases and trims", () => { + const result = McpTls.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()) + expect(result).toHaveLength(64) + }) + + test("rejects invalid values", () => { + expect(() => McpTls.validateFingerprint("")).toThrow("Invalid SHA256 fingerprint") + expect(() => McpTls.validateFingerprint("a".repeat(63))).toThrow("Invalid SHA256 fingerprint") + expect(() => McpTls.validateFingerprint("a".repeat(65))).toThrow("Invalid SHA256 fingerprint") + expect(() => McpTls.validateFingerprint("g" + "a".repeat(63))).toThrow("Invalid SHA256 fingerprint") + expect(() => McpTls.validateFingerprint("SHA1:" + "a".repeat(64))).toThrow("Invalid SHA256 fingerprint") + expect(() => McpTls.validateFingerprint("' OR '1'='1")).toThrow("Invalid SHA256 fingerprint") + }) +}) + +describe("resolveFilePath", () => { + test("expands ~ to home directory", () => { + const result = McpTls.resolveFilePath("~/test/file.pem", "/workspace") + expect(result).not.toContain("~") + expect(result).toContain("test") + }) + + test("returns absolute paths as-is", () => { + expect(McpTls.resolveFilePath("/absolute/path/ca.pem", "/workspace")).toBe("/absolute/path/ca.pem") + }) + + test("resolves relative paths against workspace", () => { + expect(McpTls.resolveFilePath("certs/ca.pem", "/workspace")).toBe(path.resolve("/workspace", "certs/ca.pem")) + }) +}) + +describe("readCaFile", () => { + test("reads a valid PEM file", () => { + using dir = tmpdir() + const content = McpTls.readCaFile(writeTempFile(dir.path, "ca.pem", SAMPLE_CERT), dir.path) + expect(content).toBe(SAMPLE_CERT_TRIMMED) + }) + + test("resolves relative paths against workspace", () => { + using dir = tmpdir() + writeTempFile(dir.path, "ca.pem", SAMPLE_CERT) + expect(McpTls.readCaFile("ca.pem", dir.path)).toBe(SAMPLE_CERT_TRIMMED) + }) + + test("throws if file does not exist", () => { + expect(() => McpTls.readCaFile("nonexistent.pem", "/workspace")).toThrow("CA file not found") + }) + + test("throws if path is a directory", () => { + using dir = tmpdir() + expect(() => McpTls.readCaFile(dir.path, dir.path)).toThrow("CA path is not a file") + }) + + test("throws if file does not contain a PEM certificate", () => { + using dir = tmpdir() + expect(() => McpTls.readCaFile(writeTempFile(dir.path, "bad.pem", "not a certificate"), dir.path)).toThrow( + "missing PEM certificate header", + ) + }) + + test("includes the original path in error messages", () => { + expect(() => McpTls.readCaFile("nonexistent.pem", "/workspace")).toThrow("nonexistent.pem") + }) +}) + +describe("derToPem", () => { + test("converts DER bytes to PEM format", () => { + const pem = McpTls.derToPem(new TextEncoder().encode("dummy-cert-bytes").buffer) + expect(pem).toStartWith("-----BEGIN CERTIFICATE-----") + expect(pem).toEndWith("-----END CERTIFICATE-----\n") + }) + + test("wraps base64 at 64 characters", () => { + const pem = McpTls.derToPem(Uint8Array.from({ length: 256 }, (_, i) => i).buffer) + for (const line of pem.split("\n").slice(1, -2)) expect(line).toMatch(/^[A-Za-z0-9+/=]{1,64}$/) + }) + + test("empty DER produces minimal PEM", () => { + expect(McpTls.derToPem(new ArrayBuffer(0))).toBe("-----BEGIN CERTIFICATE-----\n\n-----END CERTIFICATE-----\n") + }) +}) + +describe("resolveTls", () => { + const url = new URL("https://example.com/mcp") + + test("returns undefined when no TLS options configured", async () => { + await expect(McpTls.resolveTls({}, "/workspace", url)).resolves.toBeUndefined() + }) + + test("resolves CA from ca_pem", async () => { + await expect(McpTls.resolveTls({ ca_pem: SAMPLE_CERT }, "/workspace", url)).resolves.toEqual({ ca: SAMPLE_CERT }) + }) + + test("resolves CA from ca_file", async () => { + using dir = tmpdir() + await expect( + McpTls.resolveTls({ ca_file: writeTempFile(dir.path, "ca.pem", SAMPLE_CERT) }, dir.path, url), + ).resolves.toEqual({ ca: SAMPLE_CERT_TRIMMED }) + }) + + test("concatenates ca_pem and ca_file", async () => { + using dir = tmpdir() + await expect( + McpTls.resolveTls({ ca_pem: SAMPLE_CERT, ca_file: writeTempFile(dir.path, "ca2.pem", SAMPLE_CERT) }, dir.path, url), + ).resolves.toEqual({ ca: SAMPLE_CERT + SAMPLE_CERT_TRIMMED }) + }) + + test("throws if ca_pem is not valid PEM", async () => { + await expect(McpTls.resolveTls({ ca_pem: "not a cert" }, "/workspace", url)).rejects.toThrow( + "missing PEM certificate header", + ) + }) + + test("throws if ca_file does not exist", async () => { + await expect(McpTls.resolveTls({ ca_file: "nonexistent.pem" }, "/workspace", url)).rejects.toThrow("CA file not found") + }) + + test("throws if fingerprint is invalid format", async () => { + await expect(McpTls.resolveTls({ fingerprint: "not-a-fingerprint" }, "/workspace", url)).rejects.toThrow( + "Invalid SHA256 fingerprint", + ) + }) + + test("resolves a matching fingerprint to the presented certificate as the trust anchor", async () => { + const server = await startTlsMcpServer() + try { + const resolved = await McpTls.resolveTls({ fingerprint: FINGERPRINT }, import.meta.dir, new URL(server.url)) + expect(resolved?.fingerprint).toBe(FINGERPRINT) + if (!resolved) throw new Error("resolveTls returned undefined") + const anchor = createHash("sha256") + .update(Buffer.from(resolved.ca.split("\n").slice(1, -1).join(""), "base64")) + .digest("hex") + expect(anchor).toBe(FINGERPRINT) + } finally { + await server.close() + } + }) + + test("throws on a fingerprint mismatch before connecting", async () => { + const server = await startTlsMcpServer() + try { + await expect( + McpTls.resolveTls({ fingerprint: "a".repeat(64) }, import.meta.dir, new URL(server.url)), + ).rejects.toThrow("fingerprint mismatch") + } finally { + await server.close() + } + }) +}) + +describe("withTls", () => { + test("injects tls.ca and preserves the caller's init", async () => { + const calls: Array<{ url: string | URL; init?: RequestInit }> = [] + const base = ((url: string | URL, init?: RequestInit) => { + calls.push({ url, init }) + return Promise.resolve(new Response("ok")) + }) as typeof fetch + + await McpTls.withTls({ ca: SAMPLE_CERT }, base)("https://example.com/mcp", { method: "POST", headers: { a: "b" } }) + + expect(calls).toHaveLength(1) + expect(calls[0].url).toBe("https://example.com/mcp") + const init = calls[0].init as BunFetchRequestInit + expect(init.method).toBe("POST") + expect(init.headers).toEqual({ a: "b" }) + expect(init.tls).toEqual({ ca: SAMPLE_CERT }) + }) + + test("works when the caller passes no init", async () => { + const calls: Array<{ init?: RequestInit }> = [] + const base = ((_: string | URL, init?: RequestInit) => { + calls.push({ init }) + return Promise.resolve(new Response("ok")) + }) as typeof fetch + + await McpTls.withTls({ ca: SAMPLE_CERT }, base)("https://example.com/mcp") + + expect((calls[0].init as BunFetchRequestInit).tls).toEqual({ ca: SAMPLE_CERT }) + }) + + test("anchors the trust store to the pinned certificate and installs the fingerprint check", async () => { + const calls: Array<{ init?: RequestInit }> = [] + const base = ((_: string | URL, init?: RequestInit) => { + calls.push({ init }) + return Promise.resolve(new Response("ok")) + }) as typeof fetch + + await McpTls.withTls({ ca: CERT, fingerprint: FINGERPRINT }, base)("https://127.0.0.1:8443/mcp") + + const tls = (calls[0].init as BunFetchRequestInit).tls + expect(tls?.ca).toBe(CERT) + expect(tls?.rejectUnauthorized).toBeUndefined() + const check = tls?.checkServerIdentity + expect(typeof check).toBe("function") + const der = Buffer.from(CERT.split("\n").slice(1, -1).join(""), "base64") + expect(check!("127.0.0.1", { raw: der } as PeerCertificate)).toBeUndefined() + const mismatch = check!("127.0.0.1", { raw: Buffer.alloc(32) } as PeerCertificate) + expect(mismatch).toBeInstanceOf(Error) + expect((mismatch as Error).message).toContain("fingerprint mismatch") + }) +}) + +describe("isTlsCertificateError", () => { + const tlsErrors: Array<[string, unknown]> = [ + ["self-signed message (bun)", new Error("self signed certificate")], + ["altname message", new Error('ERR_TLS_CERT_ALTNAME_INVALID fetching "https://127.0.0.1:8443/mcp"')], + ["expired message", new Error("certificate has expired")], + ["node depth-zero code", Object.assign(new Error("self-signed certificate"), { code: "DEPTH_ZERO_SELF_SIGNED_CERT" })], + ["node self-signed-in-chain code", Object.assign(new Error("unable to verify the first certificate"), { code: "SELF_SIGNED_CERT_IN_CHAIN" })], + ["caused by a self-signed error", Object.assign(new TypeError("fetch failed"), { cause: new Error("self signed certificate") })], + ["nested cause", Object.assign(new Error("connect failed"), { cause: Object.assign(new Error("tls error"), { cause: new Error("certificate has expired") }) })], + ] + for (const [name, error] of tlsErrors) test(`matches ${name}`, () => expect(McpTls.isTlsCertificateError(error)).toBe(true)) + + const otherErrors: Array<[string, unknown]> = [ + ["connection refused", new Error("Unable to connect. Is the computer able to access the url?")], + ["http failure", new Error("HTTP error: 500 Internal Server Error")], + ["bare fetch failed", new TypeError("fetch failed")], + ["timeout", new Error("Request timed out after 30000ms")], + ["non-error value", "self signed certificate is not the issue"], + ["null", null], + ] + for (const [name, error] of otherErrors) test(`rejects ${name}`, () => expect(McpTls.isTlsCertificateError(error)).toBe(false)) +}) + +// The fingerprint is the SHA256 of the DER body, the same value +// tls.getPeerX509Certificate().fingerprint256 reports. +const FINGERPRINT = createHash("sha256") + .update(Buffer.from(CERT.split("\n").slice(1, -1).join(""), "base64")) + .digest("hex") +const FINGERPRINT_APPLIANCE = createHash("sha256") + .update(Buffer.from(CERT_APPLIANCE.split("\n").slice(1, -1).join(""), "base64")) + .digest("hex") + +const connect = (name: string, config: ConfigMCP.Remote) => + McpClient.connect(name, config, import.meta.dir).pipe(Effect.provide(hostEnvironmentLayer)) + +// The connection lives only as long as use; closing the scope tears the transport down. +const withConnection = ( + name: string, + config: ConfigMCP.Remote, + use: (connection: McpClient.Connection) => Effect.Effect, +) => Effect.scoped(connect(name, config).pipe(Effect.flatMap(use))) + +describe("MCP connect over TLS", () => { + test("trusts a self-signed server via ca_file", async () => { + const server = await startTlsMcpServer() + try { + await Effect.runPromise( + withConnection( + "tls-ca", + new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, tls: { ca_file: path.join(TLS_FIXTURE_DIR, "localhost.crt") } }), + (connection) => + connection.tools().pipe(Effect.map((tools) => expect(tools.map((tool) => tool.name)).toEqual(["echo"]))), + ), + ) + } finally { + await server.close() + } + }) + + test("trusts a self-signed server via ca_pem", async () => { + const server = await startTlsMcpServer() + try { + await Effect.runPromise( + withConnection( + "tls-pem", + new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, tls: { ca_pem: CERT } }), + (connection) => + connection.tools().pipe(Effect.map((tools) => expect(tools.map((tool) => tool.name)).toEqual(["echo"]))), + ), + ) + } finally { + await server.close() + } + }) + + test("pins the certificate by fingerprint against an IP host", async () => { + const server = await startTlsMcpServer() + try { + await Effect.runPromise( + withConnection( + "tls-pin", + new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, tls: { fingerprint: FINGERPRINT } }), + (connection) => + connection.tools().pipe(Effect.map((tools) => expect(tools.map((tool) => tool.name)).toEqual(["echo"]))), + ), + ) + } finally { + await server.close() + } + }) + + test("a fingerprint pin reaches an appliance certificate without IP SANs", async () => { + const server = await startTlsMcpServer({ cert: CERT_APPLIANCE, key: KEY_APPLIANCE }) + try { + const resolved = await McpTls.resolveTls({ fingerprint: FINGERPRINT_APPLIANCE }, import.meta.dir, new URL(server.url)) + if (!resolved || resolved.fingerprint !== FINGERPRINT_APPLIANCE) + throw new Error(`unexpected resolved tls: ${JSON.stringify(resolved)}`) + // A bare GET is rejected by the streamable transport, so prove the trust with an initialize. + const response = await McpTls.withTls(resolved, globalThis.fetch)(server.url, { + method: "POST", + headers: { accept: "application/json, text/event-stream", "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 0, + method: "initialize", + params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "opencode", version: "1.0.0" } }, + }), + }) + expect(response.status).toBe(200) + await response.body?.cancel() + } finally { + await server.close() + } + }) + + test("connects an appliance certificate without IP SANs via a fingerprint pin", async () => { + const server = await startTlsMcpServer({ cert: CERT_APPLIANCE, key: KEY_APPLIANCE }) + try { + await Effect.runPromise( + withConnection( + "tls-appliance", + new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, tls: { fingerprint: FINGERPRINT_APPLIANCE } }), + (connection) => + connection.tools().pipe(Effect.map((tools) => expect(tools.map((tool) => tool.name)).toEqual(["echo"]))), + ), + ) + } finally { + await server.close() + } + }) + + test("rejects a fingerprint mismatch before connecting", async () => { + const server = await startTlsMcpServer() + try { + const exit = await Effect.runPromise( + Effect.scoped( + connect("tls-bad-pin", new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, tls: { fingerprint: "a".repeat(64) } })), + ).pipe(Effect.exit), + ) + if (Exit.isSuccess(exit)) throw new Error("connect unexpectedly succeeded") + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(McpClient.ConnectError) + if (!(error instanceof McpClient.ConnectError)) throw new Error("expected ConnectError") + expect(error.message).toContain("fingerprint mismatch") + } finally { + await server.close() + } + }) + + test("rejects a certificate no configured CA signed", async () => { + const server = await startTlsMcpServer() + try { + const exit = await Effect.runPromise( + Effect.scoped( + connect("tls-wrong-ca", new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, tls: { ca_pem: SAMPLE_CERT } })), + ).pipe(Effect.exit), + ) + if (Exit.isSuccess(exit)) throw new Error("connect unexpectedly succeeded") + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(McpClient.ConnectError) + } finally { + await server.close() + } + }) + + test("rejects a certificate swapped after the first connect", async () => { + const probe = await startTlsMcpServer() + const port = probe.port + await probe.close() + let current = await startTlsMcpServer({ cert: CERT_APPLIANCE, key: KEY_APPLIANCE, port }) + try { + const url = new URL(current.url) + const resolved = await McpTls.resolveTls({ fingerprint: FINGERPRINT_APPLIANCE }, import.meta.dir, url) + if (!resolved || resolved.fingerprint !== FINGERPRINT_APPLIANCE) + throw new Error(`unexpected resolved tls: ${JSON.stringify(resolved)}`) + const fetchPinned = McpTls.withTls(resolved, globalThis.fetch) + const request = () => + fetchPinned(url, { + method: "POST", + headers: { accept: "application/json, text/event-stream", "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 0, + method: "initialize", + params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "opencode", version: "1.0.0" } }, + }), + }) + expect((await request()).status).toBe(200) + // The pin is re-verified on every handshake: swap the certificate on the same port and + // the next request must fail, not just the first one. + await current.close() + current = await startTlsMcpServer({ cert: CERT, key: KEY, port }) + await expect(request()).rejects.toThrow(/self[- ]signed|fingerprint mismatch/i) + } finally { + await current.close() + } + }) +}) diff --git a/packages/schema/src/config/mcp.ts b/packages/schema/src/config/mcp.ts index 1a684794304f..486be74af43a 100644 --- a/packages/schema/src/config/mcp.ts +++ b/packages/schema/src/config/mcp.ts @@ -12,6 +12,8 @@ export const OAuth = Mcp.OAuthConfig export type OAuth = Mcp.OAuthConfig export const Remote = Mcp.RemoteConfig export type Remote = Mcp.RemoteConfig +export const Tls = Mcp.TlsConfig +export type Tls = Mcp.TlsConfig export const Server = Mcp.ServerConfig export const Protocol = Mcp.Protocol export type Protocol = Mcp.Protocol diff --git a/packages/schema/src/mcp.ts b/packages/schema/src/mcp.ts index 2d782dd6a40a..fa979c16cc1b 100644 --- a/packages/schema/src/mcp.ts +++ b/packages/schema/src/mcp.ts @@ -50,6 +50,20 @@ export class OAuthConfig extends Schema.Class("Mcp.OAuthConfig")({ }), }) {} +export class TlsConfig extends Schema.Class("Mcp.TlsConfig")({ + ca_file: Schema.String.pipe(optional).annotate({ + description: + "Path to a PEM-encoded CA certificate file to trust for this server. Relative paths resolve from the workspace; ~ expands to the home directory.", + }), + ca_pem: Schema.String.pipe(optional).annotate({ + description: "Inline PEM-encoded CA certificate content to trust for this server.", + }), + fingerprint: Schema.String.pipe(optional).annotate({ + description: + "SHA256 fingerprint of the server certificate to pin. Format: 'SHA256:XX:XX:...', 'XX:XX:...', or bare hex.", + }), +}) {} + export class RemoteConfig extends Schema.Class("Mcp.RemoteConfig")({ type: Schema.Literal("remote"), url: Schema.String, @@ -61,6 +75,10 @@ export class RemoteConfig extends Schema.Class("Mcp.RemoteConfig") }), timeout: TimeoutConfig.pipe(optional), protocol: Protocol.pipe(optional), + tls: TlsConfig.pipe(optional).annotate({ + description: + "TLS trust for this server: a custom CA and/or a pinned certificate fingerprint. Applies to this server only.", + }), }) {} export const ServerConfig = Schema.Union([LocalConfig, RemoteConfig]).pipe(Schema.toTaggedUnion("type")) diff --git a/packages/schema/test/mcp.test.ts b/packages/schema/test/mcp.test.ts index 75daa7fe1c02..466d91a0ac05 100644 --- a/packages/schema/test/mcp.test.ts +++ b/packages/schema/test/mcp.test.ts @@ -15,6 +15,30 @@ describe("Mcp resources", () => { }) }) + test("decodes remote TLS trust config and omits unset fields on encode", () => { + const decoded = Schema.decodeUnknownSync(Mcp.RemoteConfig)({ + type: "remote", + url: "https://192.168.1.1/mcp", + tls: { ca_file: "~/.config/mcp/ca.pem", fingerprint: "SHA256:AA:BB" }, + }) + expect(Schema.encodeSync(Mcp.RemoteConfig)(decoded)).toEqual({ + type: "remote", + url: "https://192.168.1.1/mcp", + tls: { ca_file: "~/.config/mcp/ca.pem", fingerprint: "SHA256:AA:BB" }, + }) + const bare = Schema.decodeUnknownSync(Mcp.RemoteConfig)({ type: "remote", url: "https://example.com/mcp" }) + expect(Schema.encodeSync(Mcp.RemoteConfig)(bare)).toEqual({ type: "remote", url: "https://example.com/mcp" }) + }) + + test("rejects non-string TLS trust fields", () => { + expect(() => + Schema.decodeUnknownSync(Mcp.RemoteConfig)({ type: "remote", url: "https://example.com/mcp", tls: { ca_pem: 1 } }), + ).toThrow() + expect(() => + Schema.decodeUnknownSync(Mcp.RemoteConfig)({ type: "remote", url: "https://example.com/mcp", tls: "pin" }), + ).toThrow() + }) + test("preserves text and base64 blob contents", () => { expect( Schema.decodeUnknownSync(Mcp.ResourceContent)({ diff --git a/services/www/src/docs/content/mcp-servers.mdx b/services/www/src/docs/content/mcp-servers.mdx index bfea1f696323..60bb2142db28 100644 --- a/services/www/src/docs/content/mcp-servers.mdx +++ b/services/www/src/docs/content/mcp-servers.mdx @@ -174,6 +174,7 @@ Use configuration for other options. Store secrets in environment variables rath | `codemode` | No | Set to `false` to expose tools directly instead of through Code Mode. Defaults to `true`. | | `timeout` | No | Per-server timeout overrides. | | `protocol` | No | `legacy` (default), `auto`, or `2026-07-28`. See [Protocol version](#protocol-version). | +| `tls` | No | TLS trust settings for self-signed or private-CA certificates. See [TLS Trust](#tls-trust). | Use `oauth: false` only when the server exclusively uses an API key or another header credential: @@ -186,6 +187,62 @@ Use `oauth: false` only when the server exclusively uses an API key or another h } ``` +## TLS Trust + +Servers on private networks often use self-signed certificates or certificates from a private certificate authority. Trust one for a single server without changing verification for the rest of OpenCode: + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "servers": { + "opnsense": { + "type": "remote", + "url": "https://192.168.1.1/mcp", + "tls": { + "ca_file": "~/.config/mcp/opnsense-ca.pem", + }, + }, + }, + }, +} +``` + +| Field | Description | +| --- | --- | +| `ca_file` | PEM-encoded CA certificate file. Relative paths resolve from the workspace; `~` expands to the home directory. | +| `ca_pem` | Inline PEM-encoded CA certificate. Useful when the appliance ships its configuration self-contained. | +| `fingerprint` | SHA256 fingerprint of the server certificate to pin, like an SSH host key. | + +Set `ca_file` and `ca_pem` together to trust several authorities at once: + +```jsonc +{ + "tls": { + "ca_file": "/etc/company/ca.pem", + "ca_pem": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", + }, +} +``` + +Pin the exact certificate a server presents with `fingerprint`, formatted as `SHA256:AA:BB:CC:...`, `AA:BB:CC:...`, or bare hex: + +```jsonc +{ + "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", + }, +} +``` + +OpenCode compares the certificate the server presents against the fingerprint and then anchors the connection to that exact certificate: every handshake verifies the chain against it and re-checks the fingerprint. Name matching is not enforced, so this also works for appliance certificates without an IP SAN. Get a fingerprint from the server with: + +```sh +openssl s_client -connect 192.168.1.1:443 /dev/null | openssl x509 -noout -fingerprint -sha256 +``` + +A TLS failure shows up as the server's connection status in `opencode mcp list` and `/mcps`. Unlike [NODE_EXTRA_CA_CERTS](/network), the trust applies to this server only. + ## OAuth OAuth is enabled for remote servers unless `oauth` is `false`. OpenCode discovers the authorization server, signs in with PKCE, and refreshes tokens. Credentials are stored outside project configuration.