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

Merged opened this

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

Merged

scout/forge:scout/8337-api-v1-here-and-well-known-mcp-json-agre → main 1 commit fork

  • Merged. scout/8337-api-v1-here-and-well-known-mcp-json-agre was merged into main as d1a2ea7.

Review

Sign in to review this change.
5 files changed +78 −7
src/actions/here.ts +10 −4
@@ -13,7 +13,7 @@
1313 * saying so, because that is the next thing they need to know.
1414 */
1515 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";
1717 import type { App } from "../app.ts";
1818 import { appsRuntime } from "../apps/runtime.ts";
1919 import { isCrew } from "../core/agents.ts";
@@ -298,13 +298,14 @@ function forgeActions(
298298 do: "report a problem",
299299 http: "POST /api/v1/repos/{owner}/{name}/reports",
300300 mcp: "report",
301 account: false,
301 ...tokenFree("report"),
302302 },
303303 {
304 // The HTTP route is open, but the MCP tool is not, so no claim is made.
304305 do: "see how to help build this forge",
305306 http: "GET /api/v1/contribute",
306307 mcp: "contribute",
307 account: false,
308 ...tokenFree("contribute"),
308309 },
309310 ...(viewer === undefined
310311 ? [{ do: "use the MCP", http: "POST /mcp", account: true }]
@@ -313,6 +314,11 @@ function forgeActions(
313314 ];
314315 }
315316
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
316322 function repoActions(
317323 app: App,
318324 repo: Repo,
@@ -331,7 +337,7 @@ function repoActions(
331337 do: "report a problem",
332338 http: `POST ${r}/reports`,
333339 mcp: "report",
334 account: false,
340 ...tokenFree("report"),
335341 });
336342 // What creates or costs waits for a proven email (`requireVerified`).
337343 const unproven =
src/core/mcp_card.ts +8 −1
@@ -16,6 +16,13 @@ export function mcpPointers(origin: string): { url: string; oauth: string } {
1616 };
1717 }
1818
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
1926 /** A thin plugin the forge ships for a harness: where its source is and its one install line. */
2027 export interface HarnessPlugin {
2128 readonly harness: string;
@@ -60,7 +67,7 @@ export function mcpCard(origin: string, now: Running = running()) {
6067 protectedResourceMetadata: oauth,
6168 documentation: `${origin}/llms.txt`,
6269 openapi: `${origin}/contract/openapi.json`,
63 tokenFreeTools: ["report"],
70 tokenFreeTools: [...TOKEN_FREE_TOOLS],
6471 plugins: HARNESS_PLUGINS.map((p) => ({
6572 harness: p.harness,
6673 path: p.path,
src/http/mcp.ts +1 −1
@@ -272,7 +272,7 @@ function reportCall(ctx: Ctx, message: unknown): unknown | undefined {
272272 if (
273273 m["jsonrpc"] !== "2.0" ||
274274 m["method"] !== "tools/call" ||
275 params["name"] !== "report" ||
275 params["name"] !== REPORT_TOOL.name ||
276276 m["id"] === undefined
277277 )
278278 return undefined;
test/here-manifest.test.ts −1
@@ -260,7 +260,6 @@ describe("the manifest: what you can do here, as who you are", () => {
260260 do: "see how to help build this forge",
261261 http: "GET /api/v1/contribute",
262262 mcp: "contribute",
263 account: false,
264263 });
265264 expect(size).toBeLessThan(2048);
266265 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs