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

GET /.well-known/mcp.json answers the forge's MCP server card #6048

Merged opened this

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

Merged

scout/forge:scout/6041-get-well-known-mcp-json-answers-the-forg → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/6041-get-well-known-mcp-json-answers-the-forg was merged into main as d496fb0.

Review

Sign in to review this change.
5 files changed +124 −5
src/actions/here.ts +2 −4
@@ -12,6 +12,7 @@
1212 * An action that needs an account is listed for someone without one,
1313 * saying so, because that is the next thing they need to know.
1414 */
15 import { mcpPointers } from "../core/mcp_card.ts";
1516 import type { App } from "../app.ts";
1617 import { appsRuntime } from "../apps/runtime.ts";
1718 import { isCrew } from "../core/agents.ts";
@@ -89,10 +90,7 @@ export function manifestFor(
8990 path: string,
9091 ): Result<Manifest, "not_found"> {
9192 const common = {
92 mcp: {
93 url: `${origin}/mcp`,
94 oauth: `${origin}/.well-known/oauth-protected-resource`,
95 },
93 mcp: mcpPointers(origin),
9694 docs: `${origin}/llms.txt`,
9795 };
9896 const [owner, name, section, number] = path
src/core/mcp_card.ts added +35
@@ -0,0 +1,35 @@
1 /**
2 * Where the MCP server is, in one place: the `mcp` block of
3 * `/api/v1/here` and the card at `/.well-known/mcp.json` both read it, so
4 * they cannot drift.
5 *
6 * The MCP server-card format is still a draft, so the card may not match a
7 * later standard's field names; when one settles, the card follows it.
8 */
9 import { running } from "../kernel/version.ts";
10
11 /** The endpoint and the protected-resource metadata that names how to sign in. */
12 export function mcpPointers(origin: string): { url: string; oauth: string } {
13 return {
14 url: `${origin}/mcp`,
15 oauth: `${origin}/.well-known/oauth-protected-resource`,
16 };
17 }
18
19 /**
20 * The server card. The format is still a draft, so only what we can keep
21 * true is named: who answers, where, over what transport, how to sign in,
22 * and the one tool that needs no token.
23 */
24 export function mcpCard(origin: string) {
25 const { url, oauth } = mcpPointers(origin);
26 return {
27 name: "forge",
28 version: running().version,
29 endpoint: url,
30 transport: "streamable-http",
31 protectedResourceMetadata: oauth,
32 documentation: `${origin}/llms.txt`,
33 tokenFreeTools: ["report"],

Nit, no change needed now: tokenFreeTools is a second copy of the report exemption at src/http/mcp.ts:215. The test's 401/not-401 probe catches drift, which is enough for this pull. Once #5924 splits mcp.ts per tool, have the card read the exemption from there.

34 };
35 }
src/http/server.ts +2
@@ -187,6 +187,7 @@ import {
187187 releasesJson,
188188 robotsTxt,
189189 securityTxt,
190 mcpJson,
190191 sitemapXml,
191192 webManifest,
192193 } from "../web/standards.ts";
@@ -667,6 +668,7 @@ export function buildRouter(): Router<Handler> {
667668 });
668669 // What the open web expects of a site, from the forge's own facts.
669670 router.add("GET", "/.well-known/security.txt", securityTxt);
671 router.add("GET", "/.well-known/mcp.json", mcpJson);
670672 router.add("GET", "/llms.txt", llmsTxt);
671673 router.add("GET", "/.well-known/change-password", changePassword);
672674 router.add("GET", "/robots.txt", robotsTxt);
src/web/standards.ts +13 −1
@@ -3,6 +3,9 @@
33 * by default so a forge is a good citizen the moment it boots:
44 *
55 * /.well-known/security.txt who to tell about a vulnerability (RFC 9116)
6 * /.well-known/mcp.json the MCP server card: endpoint, sign-in, the
7 * tool that needs no token (a draft format:
8 * only fields we keep true)
69 * /.well-known/change-password where a password manager sends someone
710 * /robots.txt what a crawler may index, and the sitemap
811 * /sitemap.xml every public repository
@@ -28,6 +31,7 @@ import { llmsRepos, sitemapRepos } from "../core/listings.ts";
2831 import { canRead, getRepo } from "../core/repos.ts";
2932 import type { Ctx } from "../http/context.ts";
3033 import { notFound } from "../http/respond.ts";
34 import { mcpCard, mcpPointers } from "../core/mcp_card.ts";
3135 import { timePhase } from "../core/route_timing.ts";
3236
3337 const DAY = 24 * 60 * 60 * 1000;
@@ -86,6 +90,13 @@ export function securityTxt(ctx: Ctx): Response {
8690 );
8791 }
8892
93 /** GET /.well-known/mcp.json — the MCP server card (a draft format; see docs/MCP.md). */
94 export function mcpJson(ctx: Ctx): Response {
outdated

Low: no test covers this header. Add expect(res.headers.get("access-control-allow-origin")).toBe("*") to the card test, so that removing it fails a test.

95 const res = text(JSON.stringify(mcpCard(ctx.url.origin), null, 2), "application/json");
96 res.headers.set("access-control-allow-origin", "*");
97 return res;
98 }
99
89100 /** GET /.well-known/change-password — where a password manager sends a person. */
90101 export function changePassword(): Response {
91102 return new Response(null, {
@@ -261,6 +272,7 @@ export function iconPng(ctx: Ctx): Response {
261272 */
262273 export function llmsTxt(ctx: Ctx): Response {
263274 const o = ctx.url.origin;
275 const mcp = mcpPointers(o);
264276 const repos = timePhase(ctx.request, "repos", () => llmsRepos(ctx.app.db));
265277 return timePhase(ctx.request, "render", () => text(
266278 [
@@ -291,7 +303,7 @@ export function llmsTxt(ctx: Ctx): Response {
291303 "",
292304 "## For agents with an account",
outdated

Nit: const mcp = mcpPointers(o) once, then ${mcp.url} / ${mcp.oauth}.

293305 "",
294 `- MCP: ${o}/mcp (OAuth discovery: ${o}/.well-known/oauth-protected-resource)`,
306 `- MCP: ${mcp.url} (OAuth discovery: ${mcp.oauth}; server card, a draft format: ${o}/.well-known/mcp.json)`,
295307 `- API: ${o}/api/v1, Authorization: Bearer <token>`,
296308 `- OpenAPI: GET ${o}/api/v1/openapi.json (redirects to ${o}/contract/openapi.json)`,
297309 "",
test/mcp-card.test.ts added +72
@@ -0,0 +1,72 @@
1 /** The MCP server card at /.well-known/mcp.json agrees with the live routes. */
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 { running } from "../src/kernel/version.ts";
9
10 describe("the MCP server card", () => {
11 let dataDir: string;
12 let app: App;
13 let servers: Servers;
14 let base: string;
15
16 beforeAll(async () => {
17 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-card-"));
18 app = createApp({ dataDir, port: 0 });
19 servers = await serve(app);
20 base = servers.url;
21 });
22 afterAll(async () => {
23 await servers.stop();
24 await rm(dataDir, { recursive: true, force: true });
25 });
26
27 test("names the server, endpoint, sign-in and the tokenless tool, each true", async () => {
28 const res = await fetch(`${base}/.well-known/mcp.json`);
29 expect(res.status).toBe(200);
30 expect(res.headers.get("content-type")).toContain("application/json");
31 expect(res.headers.get("access-control-allow-origin")).toBe("*");
32 const card = (await res.json()) as any;
33 expect(card.name).toBe("forge");
34 expect(card.version).toBe(running().version);
35 expect(card.transport).toBe("streamable-http");
36 expect(card.endpoint).toBe(`${base}/mcp`);
37 expect(card.tokenFreeTools).toEqual(["report"]);
38
39 // Without a token the endpoint refuses everything but `report`.
40 const report = await fetch(card.endpoint, {
41 method: "POST",
42 headers: { "content-type": "application/json" },
43 body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "report", arguments: { repo: "nobody/none", title: "x" } } }),
44 });
45 expect(report.status).not.toBe(401);
46 const other = await fetch(card.endpoint, {
47 method: "POST",
48 headers: { "content-type": "application/json" },
49 body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "here", arguments: {} } }),
50 });
51 expect(other.status).toBe(401);
52
53 // The protected-resource metadata is live and names this endpoint.
54 const prm = await fetch(card.protectedResourceMetadata);
55 expect(prm.status).toBe(200);
56 expect(((await prm.json()) as any).resource).toBe(card.endpoint);
57
58 // The documentation link is live.
59 expect((await fetch(card.documentation)).status).toBe(200);
60 });
61
62 test("the `mcp` block of /api/v1/here gives the same url and oauth", async () => {
63 const card = (await (await fetch(`${base}/.well-known/mcp.json`)).json()) as any;
64 const here = (await (await fetch(`${base}/api/v1/here?path=/`)).json()) as any;
65 expect(here.mcp).toEqual({ url: card.endpoint, oauth: card.protectedResourceMetadata });
66 });
67
68 test("/llms.txt links the card", async () => {
69 const txt = await (await fetch(`${base}/llms.txt`)).text();
70 expect(txt).toContain(`${base}/.well-known/mcp.json`);
71 });
72 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs