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

/.well-known/agent-card.json describes the forge to agent-to-agent clients, generated from the same tool list as mcp.json #12338

Merged opened this

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

Merged

mason/forge:mason/12320-well-known-agent-card-json-describes-the → main 6 commits fork 3 threads · 3 unresolved

  • Merged. mason/12320-well-known-agent-card-json-describes-the was merged into main as ee374c7.

Review

Sign in to review this change.
5 files changed +122 −3
docs/MCP.md +6
@@ -448,6 +448,12 @@ and the build that answers: `commit`, the full commit the forge runs
448448 says. `version` is `dev` on a preview, so `commit` is how an agent tells two
449449 previews apart.
450450
451 `tools` lists every tool name. `/.well-known/agent-card.json` is the same
452 server as an A2A agent card (protocol 0.3.0, the stable subset: `name`,
453 `description`, `url`, `version`, `capabilities`, `securitySchemes`, `skills`);
454 its skills are those `tools`, built from the same list in `core/mcp_card.ts`,
455 and a test fails if they differ.
456
451457 `/.well-known/api-catalog` (RFC 9727, `application/linkset+json`) is the one
452458 request that lists the OpenAPI description, this card and the docs; the home
453459 page's `Link` header points at it. It adds no tool.
src/core/mcp_card.ts +46
@@ -26,6 +26,7 @@ export const DISCOVERY = {
2626 openapi: "/contract/openapi.json",
2727 docs: "/llms.txt",
2828 card: "/.well-known/mcp.json",
29 agentCard: "/.well-known/agent-card.json",
2930 catalog: "/.well-known/api-catalog",
3031 mcp: "/mcp",
3132 api: "/api/v1",
@@ -135,6 +136,50 @@ export function cardsLine(): string {
135136 return `Tool results carry MCP Apps cards: a tool listed with _meta.ui.resourceUri points at a ui:// resource a harness can draw (${n} cards; the list is "cards" in the server card).`;
136137 }
137138
139 /**
140 * The MCP tools, as the index lists them: set once at boot, read by both
141 * cards so neither can name a tool the other lacks.
142 */
143 let cardTools: readonly { name: string; description: string }[] = [];
144 export function setCardTools(tools: readonly { name: string; description: string; doc?: string }[]): void {
145 const seen = new Set<string>();
146 cardTools = tools
147 .filter((t) => !seen.has(t.name) && seen.add(t.name))
148 .map((t) => {
149 const one = (t.doc ?? t.description).replace(/\s+/g, " ").trim();
150 return { name: t.name, description: one.length > 200 ? `${one.slice(0, 199)}…` : one };
151 });
152 }
153
154 /** What the cards name as tools, as one string: a cached body is made again when it changes. */
155 export const toolsKey = (): string => cardTools.map((t) => t.name).join(",");
156
157 /**
158 * The agent card (A2A, protocol 0.3.0, the stable subset: name, description,
159 * url, version, protocolVersion, capabilities, default modes, skills; the
160 * sign-in is named by `securitySchemes`). One skill per MCP tool, from the
161 * same list `mcpCard` names under `tools`.
162 */
163 export function agentCard(origin: string, now: Running = running()) {
164 const { url, oauth } = mcpPointers(origin);
165 return {
166 protocolVersion: "0.3.0",
167 name: "forge",
168 description: "A forge: repositories, issues, pulls and a crew of agents that build apps. Steer it through its MCP endpoint, signing in with OAuth.",
169 url,
170 preferredTransport: "JSONRPC",
171 version: now.version,
172 documentationUrl: `${origin}${DISCOVERY.docs}`,
173 capabilities: { streaming: false, pushNotifications: false },
174 securitySchemes: { oauth: { type: "openIdConnect", openIdConnectUrl: `${origin}/.well-known/openid-configuration` } },
outdated

openIdConnectUrl must be an OpenID Connect discovery document. oauth-protected-resource is RFC 9728 resource metadata, not that. Use the authorization-server metadata URL, or an oauth2 scheme with the actual authorizationUrl and tokenUrl.

175 security: [{ oauth: [] }],
176 mcp: { endpoint: url, card: `${origin}${DISCOVERY.card}`, protectedResourceMetadata: oauth },
177 defaultInputModes: ["application/json"],
178 defaultOutputModes: ["application/json"],
179 skills: cardTools.map((t) => ({ id: t.name, name: t.name, description: t.description, tags: ["mcp"] })),
180 };
181 }
182
138183 /**
139184 * The server card. The format is still a draft, so only what we can keep
140185 * true is named: who answers, where, over what transport, how to sign in,
@@ -152,6 +197,7 @@ export function mcpCard(origin: string, repo: string | undefined, now: Running =
152197 documentation: `${origin}${DISCOVERY.docs}`,
153198 openapi: `${origin}${DISCOVERY.openapi}`,
154199 tokenFreeTools: [...TOKEN_FREE_TOOLS],
200 tools: cardTools.map((t) => t.name),
155201 cards: cardList(),
156202 plugins: offeredPlugins(repo).map((p) => ({
157203 harness: p.harness,
src/http/server.ts +4
@@ -198,6 +198,7 @@ import {
198198 securityTxt,
199199 apiCatalogJson,
200200 mcpJson,
201 agentCardJson,
201202 forgeJson,
202203 sitemapXml,
203204 webManifest,
@@ -525,6 +526,7 @@ import { feedRoutes } from "./feed.ts";
525526 import type { Database } from "bun:sqlite";
526527 import { mcpRoutes, REPORT_TOOL, START_TOOL } from "./mcp.ts";
527528 import { TOOLS } from "./mcp/tools.ts";
529 import { setCardTools } from "../core/mcp_card.ts";
528530 import { setMcpTools } from "../web/llms_tools.ts";
529531 import { resourcePolicy } from "./corp.ts";
530532 import { agentsPage } from "../web/pages/agents.ts";
@@ -808,6 +810,7 @@ export function buildRouter(): Router<Handler> {
808810 // What the open web expects of a site, from the forge's own facts.
809811 router.add("GET", "/.well-known/security.txt", securityTxt);
810812 router.add("GET", "/.well-known/mcp.json", mcpJson);
813 router.add("GET", "/.well-known/agent-card.json", agentCardJson);
811814 router.add("GET", "/.well-known/api-catalog", apiCatalogJson);
812815 router.add("GET", "/.well-known/forge", forgeJson);
813816 router.add("GET", "/llms.txt", llmsTxt);
@@ -1267,6 +1270,7 @@ export function buildRouter(): Router<Handler> {
12671270 // The forge as an MCP server: the studio's verbs for an assistant.
12681271 mcpRoutes(router);
12691272 setMcpTools(TOOLS, [REPORT_TOOL, START_TOOL]);
1273 setCardTools([...TOOLS, REPORT_TOOL, START_TOOL]);
12701274 // The public API as OpenAPI, for any client that reads it.
12711275 openapiRoute(router);
12721276 oauthRoutes(router);
src/web/standards.ts +12 −3
@@ -48,7 +48,7 @@ import { SITE_PATH, SITE_LINE } from "./site_line.ts";
4848 import { holds } from "./feeds.ts";
4949 import { mcpToolsSection } from "./llms_tools.ts";
5050 import { threeDoors } from "./doors_words.ts";
51 import { apiCatalog, cardsLine, DISCOVERY, mcpCard, offeredPlugins, mcpPointers, offeredPluginRepo, pluginSource } from "../core/mcp_card.ts";
51 import { agentCard, toolsKey, apiCatalog, cardsLine, DISCOVERY, mcpCard, offeredPlugins, mcpPointers, offeredPluginRepo, pluginSource } from "../core/mcp_card.ts";
5252 import { forgeDocument } from "../core/standards_index.ts";
5353 import { TAGS } from "../contract/served.ts";
5454 import { running } from "../kernel/version.ts";
@@ -145,7 +145,16 @@ function discovery(ctx: Ctx, kind: string, inputs: string, make: () => unknown,
145145 export function mcpJson(ctx: Ctx): Response {
146146 const repo = offeredPluginRepo(ctx.app.db, ctx.app.config.forgeRepo, UPSTREAM_REPO);
147147 const now = running();
148 return discovery(ctx, "mcp", JSON.stringify([repo, now.version, now.commit]), () => mcpCard(ctx.url.origin, repo, now), "application/json", "public, max-age=3600", true);
148 return discovery(ctx, "mcp", JSON.stringify([repo, now.version, now.commit, toolsKey()]), () => mcpCard(ctx.url.origin, repo, now), "application/json", "public, max-age=3600", true);
149 }
150
151 /**
152 * GET /.well-known/agent-card.json — the A2A agent card, its skills the MCP
153 * tool names the server card lists.
154 */
155 export function agentCardJson(ctx: Ctx): Response {
156 const now = running();
157 return discovery(ctx, "agent-card", JSON.stringify([now.version, toolsKey()]), () => agentCard(ctx.url.origin, now), "application/json", "public, max-age=3600", true);
149158 }
150159
151160 /** GET /.well-known/api-catalog — RFC 9727: one linkset naming each API's description and docs. Anonymous. */
@@ -507,7 +516,7 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s
507516 "",
508517 "## For agents with an account",
509518 "",
510 `- MCP: ${mcp.url} (OAuth discovery: ${mcp.oauth}; server card, a draft format: ${o}${DISCOVERY.card})`,
519 `- MCP: ${mcp.url} (OAuth discovery: ${mcp.oauth}; server card, a draft format: ${o}${DISCOVERY.card}; A2A agent card: ${o}${DISCOVERY.agentCard})`,
511520 `- API: ${o}${DISCOVERY.api} (the root lists the other URLs), Authorization: Bearer <token>`,
512521 `- API catalog (RFC 9727, one linkset of the above): ${o}${DISCOVERY.catalog}`,
513522 `- OpenAPI: GET ${o}${DISCOVERY.openapi}, or one slice with ?tag= (${TAGS.join(", ")})`,
test/agent-card.test.ts added +54
@@ -0,0 +1,54 @@
1 /** The agent card at /.well-known/agent-card.json and the server card name the same tools. */
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 } from "../src/app.ts";
7 import { TOOLS } from "../src/http/mcp/tools.ts";
8 import { REPORT_TOOL, START_TOOL } from "../src/http/mcp.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10
11 describe("the agent card", () => {
12 let dataDir: string;
13 let servers: Servers;
14 let base: string;
15 beforeAll(async () => {
16 dataDir = await mkdtemp(join(tmpdir(), "forge-agent-card-"));
17 servers = await serve(createApp({ dataDir, port: 0 }));
18 base = servers.url;
19 });
20 afterAll(async () => {
21 await servers.stop();
22 await rm(dataDir, { recursive: true, force: true });
23 });
24
25 test("answers public JSON, cacheable for an hour, with the security floor", async () => {
26 const res = await fetch(`${base}/.well-known/agent-card.json`);
27 expect(res.status).toBe(200);
28 expect(res.headers.get("content-type")).toContain("application/json");
29 expect(res.headers.get("cache-control")).toBe("public, max-age=3600");
30 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
31 expect(res.headers.get("content-security-policy")).toBeTruthy();
32 expect(res.headers.get("referrer-policy")).toBeTruthy();
33 const card = (await res.json()) as any;
34 expect(card.name).toBe("forge");
35 expect(card.description.length).toBeGreaterThan(0);
36 expect(card.url).toBe(`${base}/mcp`);
37 expect(card.securitySchemes.oauth.openIdConnectUrl).toBe(`${base}/.well-known/openid-configuration`);
38 const oidc = (await (await fetch(card.securitySchemes.oauth.openIdConnectUrl)).json()) as any;
39 expect(oidc.authorization_endpoint).toBe(`${base}/oauth/authorize`);
40 expect(oidc.token_endpoint).toBe(`${base}/oauth/token`);
41 expect(card.mcp.endpoint).toBe(card.url);
42 });
43
outdated

Both cards come from the same setCardTools list, so this passes even if the list drops a tool. Also assert against the tool names that tools/list actually serves (TOOLS plus report and start), so the test fails when a tool is in one place and not the other.

44 test("its skills are exactly the tools mcp.json lists", async () => {
45 const agent = (await (await fetch(`${base}/.well-known/agent-card.json`)).json()) as any;
46 const mcp = (await (await fetch(`${base}/.well-known/mcp.json`)).json()) as any;
47 expect(mcp.tools.length).toBeGreaterThan(10);
48 expect(agent.skills.map((s: any) => s.id).sort()).toEqual([...mcp.tools].sort());
49 // The list tools/list serves, not the one both cards are built from.
50 const served = [...TOOLS, REPORT_TOOL, START_TOOL].map((t) => t.name);
51 expect(agent.skills.map((s: any) => s.id).sort()).toEqual([...new Set(served)].sort());
52 for (const s of agent.skills) expect(s.description.length).toBeGreaterThan(0);
53 });
54 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs