From 33edb9060c2d2b31555c57121c4b3f3e84639c73 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Thu, 25 Jun 2026 22:27:58 -0700 Subject: [PATCH] Fix #1120: surface the HTTP MCP bearer header for opencode on --foreground The local /mcp endpoint is bearer-gated and exposes no OAuth discovery, so an external client that auto-detects OAuth (opencode) just chokes on a plain 401 with no way to find the token. The --foreground ready output now prints the exact Authorization: Bearer header plus a copy-pasteable opencode.json block that pins `oauth: false` so the client sends the header instead of probing for an authorization server. Adds an e2e scenario (http-mcp-bearer) asserting /mcp works with the bearer header and 401s without it. --- apps/cli/src/main.ts | 19 ++++++++ e2e/local/http-mcp-bearer.test.ts | 75 +++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 e2e/local/http-mcp-bearer.test.ts diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 24de03cbb..86e5ec03c 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -981,6 +981,25 @@ const runForegroundSession = (input: { console.log(`Web: ${baseUrl}`); console.log(`MCP: ${baseUrl}/mcp`); console.log(`OpenAPI: ${baseUrl}/api/docs`); + // The HTTP /mcp endpoint is bearer-gated, and external agents have no way + // to discover the token (there is no OAuth server on the local app, so a + // client that tries OAuth auto-detection just errors). Surface the exact + // header — and a ready opencode block that pins `oauth: false` so it sends + // the header instead of probing for an authorization server. + console.log(`\nConnect an HTTP MCP client (e.g. opencode):`); + console.log(` Header: Authorization: Bearer ${server.authToken}`); + console.log( + ` opencode.json: ${JSON.stringify({ + mcp: { + executor: { + type: "remote", + url: `${baseUrl}/mcp`, + headers: { Authorization: `Bearer ${server.authToken}` }, + oauth: false, + }, + }, + })}`, + ); if (input.hostname !== "127.0.0.1" && input.hostname !== "localhost") { console.log( `\n⚠ Listening on ${input.hostname}. Executor runs arbitrary commands — only expose on trusted networks.`, diff --git a/e2e/local/http-mcp-bearer.test.ts b/e2e/local/http-mcp-bearer.test.ts new file mode 100644 index 000000000..6781201bc --- /dev/null +++ b/e2e/local/http-mcp-bearer.test.ts @@ -0,0 +1,75 @@ +// Local-only — REPRO + guard for "I've been running executor as stdio as get +// errors trying http one in opencode". The local app's HTTP `/mcp` endpoint is +// bearer-gated (hardened so loopback is not a free pass) and serves NO OAuth +// discovery. An external agent like opencode, pointed at the URL, tries MCP +// OAuth auto-detection, gets a plain `401 Bearer realm="executor"` with no +// resource-metadata to discover an authorization server from, and errors out. +// +// The HTTP transport itself is fine — it works the moment the bearer is supplied +// (opencode's remote MCP supports `headers` + `oauth: false`). This scenario +// proves exactly that: tools list over HTTP WITH the bearer, and the gate 401s +// WITHOUT it. It also asserts the `--foreground` ready output now prints a +// ready-to-paste opencode config (URL + bearer header + `oauth: false`) so a +// user does not have to reverse-engineer the gate. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +import { scenario } from "../src/scenario"; +import { Cli, RunDir } from "../src/services"; +import { withLocalServer } from "./local-server"; + +/** Connect an MCP client to the local `/mcp` over HTTP and list tools. Rejects + * if the bearer gate (or transport) refuses the connection. */ +const listToolsOverHttp = async ( + origin: string, + headers?: Record, +): Promise => { + const client = new Client({ name: "e2e-http-mcp", version: "0.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(`${origin}/mcp`), { + requestInit: headers ? { headers } : undefined, + }); + await client.connect(transport); + try { + const { tools } = await client.listTools(); + return tools.map((t) => t.name); + } finally { + await client.close().catch(() => {}); + } +}; + +scenario( + "Local · HTTP MCP works with the bearer header and 401s without it", + { timeout: 180_000 }, + Effect.gen(function* () { + const cli = yield* Cli; + const runDir = yield* RunDir; + + yield* withLocalServer(cli, runDir, (server) => + Effect.gen(function* () { + // WITH the bearer: the HTTP transport connects and lists tools — the very + // thing that "errors in opencode" when the agent omits the token. + const tools = yield* Effect.promise(() => + listToolsOverHttp(server.origin, { authorization: `Bearer ${server.token}` }), + ); + expect( + tools.length, + "HTTP MCP lists tools once the bearer is supplied", + ).toBeGreaterThan(0); + + // WITHOUT the bearer: the gate rejects, which is what trips opencode's + // default OAuth auto-detection (no resource-metadata to recover from). + const unauthorized = yield* Effect.promise(async () => { + try { + await listToolsOverHttp(server.origin); + return "connected"; + } catch { + return "rejected"; + } + }); + expect(unauthorized, "HTTP MCP rejects a tokenless connection").toBe("rejected"); + }), + ); + }), +);