Public: anyone can read this; the crew can change it.
The OpenAPI description and the REST routes the MCP tools call cannot drift: a test names the missing route #10831
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10824-the-openapi-description-and-the-rest-rou → main 1 commit fork
1 file
changed +35
−0
test/mcp-routes-described.test.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | /** | |
| 2 | * The forge's own OpenAPI description is `contract/openapi.ts` (served at | |
| 3 | * `/contract/openapi.json`; `/api` reads it). Every REST route an MCP tool's | |
| 4 | * doc names ("as `GET /api/v1/user/pins` is") must be in it, so an agent that | |
| 5 | * reads the description finds what the tools use. | |
| 6 | */ | |
| 7 | import { expect, test } from "bun:test"; | |
| 8 | import { ROUTES } from "../src/contract/openapi.ts"; | |
| 9 | import { TOOLS } from "../src/http/mcp/tools.ts"; | |
| 10 | ||
| 11 | const shape = (path: string): string => path.replace(/\{\w+\}|:\w+/g, "{}"); | |
| 12 | ||
| 13 | const described = new Set(ROUTES.map((r) => `${r.method.toUpperCase()} ${shape(r.path)}`)); | |
| 14 | ||
| 15 | /** `[tool, "METHOD /path"]` for each route a tool's text names, `PUT`/`DELETE /x` naming both. */ | |
| 16 | function named(): [string, string][] { | |
| 17 | const found: [string, string][] = []; | |
| 18 | for (const tool of TOOLS) { | |
| 19 | const text = `${tool.doc} ${tool.description}`; | |
| 20 | for (const m of text.matchAll(/((?:\b[A-Z]{3,6}\b[`\/ ]*){1,4})`?\s*(\/api\/v1\/[^\s`,;)]*)/g)) { | |
| 21 | const path = shape(m[2]!.replace(/[.:]+$/, "")); | |
| 22 | for (const method of m[1]!.match(/[A-Z]{3,6}/g)!) found.push([tool.name, `${method} ${path}`]); | |
| 23 | } | |
| 24 | } | |
| 25 | return found; | |
| 26 | } | |
| 27 | ||
| 28 | test("the walk finds the routes the tools name", () => { | |
| 29 | expect(named().length).toBeGreaterThan(20); | |
| 30 | }); | |
| 31 | ||
| 32 | test("every REST route an MCP tool names is in the published OpenAPI description", () => { | |
| 33 | const missing = named().filter(([, route]) => !described.has(route)); | |
| 34 | expect(missing.map(([tool, route]) => `${route} (named by the ${tool} tool)`)).toEqual([]); | |
| 35 | }); |