// Verifies the public MCP contract with the official SDK client: server identity, the exact tool set, // representative read calls, domain errors and schema validation. It is safe against an empty database. // A module's tool is called with {} when its input accepts it; otherwise only discovery is checked. // --full also checks successful report and story reads, which need a populated site. // node scripts/mcp-check.ts [url] [--full] import { Client } from "@modelcontextprotocol/client"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; import { PUBLIC_INTERFACE_VERSION } from "@aihot/contracts/http-policy"; import { MCP_TOOL_NAMES as T, MCP_TOOLS, mcpToolName } from "@aihot/contracts/mcp"; import { SITE } from "@aihot/site"; import { SERVER_MODULES } from "@aihot/site/modules/server"; const args = process.argv.slice(2); const full = args.includes("--full"); const url = new URL(args.find((a) => !a.startsWith("--")) ?? "http://127.0.0.1:3001/api/mcp"); /** The site's modules' tools, after the engine's. */ const moduleTools = SERVER_MODULES.flatMap((m) => m.agent?.abilities ?? []).map((a) => ({ name: mcpToolName(a.mcp.tool), input: a.mcp.input, })); const client = new Client({ name: `${SITE.mcpPrefix}-mcp-check`, version: "1.0.0" }); function firstText(result: Awaited>): string { return (result.content as Array<{ type: string; text?: string }>)[0]?.text ?? ""; } function errorCode(result: Awaited>): string | null { const structured = result.structuredContent as { error?: { code?: unknown } } | undefined; return typeof structured?.error?.code === "string" ? structured.error.code : null; } async function call( name: string, args: Record, expect: { error?: boolean; code?: string } = {}, ) { const result = await client.callTool({ name, arguments: args }); const text = firstText(result).replace(/\n/g, " ").slice(0, 140); const gotError = result.isError === true; console.log(`${name} ${JSON.stringify(args)} → ${gotError ? "ERROR" : "ok"} | ${text}`); if (gotError !== (expect.error ?? false)) { throw new Error(`${name} expected ${expect.error ? "an error" : "success"}, got ${gotError ? "an error" : "success"}`); } if (expect.code && errorCode(result) !== expect.code) { throw new Error(`${name} expected error code ${expect.code}, got ${errorCode(result) ?? "(none)"}`); } if (!gotError && (!text || !result.structuredContent)) throw new Error(`${name} returned no readable or no structured result`); return result; } await client.connect(new StreamableHTTPClientTransport(url)); try { const info = client.getServerVersion?.(); console.log("server:", JSON.stringify(info)); if (info?.name !== SITE.mcpPrefix) { throw new Error(`server name mismatch: expected ${SITE.mcpPrefix}, got ${info?.name ?? "(missing)"}`); } if (info?.version !== PUBLIC_INTERFACE_VERSION) { throw new Error(`server version mismatch: expected ${PUBLIC_INTERFACE_VERSION}, got ${info?.version ?? "(missing)"}`); } const listed = await client.listTools(); const actualNames = listed.tools.map((tool) => tool.name).sort(); const expectedNames = [...MCP_TOOLS.map((tool) => tool.name), ...moduleTools.map((tool) => tool.name)].sort(); console.log("tools:", actualNames.join(", ")); if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) { throw new Error(`tool list mismatch: expected [${expectedNames.join(", ")}], got [${actualNames.join(", ")}]`); } // These reads are valid on a fresh site: an empty result set is still a successful public answer. await call(T.latest, { limit: 2 }); await call(T.search, { q: "OpenAI", limit: 2 }); await call(T.hot, { limit: 3 }); for (const tool of moduleTools) { if ((await tool.input.safeParseAsync({})).success) await call(tool.name, {}); else console.log(`${tool.name} → discovery only (input rejects {})`); } // Exercise the remaining tools without depending on seeded reports/stories. await call(T.daily, { date: "2026-02-30" }, { error: true, code: "invalid_request" }); await call(T.weekly, { week: "2026-W54" }, { error: true, code: "invalid_request" }); await call(T.monthly, { month: "2026-13" }, { error: true, code: "invalid_request" }); await call(T.story, { public_id: "__mcp_check_missing__", report_limit: 3 }, { error: true, code: "not_found" }); // SDK/server schema validation must reject values outside the advertised input contract. await call(T.latest, { limit: 99 }, { error: true }); if (full) { for (const name of [T.daily, T.weekly, T.monthly]) await call(name, {}); const hot = await call(T.hot, { limit: 1 }); const storyId = ((hot.structuredContent as { items?: Array<{ links?: { story?: string } }> }).items?.[0]?.links?.story ?? "").split("/").pop(); if (!storyId) throw new Error("--full needs a populated public story"); await call(T.story, { public_id: storyId, report_limit: 3 }); } console.log("MCP contract check passed"); } finally { await client.close(); }