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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/12320-well-known-agent-card-json-describes-the → main 6 commits fork 3 threads · 3 unresolved
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 | ||
| 448 | 448 | says. `version` is `dev` on a preview, so `commit` is how an agent tells two |
| 449 | 449 | previews apart. |
| 450 | 450 | |
| 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 | ||
| 451 | 457 | `/.well-known/api-catalog` (RFC 9727, `application/linkset+json`) is the one |
| 452 | 458 | request that lists the OpenAPI description, this card and the docs; the home |
| 453 | 459 | page's `Link` header points at it. It adds no tool. |
src/core/mcp_card.ts +46
| @@ -26,6 +26,7 @@ export const DISCOVERY = { | ||
| 26 | 26 | openapi: "/contract/openapi.json", |
| 27 | 27 | docs: "/llms.txt", |
| 28 | 28 | card: "/.well-known/mcp.json", |
| 29 | agentCard: "/.well-known/agent-card.json", | |
| 29 | 30 | catalog: "/.well-known/api-catalog", |
| 30 | 31 | mcp: "/mcp", |
| 31 | 32 | api: "/api/v1", |
| @@ -135,6 +136,50 @@ export function cardsLine(): string { | ||
| 135 | 136 | 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).`; |
| 136 | 137 | } |
| 137 | 138 | |
| 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` } }, | |
| 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 | ||
| 138 | 183 | /** |
| 139 | 184 | * The server card. The format is still a draft, so only what we can keep |
| 140 | 185 | * 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 = | ||
| 152 | 197 | documentation: `${origin}${DISCOVERY.docs}`, |
| 153 | 198 | openapi: `${origin}${DISCOVERY.openapi}`, |
| 154 | 199 | tokenFreeTools: [...TOKEN_FREE_TOOLS], |
| 200 | tools: cardTools.map((t) => t.name), | |
| 155 | 201 | cards: cardList(), |
| 156 | 202 | plugins: offeredPlugins(repo).map((p) => ({ |
| 157 | 203 | harness: p.harness, |
src/http/server.ts +4
| @@ -198,6 +198,7 @@ import { | ||
| 198 | 198 | securityTxt, |
| 199 | 199 | apiCatalogJson, |
| 200 | 200 | mcpJson, |
| 201 | agentCardJson, | |
| 201 | 202 | forgeJson, |
| 202 | 203 | sitemapXml, |
| 203 | 204 | webManifest, |
| @@ -525,6 +526,7 @@ import { feedRoutes } from "./feed.ts"; | ||
| 525 | 526 | import type { Database } from "bun:sqlite"; |
| 526 | 527 | import { mcpRoutes, REPORT_TOOL, START_TOOL } from "./mcp.ts"; |
| 527 | 528 | import { TOOLS } from "./mcp/tools.ts"; |
| 529 | import { setCardTools } from "../core/mcp_card.ts"; | |
| 528 | 530 | import { setMcpTools } from "../web/llms_tools.ts"; |
| 529 | 531 | import { resourcePolicy } from "./corp.ts"; |
| 530 | 532 | import { agentsPage } from "../web/pages/agents.ts"; |
| @@ -808,6 +810,7 @@ export function buildRouter(): Router<Handler> { | ||
| 808 | 810 | // What the open web expects of a site, from the forge's own facts. |
| 809 | 811 | router.add("GET", "/.well-known/security.txt", securityTxt); |
| 810 | 812 | router.add("GET", "/.well-known/mcp.json", mcpJson); |
| 813 | router.add("GET", "/.well-known/agent-card.json", agentCardJson); | |
| 811 | 814 | router.add("GET", "/.well-known/api-catalog", apiCatalogJson); |
| 812 | 815 | router.add("GET", "/.well-known/forge", forgeJson); |
| 813 | 816 | router.add("GET", "/llms.txt", llmsTxt); |
| @@ -1267,6 +1270,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1267 | 1270 | // The forge as an MCP server: the studio's verbs for an assistant. |
| 1268 | 1271 | mcpRoutes(router); |
| 1269 | 1272 | setMcpTools(TOOLS, [REPORT_TOOL, START_TOOL]); |
| 1273 | setCardTools([...TOOLS, REPORT_TOOL, START_TOOL]); | |
| 1270 | 1274 | // The public API as OpenAPI, for any client that reads it. |
| 1271 | 1275 | openapiRoute(router); |
| 1272 | 1276 | oauthRoutes(router); |
src/web/standards.ts +12 −3
| @@ -48,7 +48,7 @@ import { SITE_PATH, SITE_LINE } from "./site_line.ts"; | ||
| 48 | 48 | import { holds } from "./feeds.ts"; |
| 49 | 49 | import { mcpToolsSection } from "./llms_tools.ts"; |
| 50 | 50 | 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"; | |
| 52 | 52 | import { forgeDocument } from "../core/standards_index.ts"; |
| 53 | 53 | import { TAGS } from "../contract/served.ts"; |
| 54 | 54 | import { running } from "../kernel/version.ts"; |
| @@ -145,7 +145,16 @@ function discovery(ctx: Ctx, kind: string, inputs: string, make: () => unknown, | ||
| 145 | 145 | export function mcpJson(ctx: Ctx): Response { |
| 146 | 146 | const repo = offeredPluginRepo(ctx.app.db, ctx.app.config.forgeRepo, UPSTREAM_REPO); |
| 147 | 147 | 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); | |
| 149 | 158 | } |
| 150 | 159 | |
| 151 | 160 | /** 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 | ||
| 507 | 516 | "", |
| 508 | 517 | "## For agents with an account", |
| 509 | 518 | "", |
| 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})`, | |
| 511 | 520 | `- API: ${o}${DISCOVERY.api} (the root lists the other URLs), Authorization: Bearer <token>`, |
| 512 | 521 | `- API catalog (RFC 9727, one linkset of the above): ${o}${DISCOVERY.catalog}`, |
| 513 | 522 | `- 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 | }); |
new above
offline
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.