From 5798f1a30702b8b4f8afa006ac5b72674673796e Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Fri, 5 Jun 2026 00:04:59 +0530 Subject: [PATCH] test(mcp): registry-iterating done-bar drift guard + agent-facing error contract tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raises MCP test coverage toward the flow matrix (docs/sessions/2026-06-04/USER-FLOW-INVENTORY-AND-TEST-MATRIX.md §4.2) and the done-bar. Agents are the PRIMARY consumers of instanode, so MCP tool flows + the agent-facing error surface are P0. Done-bar drift guard (test/tool-coverage.test.ts, NEW): iterates the live `server._registeredTools` registry (rule 18 — no hand-typed slice) and FAILS CI if any registered tool lacks (a) a J-row mapping in MAPPED_TOOLS, (b) a callable handler + inputSchema + description, or (c) any test exercising it in test/*.test.ts. Also reds on stale mappings (renamed/removed tool). Verified failing-then-passing: dropping a MAPPED_TOOLS row reds "no flow-matrix J-row mapping". Contract / error-mapping tests (test/tool-contract.test.ts, NEW): drive real tool handlers against the mock api and assert the agent-facing envelope mapping the LLM reads aloud: - 402 tier-gate (create_deploy private on hobby) → Action: + Upgrade: block surfaced verbatim (rule 12 / FIX-E #C7), + pro negative control - 401 on auth-required tools → dashboard CTA - 404 (cross-team / absent, J11+J17) → clean not_found - endpoint contract for gap tools J5/J6/J7/J14/J15/J16 (correct endpoint hit + documented response shape) mock-api: add HOBBY_TOKEN fixture (test/mock-api.ts) so the tier-gate 402 is reachable through a REAL create_deploy({private:true}) call — previously only reachable via an `x-mock-tier` header the InstantClient never sends, leaving the agent_action surfacing path unexercised end-to-end. Tier now derives from the bearer (header override still wins for backward-compat). Wire both new files into `npm test`/`test:nocov` and the coverage.yml lcov step. No src/ changes (diff-cover patch gate trivially satisfied). Real-backend MUTATING MCP flows still depend on the W0 skip-cohort guard (matrix §3.W0) and stay STAGING-only — noted in the test header. Gate: build + npm test green (390 tests, was 373). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/coverage.yml | 2 + package.json | 4 +- test/mock-api.ts | 48 +++++-- test/tool-contract.test.ts | 247 +++++++++++++++++++++++++++++++++ test/tool-coverage.test.ts | 166 ++++++++++++++++++++++ 5 files changed, 453 insertions(+), 14 deletions(-) create mode 100644 test/tool-contract.test.ts create mode 100644 test/tool-coverage.test.ts diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 6c3c315..e86af59 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -58,6 +58,8 @@ jobs: dist-test/test/client-unit.test.js \ dist-test/test/index-unit.test.js \ dist-test/test/tools-unit.test.js \ + dist-test/test/tool-coverage.test.js \ + dist-test/test/tool-contract.test.js \ dist-test/test/env-regex-unit.test.js \ dist-test/test/input-hardening-unit.test.js - uses: actions/setup-python@v6 diff --git a/package.json b/package.json index 53f5eb8..f301158 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,8 @@ "dev": "tsc --watch", "start": "node dist/index.js", "pretest": "tsc && tsc -p tsconfig.test.json", - "test": "node --test --experimental-test-coverage --test-coverage-exclude='dist-test/test/**' --test-coverage-exclude='dist/**' --test-coverage-exclude='node_modules/**' dist-test/test/integration.test.js dist-test/test/live-smoke.test.js dist-test/test/client-unit.test.js dist-test/test/index-unit.test.js dist-test/test/tools-unit.test.js dist-test/test/env-regex-unit.test.js dist-test/test/input-hardening-unit.test.js", - "test:nocov": "node --test dist-test/test/integration.test.js dist-test/test/live-smoke.test.js dist-test/test/client-unit.test.js dist-test/test/index-unit.test.js dist-test/test/tools-unit.test.js dist-test/test/env-regex-unit.test.js dist-test/test/input-hardening-unit.test.js", + "test": "node --test --experimental-test-coverage --test-coverage-exclude='dist-test/test/**' --test-coverage-exclude='dist/**' --test-coverage-exclude='node_modules/**' dist-test/test/integration.test.js dist-test/test/live-smoke.test.js dist-test/test/client-unit.test.js dist-test/test/index-unit.test.js dist-test/test/tools-unit.test.js dist-test/test/tool-coverage.test.js dist-test/test/tool-contract.test.js dist-test/test/env-regex-unit.test.js dist-test/test/input-hardening-unit.test.js", + "test:nocov": "node --test dist-test/test/integration.test.js dist-test/test/live-smoke.test.js dist-test/test/client-unit.test.js dist-test/test/index-unit.test.js dist-test/test/tools-unit.test.js dist-test/test/tool-coverage.test.js dist-test/test/tool-contract.test.js dist-test/test/env-regex-unit.test.js dist-test/test/input-hardening-unit.test.js", "test:smoke": "bash test.sh", "prepublishOnly": "npm run build" }, diff --git a/test/mock-api.ts b/test/mock-api.ts index 18388a9..d66c984 100644 --- a/test/mock-api.ts +++ b/test/mock-api.ts @@ -95,6 +95,22 @@ export const BAD_TOKEN = "test-bearer-revoked"; */ export const PAT_TOKEN = "test-bearer-pat-pro-tier"; +/** + * A valid bearer token whose plan tier is "hobby". + * + * Used to exercise the agent-facing tier-gate (402) error mapping END-TO-END + * through a real MCP tool call. The hobby plan cannot create private deploys + * (private deploys require Pro+), so a `create_deploy` with `private: true` + * authenticated as HOBBY_TOKEN returns 402 `tier_upgrade_required` carrying an + * `agent_action` + `upgrade_url`. Before this fixture the only way the mock + * produced that 402 was an `x-mock-tier: hobby` request header the real + * InstantClient never sends — so the agent_action surfacing path was unreachable + * through an actual tool invocation. Keying it off the bearer makes the 402 path + * reachable the same way prod reaches it (the api derives tier from the team + * behind the token). + */ +export const HOBBY_TOKEN = "test-bearer-hobby-tier"; + export interface MockApiHandle { /** Base URL the MCP server should be pointed at (INSTANODE_API_URL). */ url: string; @@ -149,7 +165,7 @@ function sendJSON(res: ServerResponse, status: number, payload: unknown): void { } /** Classify the inbound Authorization header. */ -type AuthState = "anonymous" | "valid" | "pat" | "bad"; +type AuthState = "anonymous" | "valid" | "pat" | "hobby" | "bad"; function classifyAuth(req: IncomingMessage): AuthState { const h = req.headers["authorization"]; if (!h) return "anonymous"; @@ -157,6 +173,7 @@ function classifyAuth(req: IncomingMessage): AuthState { if (!m) return "bad"; if (m[1] === VALID_TOKEN) return "valid"; if (m[1] === PAT_TOKEN) return "pat"; + if (m[1] === HOBBY_TOKEN) return "hobby"; return "bad"; } @@ -218,8 +235,8 @@ function provisionResponse( ): Record { const id = randomUUID(); const token = randomUUID(); - const paid = auth === "valid" || auth === "pat"; - const tier = paid ? "pro" : "anonymous"; + const paid = auth === "valid" || auth === "pat" || auth === "hobby"; + const tier = paid ? (auth === "hobby" ? "hobby" : "pro") : "anonymous"; const resource: MockResource = { id, token, @@ -418,10 +435,11 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P return; } - // Any authenticated session — covers session JWTs *and* PATs. The /api/v1/auth/api-keys - // route is the one exception (it requires a session, not a PAT) and handles that - // distinction in its own branch below. - const authed = auth === "valid" || auth === "pat"; + // Any authenticated session — covers session JWTs *and* PATs (any plan tier, + // including the hobby fixture). The /api/v1/auth/api-keys route is the one + // exception (it requires a session, not a PAT) and handles that distinction in + // its own branch below. + const authed = auth === "valid" || auth === "pat" || auth === "hobby"; // ── POST /claim ──────────────────────────────────────────────────────────── // Per openapi.json: returns 200 ClaimResponse {ok, team_id, user_id, session_token, @@ -617,10 +635,16 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P } const isPrivate = fields["private"] === "true"; - // The mock treats the valid token as Pro tier, so private deploys are - // allowed. A dedicated test flips this via the x-mock-tier override below. + // Tier resolution order: + // 1. explicit `x-mock-tier` request header (legacy test override), else + // 2. the tier behind the bearer — HOBBY_TOKEN → "hobby", any other valid + // paid token → "pro". This makes the private-deploy 402 reachable via a + // real `create_deploy({private:true})` call authenticated as HOBBY_TOKEN + // (the InstantClient never sends `x-mock-tier`), mirroring how prod + // derives the tier from the team behind the token. const tierOverride = req.headers["x-mock-tier"]; - const effectiveTier = (Array.isArray(tierOverride) ? tierOverride[0] : tierOverride) ?? "pro"; + const headerTier = Array.isArray(tierOverride) ? tierOverride[0] : tierOverride; + const effectiveTier = headerTier ?? (auth === "hobby" ? "hobby" : "pro"); if (isPrivate && effectiveTier === "hobby") { sendJSON( res, @@ -938,8 +962,8 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P return; } - const paid = auth === "valid" || auth === "pat"; - const tier = paid ? "pro" : "anonymous"; + const paid = auth === "valid" || auth === "pat" || auth === "hobby"; + const tier = paid ? (auth === "hobby" ? "hobby" : "pro") : "anonymous"; const stackId = `stk-${randomUUID().slice(0, 8)}`; const env = fields["env"] && fields["env"].length > 0 ? fields["env"] : "development"; diff --git a/test/tool-contract.test.ts b/test/tool-contract.test.ts new file mode 100644 index 0000000..d8039fd --- /dev/null +++ b/test/tool-contract.test.ts @@ -0,0 +1,247 @@ +/** + * Contract / agent-facing error-mapping tests for the MCP gap tools. + * + * Companion to test/tools-unit.test.ts (success paths) and test/index-unit.test.ts + * (pure formatError unit). This file fills the matrix §2 gap "MCP: J2-J9,J11, + * J14-J19 live (error + contract)" at the integration layer: each test drives a + * REAL tool handler against the mock api and asserts (a) it hits the correct J-row + * endpoint with the correct payload shape, and (b) the api's error envelope is + * mapped into the exact agent-facing block the LLM reads aloud. + * + * Agents are the PRIMARY consumers of instanode (CLAUDE.md), so the agent-facing + * error surface — the `Action:` / `Upgrade:` / `Claim:` block and the 401/404 + * headlines — is itself a P0 contract, not cosmetic text. + * + * SCOPE NOTE (matrix W0 dependency): these run hermetically against the in-process + * mock api (test/mock-api.ts). Real-backend MUTATING flows (live provision/deploy + * against staging/prod) depend on the W0 backend skip-cohort guard + * (USER-FLOW-INVENTORY-AND-TEST-MATRIX.md §3.W0) and are intentionally NOT run + * here — until that guard lands, live mutating MCP runs target STAGING only. The + * read-only live smoke lives in test/live-smoke.test.ts. + */ + +import { strict as assert } from "node:assert"; +import { gzipSync } from "node:zlib"; +import { after, afterEach, before, describe, it } from "node:test"; + +import { + startMockApi, + VALID_TOKEN, + HOBBY_TOKEN, + type MockApiHandle, +} from "./mock-api.js"; + +// Keep the side-effecting `await server.connect(transport)` off when index is +// imported (same flag the other in-process suites use). +process.env["INSTANODE_MCP_NO_LISTEN"] = "1"; + +let mock: MockApiHandle; +let server: any; + +function handlerFor(name: string): (args: any, extra?: any) => Promise { + const reg = (server as any)._registeredTools as Record; + const t = reg[name]; + if (!t) throw new Error(`tool not registered: ${name}`); + return t.handler as any; +} + +function tarballBase64(): string { + return gzipSync(Buffer.from("FROM scratch\n")).toString("base64"); +} + +function flat(callResult: any): string { + if (!callResult || !callResult.content) return ""; + return callResult.content.map((c: any) => c.text ?? "").join("\n"); +} + +// A syntactically-valid UUID that the mock has never minted → exercises the +// "not on your team / not found" 404 (matrix J11/J17 cross-team-404 contract: +// the api returns an indistinguishable 404 whether the row is on another team +// or absent — that indistinguishability IS the isolation guarantee). +const UNKNOWN_UUID = "00000000-0000-4000-8000-000000000000"; + +before(async () => { + mock = await startMockApi(); + process.env["INSTANODE_API_URL"] = mock.url; + delete process.env["INSTANODE_TOKEN"]; + const mod: any = await import("../src/index.js"); + server = mod.server; +}); + +after(async () => { + await mock.close(); +}); + +afterEach(() => { + delete process.env["INSTANODE_TOKEN"]; +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 402 over-limit → agent_action (matrix J13 "tier gate", agent-facing P0) +// +// This is the contract gap the dedicated mock fixture (HOBBY_TOKEN) was added +// for: before it, the mock's tier-gate 402 was only reachable via an +// `x-mock-tier` request header the InstantClient never sends, so the END-TO-END +// agent_action surfacing path (tool → client → ApiError → formatError) was +// never exercised. Now a real create_deploy({private:true}) on a hobby bearer +// reaches it the same way prod does. +// ─────────────────────────────────────────────────────────────────────────── +describe("agent-facing error mapping — 402 tier-gate surfaces agent_action verbatim", () => { + it("create_deploy({private:true}) on hobby tier → 402 maps to Action + Upgrade block", async () => { + process.env["INSTANODE_TOKEN"] = HOBBY_TOKEN; + const res = await handlerFor("create_deploy")({ + tarball_base64: tarballBase64(), + name: "hobby-private-app", + private: true, + allowed_ips: ["203.0.113.42/32"], + }); + const text = flat(res); + // Headline carries the 402 + the api error code. + assert.match(text, /402 tier_upgrade_required/); + assert.match(text, /private deploys require Pro tier or higher/); + // The agent_action sentence the platform copy-edited for the LLM is + // surfaced VERBATIM under an "Action:" label (rule 12 / FIX-E #C7). + assert.match(text, /\nAction: .*upgrade.*pricing/i); + // The upgrade URL is surfaced so the agent can hand the user a live CTA. + assert.match(text, /\nUpgrade: https:\/\/instanode\.dev\/pricing/); + // It did NOT create a deployment — a tier-gate is a hard stop. + assert.equal( + mock.liveDeployments().some((d) => (d.env["_name"] ?? "") === "hobby-private-app"), + false, + "402 tier-gate must not leave a half-created deployment" + ); + }); + + it("create_deploy({private:true}) on PRO tier → succeeds (negative control for the gate)", async () => { + process.env["INSTANODE_TOKEN"] = VALID_TOKEN; + const res = await handlerFor("create_deploy")({ + tarball_base64: tarballBase64(), + name: "pro-private-app", + private: true, + allowed_ips: ["203.0.113.42/32"], + }); + const text = flat(res); + assert.doesNotMatch(text, /tier_upgrade_required/); + assert.match(text, /Deployment accepted/); + assert.match(text, /Private:\s+true/); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 401 auth (matrix J11/J13/J14 — auth-required tools) +// ─────────────────────────────────────────────────────────────────────────── +describe("agent-facing error mapping — 401 on auth-required tools points at the dashboard", () => { + it("create_deploy with a revoked bearer → 401 headline + dashboard CTA", async () => { + process.env["INSTANODE_TOKEN"] = "definitely-not-a-real-token"; + const res = await handlerFor("create_deploy")({ + tarball_base64: tarballBase64(), + name: "no-auth-deploy", + }); + const text = flat(res); + assert.match(text, /401 unauthorized/i); + assert.match(text, /instanode\.dev\/dashboard/); + }); + + it("delete_resource with a revoked bearer → 401 headline + dashboard CTA", async () => { + process.env["INSTANODE_TOKEN"] = "definitely-not-a-real-token"; + const res = await handlerFor("delete_resource")({ token: UNKNOWN_UUID }); + const text = flat(res); + assert.match(text, /401 unauthorized/i); + assert.match(text, /instanode\.dev\/dashboard/); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 404 not-found / cross-team isolation (matrix J11 + J17) +// ─────────────────────────────────────────────────────────────────────────── +describe("agent-facing error mapping — 404 (cross-team / absent) maps to a clean not_found", () => { + it("get_deployment for an id not on the caller's team → 404 not_found", async () => { + process.env["INSTANODE_TOKEN"] = VALID_TOKEN; + const res = await handlerFor("get_deployment")({ id: UNKNOWN_UUID }); + const text = flat(res); + assert.match(text, /404 not_found/); + assert.match(text, /deployment not found/); + }); + + it("delete_resource for a token not on the caller's team → 404 not_found", async () => { + process.env["INSTANODE_TOKEN"] = VALID_TOKEN; + const res = await handlerFor("delete_resource")({ token: UNKNOWN_UUID }); + const text = flat(res); + assert.match(text, /404 not_found/); + assert.match(text, /resource not found/); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// Endpoint contract — each gap tool reaches the correct J-row endpoint and +// round-trips the documented response shape. A successful, shape-correct +// response proves the right (method, path) was hit (the mock routes strictly +// by method+path and 404s any unmatched route). +// ─────────────────────────────────────────────────────────────────────────── +describe("endpoint contract — gap tools hit the correct J-row endpoint + shape", () => { + it("J5 create_queue → POST /queue/new, returns a queue token + claim block (anon)", async () => { + const before = mock.provisionCount(); + const res = await handlerFor("create_queue")({ name: "ctr-queue" }); + const text = flat(res); + assert.equal(mock.provisionCount(), before + 1, "create_queue did not hit /queue/new"); + assert.match(text, /Token:/); + assert.match(text, /Claim URL/i); + }); + + it("J6 create_storage → POST /storage/new, surfaces the isolation mode", async () => { + const before = mock.provisionCount(); + const res = await handlerFor("create_storage")({ name: "ctr-storage" }); + const text = flat(res); + assert.equal(mock.provisionCount(), before + 1, "create_storage did not hit /storage/new"); + assert.match(text, /Token:/); + }); + + it("J7 create_webhook → POST /webhook/new, returns a receiver token", async () => { + const before = mock.provisionCount(); + const res = await handlerFor("create_webhook")({ name: "ctr-hook" }); + const text = flat(res); + assert.equal(mock.provisionCount(), before + 1, "create_webhook did not hit /webhook/new"); + assert.match(text, /Token:/); + }); + + it("J14 create_stack → POST /stacks/new, returns a stack_id (anon, multi-service)", async () => { + const before = mock.stackCount(); + const res = await handlerFor("create_stack")({ + name: "ctr-stack", + manifest: "services:\n app:\n build: .\n port: 8080\n expose: true\n", + service_tarballs: { app: tarballBase64() }, + }); + const text = flat(res); + assert.equal(mock.stackCount(), before + 1, "create_stack did not hit /stacks/new"); + assert.match(text, /Stack ID:\s+stk-[0-9a-f]{8}/); + assert.match(text, /Claim URL/i, "anon stack must surface the upgrade/claim block"); + }); + + it("J15 get_stack → GET /stacks/:slug, returns the stack status for polling", async () => { + // Create a stack first so there is a stack_id to fetch. + const created = flat( + await handlerFor("create_stack")({ + name: "ctr-stack-poll", + manifest: "services:\n web:\n build: .\n port: 8080\n expose: true\n", + service_tarballs: { web: tarballBase64() }, + }) + ); + const idMatch = /Stack ID:\s+(stk-[0-9a-f]{8})/.exec(created); + assert.ok(idMatch, `could not extract a stack_id from create_stack output:\n${created}`); + const res = await handlerFor("get_stack")({ stack_id: idMatch[1] }); + const text = flat(res); + // A non-error, populated response proves GET /stacks/:slug was reached and + // the build auto-flipped building → healthy on poll. + assert.match(text, new RegExp(`Stack ${idMatch[1]}`)); + assert.match(text, /Status:\s+healthy/); + }); + + it("J16 list_deployments → GET /api/v1/deployments, team-scoped list", async () => { + process.env["INSTANODE_TOKEN"] = VALID_TOKEN; + const res = await handlerFor("list_deployments")({}); + const text = flat(res); + // Either a populated list or the empty sentinel — both prove the endpoint + // was reached and the response was mapped (not an error). + assert.match(text, /deployment\(s\) on this team:|No deployments on this team yet/); + }); +}); diff --git a/test/tool-coverage.test.ts b/test/tool-coverage.test.ts new file mode 100644 index 0000000..376ff38 --- /dev/null +++ b/test/tool-coverage.test.ts @@ -0,0 +1,166 @@ +/** + * DONE-BAR / DRIFT-GUARD test for the MCP tool registry. + * + * Spec: docs/sessions/2026-06-04/USER-FLOW-INVENTORY-AND-TEST-MATRIX.md §4.2 — + * "MCP-tool-coverage test (mcp/test/tool-coverage.test.ts, NEW): iterate the + * live server.tool(...) registry; fail if any tool name lacks a J-row mapping + * + a live or unit test." + * + * This is a REGISTRY-ITERATING test (reliability rule 18): it walks the live + * `server._registeredTools` map rather than a hand-typed slice, so it CANNOT + * silently miss a tool. It fails CI the moment someone: + * + * (a) registers a new `server.tool(...)` without adding a J-row mapping to the + * MAPPED_TOOLS registry below (the §1 flow-inventory drift guard), OR + * (b) registers a new tool whose schema/handler is malformed, OR + * (c) registers a new tool with no test exercising it anywhere in test/*.test.ts + * (the "every tool has a live or unit test" half of the done-bar). + * + * It also reds if a J-row mapping points at a tool that no longer exists (stale + * mapping after a rename/removal) — both directions of drift are caught. + * + * No network, no mock-api: this asserts structure + cross-references the test + * sources on disk. It is deliberately the cheapest test in the suite. + */ + +import { strict as assert } from "node:assert"; +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { before, describe, it } from "node:test"; + +// Keep the side-effecting `await server.connect(transport)` off when index.js +// is imported (same flag the other in-process suites use). +process.env["INSTANODE_MCP_NO_LISTEN"] = "1"; + +/** + * The canonical J-row mapping from the flow matrix §1.J. Each registered MCP + * tool MUST appear here with its backing endpoint + flow ID. This is the single + * source the drift guard compares the live registry against — adding a tool to + * src/index.ts without a row here reds the build (and vice-versa). + */ +interface ToolMapping { + /** Flow-inventory ID (matrix §1.J). */ + flow: string; + /** Backing api endpoint (matrix "Backing endpoint" column). */ + endpoint: string; +} + +const MAPPED_TOOLS: Record = { + create_postgres: { flow: "J1", endpoint: "POST /db/new" }, + create_vector: { flow: "J2", endpoint: "POST /vector/new" }, + create_cache: { flow: "J3", endpoint: "POST /cache/new" }, + create_nosql: { flow: "J4", endpoint: "POST /nosql/new" }, + create_queue: { flow: "J5", endpoint: "POST /queue/new" }, + create_storage: { flow: "J6", endpoint: "POST /storage/new" }, + create_webhook: { flow: "J7", endpoint: "POST /webhook/new" }, + claim_resource: { flow: "J8", endpoint: "helper (builds /start URL)" }, + claim_token: { flow: "J9", endpoint: "POST /claim" }, + list_resources: { flow: "J10", endpoint: "GET /api/v1/resources" }, + delete_resource: { flow: "J11", endpoint: "DELETE /api/v1/resources/:id" }, + get_api_token: { flow: "J12", endpoint: "POST /api/v1/auth/api-keys" }, + create_deploy: { flow: "J13", endpoint: "POST /deploy/new" }, + create_stack: { flow: "J14", endpoint: "POST /stacks/new" }, + get_stack: { flow: "J15", endpoint: "GET /api/v1/stacks/:slug" }, + list_deployments: { flow: "J16", endpoint: "GET /api/v1/deployments" }, + get_deployment: { flow: "J17", endpoint: "GET /api/v1/deployments/:id" }, + redeploy: { flow: "J18", endpoint: "POST /deploy/:id/redeploy" }, + delete_deployment: { flow: "J19", endpoint: "DELETE /deploy/:id" }, +}; + +let registry: Record; +let registeredNames: string[]; + +/** + * Read every TS test source so we can prove each tool is exercised by at least + * one test. We scan the .ts sources (not the compiled dist-test/*.js) because + * they are the human-authored intent and survive a `tsc` clean. Excludes THIS + * file so a tool isn't "covered" merely by being listed in MAPPED_TOOLS. + */ +function loadTestSources(): string { + const here = dirname(fileURLToPath(import.meta.url)); + // import.meta.url is dist-test/test/tool-coverage.test.js → repo test/ dir is + // ../../test relative to here. Fall back to /test (CI runs from root). + const candidates = [join(here, "..", "..", "test"), join(process.cwd(), "test")]; + for (const dir of candidates) { + try { + const files = readdirSync(dir).filter( + (f) => f.endsWith(".test.ts") && f !== "tool-coverage.test.ts" + ); + if (files.length === 0) continue; + return files.map((f) => readFileSync(join(dir, f), "utf8")).join("\n"); + } catch { + // try next candidate + } + } + throw new Error("could not locate test/*.test.ts sources for coverage cross-reference"); +} + +before(async () => { + const mod: any = await import("../src/index.js"); + registry = mod.server._registeredTools; + registeredNames = Object.keys(registry); +}); + +describe("MCP tool-coverage done-bar (drift guard, matrix §4.2)", () => { + it("registers exactly 19 tools (sanity vs matrix §1.J)", () => { + assert.equal( + registeredNames.length, + 19, + `expected 19 registered tools, got ${registeredNames.length}: ${registeredNames.join(", ")}` + ); + }); + + it("every registered tool has a J-row mapping in MAPPED_TOOLS", () => { + const unmapped = registeredNames.filter((n) => !(n in MAPPED_TOOLS)); + assert.deepEqual( + unmapped, + [], + `new tool(s) registered with no flow-matrix J-row mapping — add them to ` + + `MAPPED_TOOLS (and to the matrix §1.J + a test): ${unmapped.join(", ")}` + ); + }); + + it("every MAPPED_TOOLS entry corresponds to a live registered tool (no stale mappings)", () => { + const stale = Object.keys(MAPPED_TOOLS).filter((n) => !registeredNames.includes(n)); + assert.deepEqual( + stale, + [], + `MAPPED_TOOLS lists tool(s) that are no longer registered (rename/removal ` + + `drift) — fix the mapping: ${stale.join(", ")}` + ); + }); + + it("every registered tool exposes a callable handler + an input schema + a description", () => { + for (const name of registeredNames) { + const t = registry[name]; + assert.equal(typeof t.handler, "function", `${name}: missing callable handler`); + assert.ok(t.inputSchema !== undefined, `${name}: missing inputSchema`); + assert.equal( + typeof t.description, + "string", + `${name}: missing description (agents read this to choose the tool)` + ); + assert.ok( + (t.description as string).length > 0, + `${name}: empty description` + ); + } + }); + + it("every registered tool is exercised by at least one test in test/*.test.ts", () => { + const sources = loadTestSources(); + const untested = registeredNames.filter((name) => { + // A tool is "tested" if its name appears as a quoted string anywhere in + // the test sources — handlerFor("name"), an integration tools/call with + // name:"name", or a client-method assertion referencing its endpoint. + return !sources.includes(`"${name}"`); + }); + assert.deepEqual( + untested, + [], + `tool(s) registered with NO test exercising them (done-bar violation) — ` + + `add a tools-unit/integration test: ${untested.join(", ")}` + ); + }); +});