Public: anyone can read this; the crew can change it.

The Claude Code plugin connects with OAuth when FORGE_TOKEN is not set #8299

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/8271-the-claude-code-plugin-connects-with-oau → main 4 commits fork

  • Merged. mason/8271-the-claude-code-plugin-connects-with-oau was merged into main as 3fb850f.

Review

Sign in to review this change.
7 files changed +56 −8
docs/MCP.md +5 −2
@@ -448,5 +448,8 @@ answers
448448 too but unfiltered: it skips the filter holds, so a hot-file issue is
449449 startable there and not in the repository's own map.
450450 - **Plugins.** `plugins/opencode` and `plugins/claude-code` register this
451 server and add a `forge` command (tell, status, decide, plan, preview,
452 pulls). They hold no logic.
451 server and add a `forge` command (new, tell, status, decide, plan, preview,
452 pulls, runs, log, asked). They hold no logic. The Claude Code plugin has two
453 entries: `forge` sends no Authorization header, so Claude Code signs in
454 through the OAuth flow (the default); `forge-token` sends `FORGE_TOKEN` as a
455 bearer, for scripts.
plugins/claude-code/.claude-plugin/plugin.json +7 −3
@@ -1,12 +1,16 @@
11 {
22 "name": "forge",
3 "version": "0.1.0",
4 "description": "Your forge inside Claude Code: its MCP server and a /forge command (tell, status, decide, plan).",
3 "version": "0.2.0",
4 "description": "Your forge inside Claude Code: its MCP server and a /forge command (new, tell, status, decide, plan, preview, pulls, runs, log, asked).",
55 "mcpServers": {
66 "forge": {
7 "type": "http",
8 "url": "${FORGE_URL}/mcp"
9 },
10 "forge-token": {
711 "type": "http",
812 "url": "${FORGE_URL}/mcp",
9 "headers": { "Authorization": "Bearer ${FORGE_TOKEN}" }
13 "headers": { "Authorization": "Bearer ${FORGE_TOKEN:-}" }
1014 }
1115 }
1216 }
plugins/claude-code/README.md +5 −2
@@ -1,11 +1,14 @@
11 # forge for Claude Code
22
3 Set `FORGE_URL` (the forge's address, no trailing slash) and `FORGE_TOKEN`
4 (an `rw` token; `ro` reads only; make one at `/settings/tokens` on the forge, which also shows this line filled in), then add this directory as a plugin
3 Set `FORGE_URL` (the forge's address, no trailing slash), then add this directory as a plugin
54 (`claude --plugin-dir plugins/claude-code`). It registers the forge's MCP
65 server and a `/forge` command: `new`, `tell`, `status`, `decide`, `plan`, `preview`,
76 `pulls`, `runs`, `log`, `asked`.
87
8 **Sign in (nothing to copy).** The `forge` server sends no Authorization header, so Claude Code takes the forge's OAuth sign-in: open `/mcp` in Claude Code, choose `forge`, and approve in the browser.
9
10 **A token (for scripts).** Also set `FORGE_TOKEN` (an `rw` token; `ro` reads only; make one at `/settings/tokens` on the forge, which also shows this line filled in) and use the `forge-token` server, which sends it as a bearer. `forge-token` defaults a missing `FORGE_TOKEN` to empty, so an OAuth-only user sees no missing-variable error; that entry then just sends an empty bearer and is refused, so leave it unused. A plugin.json cannot make a header conditional, so the two are separate entries; `forge` is the default.
11
912 `/forge plan` shows the planner's proposed wave in three lines. `/forge plan approve`, `/forge plan redirect <words>` or `/forge plan later` answers it, after you confirm.
1013
1114 `/forge new <name> <words>` makes an app named `<name>` whose first issue is the words, through MCP `new_app`, after you confirm; it answers with the app's page and the next step. It needs an `rw` token (an `ro` token is refused). When the forge refuses (the name is taken, the account is unverified, a limit is reached), it says the forge's own words in one line, offers another name when the name is taken, and never retries without asking.
src/core/mcp_card.ts +4
@@ -22,6 +22,8 @@ export interface HarnessPlugin {
2222 readonly label: string;
2323 readonly path: string;
2424 readonly install: string;
25 /** What to set before it runs. */
26 readonly setup: string;
2527 }
2628
2729 /** The one list the card and /llms.txt both read, so they cannot drift. */
@@ -31,12 +33,14 @@ export const HARNESS_PLUGINS: readonly HarnessPlugin[] = [
3133 label: "Claude Code",
3234 path: "plugins/claude-code",
3335 install: "claude --plugin-dir plugins/claude-code",
36 setup: "set FORGE_URL, then sign in through /mcp with the forge server; or also set FORGE_TOKEN and use the forge-token server",
3437 },
3538 {
3639 harness: "opencode",
3740 label: "OpenCode",
3841 path: "plugins/opencode/forge.ts",
3942 install: "mkdir -p .opencode/plugin && cp plugins/opencode/forge.ts .opencode/plugin/",
43 setup: "set FORGE_URL and FORGE_TOKEN",
4044 },
4145 ];
4246
src/web/pages/account.ts +1
@@ -583,6 +583,7 @@ export function connectCard(ctx: Ctx, token: string | undefined): Html {
583583 See plugins/claude-code and plugins/opencode.
584584 </p>
585585 <p class="small">Claude Code</p>
586 <p class="muted small mb">Start it with only FORGE_URL and sign in through /mcp (the forge server): nothing to copy. For scripts, use this command and the forge-token server, which sends the token.</p>
586587 ${copyField("connect-claude", `${env} claude --plugin-dir plugins/claude-code`, "Claude Code command", "Copy command")}
587588 <p class="small">OpenCode</p>
588589 ${copyField(
src/web/standards.ts +1 −1
@@ -361,7 +361,7 @@ export function llmsTxt(ctx: Ctx): Response {
361361 `- MCP endpoint: ${mcp.url} (streamable HTTP; OAuth metadata at ${mcp.oauth}). Or send a token as Authorization: Bearer.`,
362362 ...HARNESS_PLUGINS.map(
363363 (p) =>
364 `- ${p.label} plugin (source ${p.path} in this forge's repository; set FORGE_URL and FORGE_TOKEN): \`${p.install}\``,
364 `- ${p.label} plugin (source ${p.path} in this forge's repository; ${p.setup}): \`${p.install}\``,
365365 ),
366366 "",
367367 "## What you can do on any page",
test/plugin-claude-oauth.test.ts added +33
@@ -0,0 +1,33 @@
1 import { expect, test } from "bun:test";
2
3 const dir = `${import.meta.dir}/../plugins/claude-code`;
4 const plugin = await Bun.file(`${dir}/.claude-plugin/plugin.json`).json();
5
6 test("the forge server sends no Authorization header, so Claude Code signs in with OAuth", () => {
7 const forge = plugin.mcpServers.forge;
8 expect(forge.url).toBe("${FORGE_URL}/mcp");
9 expect(Object.keys(forge.headers ?? {}).map((h) => h.toLowerCase())).not.toContain("authorization");
10 });
11
12 test("forge-token sends FORGE_TOKEN as a bearer", () => {
13 expect(plugin.mcpServers["forge-token"].headers.Authorization).toBe("Bearer ${FORGE_TOKEN:-}");
14 });
15
16 test("the description names every verb the command handles", async () => {
17 const md = await Bun.file(`${dir}/commands/forge.md`).text();
18 const list = md.match(/^description: Talk to your forge: (.*)$/m)![1] as string;
19 const verbs = list.split(",").map((v) => v.trim());
20 expect(verbs.length).toBe(10);
21 for (const v of verbs) expect(plugin.description).toContain(v);
22 });
23
24 test("the forge's own surfaces agree with plugin.json", async () => {
25 const root = `${import.meta.dir}/..`;
26 const names = Object.keys(plugin.mcpServers);
27 expect(names).toEqual(["forge", "forge-token"]);
28 for (const file of ["src/core/mcp_card.ts", "src/web/pages/account.ts", "docs/MCP.md", "plugins/claude-code/README.md"]) {
29 const text = await Bun.file(`${root}/${file}`).text();
30 for (const n of names) expect(text).toContain(n);
31 expect(text).toContain("/mcp");
32 }
33 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs