diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index 8b77bc4c286d..f01a2f13c3d7 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -50,6 +50,10 @@ export class Remote extends Schema.Class("McpRemoteConfig")({ timeout: Schema.optional(Schema.Number).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), + insecure: Schema.optional(Schema.Boolean).annotate({ + description: + "Disable TLS certificate verification for this remote MCP server. Only use for self-signed certificates in trusted environments.", + }), }) { static readonly zod = zod(this) } diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 09fcfc756a16..6f93935efcd8 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -30,10 +30,22 @@ import { EffectBridge } from "@/effect" import { InstanceState } from "@/effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { Agent } from "node:https" const log = Log.create({ service: "mcp" }) const DEFAULT_TIMEOUT = 30_000 +const insecureAgent = new Agent({ rejectUnauthorized: false }) + +/** + * Creates a fetch function that skips TLS certificate verification. + * Supports both Bun (via `tls` init option) and Node (via `agent`). + */ +function createInsecureFetch(): typeof globalThis.fetch { + return ((input: RequestInfo | URL, init?: RequestInit) => + fetch(input, Object.assign({}, init, { tls: { rejectUnauthorized: false }, agent: insecureAgent }))) as typeof fetch +} + export const Resource = z .object({ name: z.string(), @@ -303,12 +315,16 @@ export const layer = Layer.effect( ) } + const insecureFetch = mcp.insecure ? createInsecureFetch() : undefined + if (mcp.insecure) log.warn("TLS certificate verification disabled", { key }) + const transports: Array<{ name: string; transport: TransportWithAuth }> = [ { name: "StreamableHTTP", transport: new StreamableHTTPClientTransport(new URL(mcp.url), { authProvider, requestInit: mcp.headers ? { headers: mcp.headers } : undefined, + fetch: insecureFetch, }), }, { @@ -316,6 +332,7 @@ export const layer = Layer.effect( transport: new SSEClientTransport(new URL(mcp.url), { authProvider, requestInit: mcp.headers ? { headers: mcp.headers } : undefined, + fetch: insecureFetch, }), }, ] @@ -766,7 +783,10 @@ export const layer = Layer.effect( auth, ) - const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider }) + const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { + authProvider, + fetch: mcpConfig.insecure ? createInsecureFetch() : undefined, + }) return yield* Effect.tryPromise({ try: () => { diff --git a/packages/opencode/test/mcp/insecure.test.ts b/packages/opencode/test/mcp/insecure.test.ts new file mode 100644 index 000000000000..5ec612216552 --- /dev/null +++ b/packages/opencode/test/mcp/insecure.test.ts @@ -0,0 +1,72 @@ +import { test, expect, mock, beforeEach } from "bun:test" +import { Effect } from "effect" +import type { MCP as MCPNS } from "../../src/mcp/index" + +const transportCalls: Array<{ fetch?: unknown }> = [] + +void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ + StreamableHTTPClientTransport: class { + constructor(_url: URL, options?: { fetch?: unknown }) { + transportCalls.push({ fetch: options?.fetch }) + } + async start() { + throw new Error("Mock transport cannot connect") + } + }, +})) + +void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ + SSEClientTransport: class { + constructor(_url: URL, options?: { fetch?: unknown }) { + transportCalls.push({ fetch: options?.fetch }) + } + async start() { + throw new Error("Mock transport cannot connect") + } + }, +})) + +beforeEach(() => { + transportCalls.length = 0 +}) + +const { MCP } = await import("../../src/mcp/index") +const { AppRuntime } = await import("../../src/effect/app-runtime") +const { Instance } = await import("../../src/project/instance") +const { tmpdir } = await import("../fixture/fixture") +const service = MCP.Service as unknown as Effect.Effect + +function addServer(name: string, insecure?: boolean) { + return Effect.gen(function* () { + const mcp = yield* service + yield* mcp + .add(name, { type: "remote", url: "https://example.com/mcp", oauth: false, insecure }) + .pipe(Effect.catch(() => Effect.void)) + }) +} + +test("insecure: true passes custom fetch to transports", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: () => AppRuntime.runPromise(addServer("insecure-server", true)), + }) + + expect(transportCalls.length).toBeGreaterThanOrEqual(1) + for (const call of transportCalls) { + expect(typeof call.fetch).toBe("function") + } +}) + +test("insecure not set does not pass custom fetch", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: () => AppRuntime.runPromise(addServer("secure-server")), + }) + + expect(transportCalls.length).toBeGreaterThanOrEqual(1) + for (const call of transportCalls) { + expect(call.fetch).toBeUndefined() + } +}) diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index 1b3006b1cbf2..f1d69356d55f 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -152,14 +152,15 @@ The `url` is the URL of the remote MCP server and with the `headers` option you #### Options -| Option | Type | Required | Description | -| --------- | ------- | -------- | ----------------------------------------------------------------------------------- | -| `type` | String | Y | Type of MCP server connection, must be `"remote"`. | -| `url` | String | Y | URL of the remote MCP server. | -| `enabled` | Boolean | | Enable or disable the MCP server on startup. | -| `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). | +| Option | Type | Required | Description | +| ---------- | ------- | -------- | ---------------------------------------------------------------------------------------------------- | +| `type` | String | Y | Type of MCP server connection, must be `"remote"`. | +| `url` | String | Y | URL of the remote MCP server. | +| `enabled` | Boolean | | Enable or disable the MCP server on startup. | +| `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). | +| `insecure` | Boolean | | Disable TLS certificate verification. Only use for self-signed certificates in trusted environments. | --- @@ -264,6 +265,33 @@ If you want to disable automatic OAuth for a server (e.g., for servers that use --- +#### Self-signed certificates + +If your remote MCP server uses a self-signed or internal TLS certificate, connections will fail with a certificate verification error. Set `insecure` to `true` to skip verification. + +:::caution +Only use `insecure` for servers in trusted environments. This disables all TLS certificate validation for that server. +::: + +```json title="opencode.json" {7} +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "internal-server": { + "type": "remote", + "url": "https://10.0.0.5:8443/mcp", + "insecure": true, + "oauth": false, + "headers": { + "Authorization": "Bearer {env:MY_TOKEN}" + } + } + } +} +``` + +--- + #### OAuth Options | Option | Type | Description |