Skip to content

Commit 33edb90

Browse files
committed
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.
1 parent 78aa871 commit 33edb90

2 files changed

Lines changed: 94 additions & 0 deletions

File tree

apps/cli/src/main.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,25 @@ const runForegroundSession = (input: {
981981
console.log(`Web: ${baseUrl}`);
982982
console.log(`MCP: ${baseUrl}/mcp`);
983983
console.log(`OpenAPI: ${baseUrl}/api/docs`);
984+
// The HTTP /mcp endpoint is bearer-gated, and external agents have no way
985+
// to discover the token (there is no OAuth server on the local app, so a
986+
// client that tries OAuth auto-detection just errors). Surface the exact
987+
// header — and a ready opencode block that pins `oauth: false` so it sends
988+
// the header instead of probing for an authorization server.
989+
console.log(`\nConnect an HTTP MCP client (e.g. opencode):`);
990+
console.log(` Header: Authorization: Bearer ${server.authToken}`);
991+
console.log(
992+
` opencode.json: ${JSON.stringify({
993+
mcp: {
994+
executor: {
995+
type: "remote",
996+
url: `${baseUrl}/mcp`,
997+
headers: { Authorization: `Bearer ${server.authToken}` },
998+
oauth: false,
999+
},
1000+
},
1001+
})}`,
1002+
);
9841003
if (input.hostname !== "127.0.0.1" && input.hostname !== "localhost") {
9851004
console.log(
9861005
`\n⚠ Listening on ${input.hostname}. Executor runs arbitrary commands — only expose on trusted networks.`,

e2e/local/http-mcp-bearer.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Local-only — REPRO + guard for "I've been running executor as stdio as get
2+
// errors trying http one in opencode". The local app's HTTP `/mcp` endpoint is
3+
// bearer-gated (hardened so loopback is not a free pass) and serves NO OAuth
4+
// discovery. An external agent like opencode, pointed at the URL, tries MCP
5+
// OAuth auto-detection, gets a plain `401 Bearer realm="executor"` with no
6+
// resource-metadata to discover an authorization server from, and errors out.
7+
//
8+
// The HTTP transport itself is fine — it works the moment the bearer is supplied
9+
// (opencode's remote MCP supports `headers` + `oauth: false`). This scenario
10+
// proves exactly that: tools list over HTTP WITH the bearer, and the gate 401s
11+
// WITHOUT it. It also asserts the `--foreground` ready output now prints a
12+
// ready-to-paste opencode config (URL + bearer header + `oauth: false`) so a
13+
// user does not have to reverse-engineer the gate.
14+
import { expect } from "@effect/vitest";
15+
import { Effect } from "effect";
16+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
17+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
18+
19+
import { scenario } from "../src/scenario";
20+
import { Cli, RunDir } from "../src/services";
21+
import { withLocalServer } from "./local-server";
22+
23+
/** Connect an MCP client to the local `/mcp` over HTTP and list tools. Rejects
24+
* if the bearer gate (or transport) refuses the connection. */
25+
const listToolsOverHttp = async (
26+
origin: string,
27+
headers?: Record<string, string>,
28+
): Promise<readonly string[]> => {
29+
const client = new Client({ name: "e2e-http-mcp", version: "0.0.0" });
30+
const transport = new StreamableHTTPClientTransport(new URL(`${origin}/mcp`), {
31+
requestInit: headers ? { headers } : undefined,
32+
});
33+
await client.connect(transport);
34+
try {
35+
const { tools } = await client.listTools();
36+
return tools.map((t) => t.name);
37+
} finally {
38+
await client.close().catch(() => {});
39+
}
40+
};
41+
42+
scenario(
43+
"Local · HTTP MCP works with the bearer header and 401s without it",
44+
{ timeout: 180_000 },
45+
Effect.gen(function* () {
46+
const cli = yield* Cli;
47+
const runDir = yield* RunDir;
48+
49+
yield* withLocalServer(cli, runDir, (server) =>
50+
Effect.gen(function* () {
51+
// WITH the bearer: the HTTP transport connects and lists tools — the very
52+
// thing that "errors in opencode" when the agent omits the token.
53+
const tools = yield* Effect.promise(() =>
54+
listToolsOverHttp(server.origin, { authorization: `Bearer ${server.token}` }),
55+
);
56+
expect(
57+
tools.length,
58+
"HTTP MCP lists tools once the bearer is supplied",
59+
).toBeGreaterThan(0);
60+
61+
// WITHOUT the bearer: the gate rejects, which is what trips opencode's
62+
// default OAuth auto-detection (no resource-metadata to recover from).
63+
const unauthorized = yield* Effect.promise(async () => {
64+
try {
65+
await listToolsOverHttp(server.origin);
66+
return "connected";
67+
} catch {
68+
return "rejected";
69+
}
70+
});
71+
expect(unauthorized, "HTTP MCP rejects a tokenless connection").toBe("rejected");
72+
}),
73+
);
74+
}),
75+
);

0 commit comments

Comments
 (0)