Public: anyone can read this; the crew can change it.
The Claude Code plugin connects with OAuth when FORGE_TOKEN is not set #8299
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8271-the-claude-code-plugin-connects-with-oau → main 4 commits fork
7 files
changed +56
−8
docs/MCP.md +5 −2
| @@ -448,5 +448,8 @@ answers | ||
| 448 | 448 | too but unfiltered: it skips the filter holds, so a hot-file issue is |
| 449 | 449 | startable there and not in the repository's own map. |
| 450 | 450 | - **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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "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).", | |
| 5 | 5 | "mcpServers": { |
| 6 | 6 | "forge": { |
| 7 | "type": "http", | |
| 8 | "url": "${FORGE_URL}/mcp" | |
| 9 | }, | |
| 10 | "forge-token": { | |
| 7 | 11 | "type": "http", |
| 8 | 12 | "url": "${FORGE_URL}/mcp", |
| 9 | "headers": { "Authorization": "Bearer ${FORGE_TOKEN}" } | |
| 13 | "headers": { "Authorization": "Bearer ${FORGE_TOKEN:-}" } | |
| 10 | 14 | } |
| 11 | 15 | } |
| 12 | 16 | } |
plugins/claude-code/README.md +5 −2
| @@ -1,11 +1,14 @@ | ||
| 1 | 1 | # forge for Claude Code |
| 2 | 2 | |
| 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 | |
| 5 | 4 | (`claude --plugin-dir plugins/claude-code`). It registers the forge's MCP |
| 6 | 5 | server and a `/forge` command: `new`, `tell`, `status`, `decide`, `plan`, `preview`, |
| 7 | 6 | `pulls`, `runs`, `log`, `asked`. |
| 8 | 7 | |
| 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 | ||
| 9 | 12 | `/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. |
| 10 | 13 | |
| 11 | 14 | `/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 { | ||
| 22 | 22 | readonly label: string; |
| 23 | 23 | readonly path: string; |
| 24 | 24 | readonly install: string; |
| 25 | /** What to set before it runs. */ | |
| 26 | readonly setup: string; | |
| 25 | 27 | } |
| 26 | 28 | |
| 27 | 29 | /** The one list the card and /llms.txt both read, so they cannot drift. */ |
| @@ -31,12 +33,14 @@ export const HARNESS_PLUGINS: readonly HarnessPlugin[] = [ | ||
| 31 | 33 | label: "Claude Code", |
| 32 | 34 | path: "plugins/claude-code", |
| 33 | 35 | 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", | |
| 34 | 37 | }, |
| 35 | 38 | { |
| 36 | 39 | harness: "opencode", |
| 37 | 40 | label: "OpenCode", |
| 38 | 41 | path: "plugins/opencode/forge.ts", |
| 39 | 42 | install: "mkdir -p .opencode/plugin && cp plugins/opencode/forge.ts .opencode/plugin/", |
| 43 | setup: "set FORGE_URL and FORGE_TOKEN", | |
| 40 | 44 | }, |
| 41 | 45 | ]; |
| 42 | 46 |
src/web/pages/account.ts +1
| @@ -583,6 +583,7 @@ export function connectCard(ctx: Ctx, token: string | undefined): Html { | ||
| 583 | 583 | See plugins/claude-code and plugins/opencode. |
| 584 | 584 | </p> |
| 585 | 585 | <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> | |
| 586 | 587 | ${copyField("connect-claude", `${env} claude --plugin-dir plugins/claude-code`, "Claude Code command", "Copy command")} |
| 587 | 588 | <p class="small">OpenCode</p> |
| 588 | 589 | ${copyField( |
src/web/standards.ts +1 −1
| @@ -361,7 +361,7 @@ export function llmsTxt(ctx: Ctx): Response { | ||
| 361 | 361 | `- MCP endpoint: ${mcp.url} (streamable HTTP; OAuth metadata at ${mcp.oauth}). Or send a token as Authorization: Bearer.`, |
| 362 | 362 | ...HARNESS_PLUGINS.map( |
| 363 | 363 | (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}\``, | |
| 365 | 365 | ), |
| 366 | 366 | "", |
| 367 | 367 | "## 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 | }); |
new above
offline