Public: anyone can read this; the crew can change it.
/api/v1/here and /.well-known/mcp.json agree on which MCP tools need no token #8358
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8337-api-v1-here-and-well-known-mcp-json-agre → main 1 commit fork
5 files
changed +78
−7
src/actions/here.ts +10 −4
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | * saying so, because that is the next thing they need to know. |
| 14 | 14 | */ |
| 15 | 15 | import { sshTellCommand } from "../ssh/command.ts"; |
| 16 | import { mcpPointers } from "../core/mcp_card.ts"; | |
| 16 | import { mcpPointers, TOKEN_FREE_TOOLS } from "../core/mcp_card.ts"; | |
| 17 | 17 | import type { App } from "../app.ts"; |
| 18 | 18 | import { appsRuntime } from "../apps/runtime.ts"; |
| 19 | 19 | import { isCrew } from "../core/agents.ts"; |
| @@ -298,13 +298,14 @@ function forgeActions( | ||
| 298 | 298 | do: "report a problem", |
| 299 | 299 | http: "POST /api/v1/repos/{owner}/{name}/reports", |
| 300 | 300 | mcp: "report", |
| 301 | account: false, | |
| 301 | ...tokenFree("report"), | |
| 302 | 302 | }, |
| 303 | 303 | { |
| 304 | // The HTTP route is open, but the MCP tool is not, so no claim is made. | |
| 304 | 305 | do: "see how to help build this forge", |
| 305 | 306 | http: "GET /api/v1/contribute", |
| 306 | 307 | mcp: "contribute", |
| 307 | account: false, | |
| 308 | ...tokenFree("contribute"), | |
| 308 | 309 | }, |
| 309 | 310 | ...(viewer === undefined |
| 310 | 311 | ? [{ do: "use the MCP", http: "POST /mcp", account: true }] |
| @@ -313,6 +314,11 @@ function forgeActions( | ||
| 313 | 314 | ]; |
| 314 | 315 | } |
| 315 | 316 | |
| 317 | /** `account: false` only for an MCP tool the gate really lets through. */ | |
| 318 | function tokenFree(tool: string): { account?: false } { | |
| 319 | return TOKEN_FREE_TOOLS.includes(tool) ? { account: false } : {}; | |
| 320 | } | |
| 321 | ||
| 316 | 322 | function repoActions( |
| 317 | 323 | app: App, |
| 318 | 324 | repo: Repo, |
| @@ -331,7 +337,7 @@ function repoActions( | ||
| 331 | 337 | do: "report a problem", |
| 332 | 338 | http: `POST ${r}/reports`, |
| 333 | 339 | mcp: "report", |
| 334 | account: false, | |
| 340 | ...tokenFree("report"), | |
| 335 | 341 | }); |
| 336 | 342 | // What creates or costs waits for a proven email (`requireVerified`). |
| 337 | 343 | const unproven = |
src/core/mcp_card.ts +8 −1
| @@ -16,6 +16,13 @@ export function mcpPointers(origin: string): { url: string; oauth: string } { | ||
| 16 | 16 | }; |
| 17 | 17 | } |
| 18 | 18 | |
| 19 | /** | |
| 20 | * The MCP tools a stranger may call with no token: the gate in | |
| 21 | * `http/mcp.ts` answers these and refuses the rest with 401. The card and | |
| 22 | * `/api/v1/here` both read this list, so they cannot drift. | |
| 23 | */ | |
| 24 | export const TOKEN_FREE_TOOLS: readonly string[] = ["report"]; | |
| 25 | ||
| 19 | 26 | /** A thin plugin the forge ships for a harness: where its source is and its one install line. */ |
| 20 | 27 | export interface HarnessPlugin { |
| 21 | 28 | readonly harness: string; |
| @@ -60,7 +67,7 @@ export function mcpCard(origin: string, now: Running = running()) { | ||
| 60 | 67 | protectedResourceMetadata: oauth, |
| 61 | 68 | documentation: `${origin}/llms.txt`, |
| 62 | 69 | openapi: `${origin}/contract/openapi.json`, |
| 63 | tokenFreeTools: ["report"], | |
| 70 | tokenFreeTools: [...TOKEN_FREE_TOOLS], | |
| 64 | 71 | plugins: HARNESS_PLUGINS.map((p) => ({ |
| 65 | 72 | harness: p.harness, |
| 66 | 73 | path: p.path, |
src/http/mcp.ts +1 −1
| @@ -272,7 +272,7 @@ function reportCall(ctx: Ctx, message: unknown): unknown | undefined { | ||
| 272 | 272 | if ( |
| 273 | 273 | m["jsonrpc"] !== "2.0" || |
| 274 | 274 | m["method"] !== "tools/call" || |
| 275 | params["name"] !== "report" || | |
| 275 | params["name"] !== REPORT_TOOL.name || | |
| 276 | 276 | m["id"] === undefined |
| 277 | 277 | ) |
| 278 | 278 | return undefined; |
test/here-manifest.test.ts −1
| @@ -260,7 +260,6 @@ describe("the manifest: what you can do here, as who you are", () => { | ||
| 260 | 260 | do: "see how to help build this forge", |
| 261 | 261 | http: "GET /api/v1/contribute", |
| 262 | 262 | mcp: "contribute", |
| 263 | account: false, | |
| 264 | 263 | }); |
| 265 | 264 | expect(size).toBeLessThan(2048); |
| 266 | 265 | expect(does((await manifest("/acme/game", who)).body)).not.toContain( |
test/token-free-tools.test.ts added +59
| @@ -0,0 +1,59 @@ | ||
| 1 | /** What needs no token over /mcp is one list; the card and `here` print it. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 8 | import { TOKEN_FREE_TOOLS } from "../src/core/mcp_card.ts"; | |
| 9 | import { REPORT_TOOL, toolNames } from "../src/http/mcp.ts"; | |
| 10 | ||
| 11 | describe("token-free MCP tools", () => { | |
| 12 | let dataDir: string; | |
| 13 | let app: App; | |
| 14 | let servers: Servers; | |
| 15 | let base: string; | |
| 16 | ||
| 17 | beforeAll(async () => { | |
| 18 | dataDir = await mkdtemp(join(tmpdir(), "forge-token-free-")); | |
| 19 | app = createApp({ dataDir, port: 0 }); | |
| 20 | servers = await serve(app); | |
| 21 | base = servers.url; | |
| 22 | }); | |
| 23 | afterAll(async () => { | |
| 24 | await servers.stop(); | |
| 25 | await rm(dataDir, { recursive: true, force: true }); | |
| 26 | }); | |
| 27 | ||
| 28 | const call = (name: string) => | |
| 29 | fetch(`${base}/mcp`, { | |
| 30 | method: "POST", | |
| 31 | headers: { "content-type": "application/json" }, | |
| 32 | body: JSON.stringify({ | |
| 33 | jsonrpc: "2.0", | |
| 34 | id: 1, | |
| 35 | method: "tools/call", | |
| 36 | params: { name, arguments: { repo: "nobody/none", title: "x" } }, | |
| 37 | }), | |
| 38 | }); | |
| 39 | ||
| 40 | test("every tool on the list answers without a token, and the gate lets in only those", async () => { | |
| 41 | expect(TOKEN_FREE_TOOLS).toContain(REPORT_TOOL.name); | |
| 42 | for (const name of TOKEN_FREE_TOOLS) { | |
| 43 | expect((await call(name)).status).not.toBe(401); | |
| 44 | } | |
| 45 | for (const name of toolNames().filter((n) => !TOKEN_FREE_TOOLS.includes(n))) { | |
| 46 | expect((await call(name)).status).toBe(401); | |
| 47 | } | |
| 48 | }); | |
| 49 | ||
| 50 | test("the card and `here` print exactly that list", async () => { | |
| 51 | const card = (await (await fetch(`${base}/.well-known/mcp.json`)).json()) as any; | |
| 52 | expect(card.tokenFreeTools).toEqual([...TOKEN_FREE_TOOLS]); | |
| 53 | const here = (await (await fetch(`${base}/api/v1/here`)).json()) as any; | |
| 54 | const open = here.actions | |
| 55 | .filter((a: any) => a.mcp !== undefined && a.account === false) | |
| 56 | .map((a: any) => a.mcp); | |
| 57 | expect(open.sort()).toEqual([...TOKEN_FREE_TOOLS].sort()); | |
| 58 | }); | |
| 59 | }); |
new above
offline