/** * End-to-end smoke test for the management MCP tools. * * Drives the actual `/api/mcp` HTTP endpoint (requires a running * Next.js dev server with the same env this script reads) and * exercises every CRUD tool we added for the Hermes agent support: * * companies.list / get / create / update / delete * customers.list / get / create / update / delete * suppliers.list / get / create / update / delete * * The test creates rows via `*.create`, mutates them via `*.update`, * reads them back via `*.get` (asserting the relations resolve), and * cleans up via `*.delete`. All names are prefixed with `MCP-SMOKE-…` * so any orphans are obvious in the PB admin. * * Run with: * # In one terminal: npm run dev * # In another: npx tsx --env-file=.env.local scripts/smoke/mcp-management.ts */ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; function loadEnvFile(name: string) { try { const raw = readFileSync(resolve(process.cwd(), name), "utf8"); for (const line of raw.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i); if (m && !process.env[m[1]]) { process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim(); } } } catch { /* fine */ } } loadEnvFile(".env"); loadEnvFile(".env.local"); const RUN_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; const CO_NAME = `MCP-SMOKE-CO-${RUN_ID}`; const CU_NAME = `MCP-SMOKE-CU-${RUN_ID}`; const SU_NAME = `MCP-SMOKE-SU-${RUN_ID}`; const PORT = Number(process.env.PORT ?? 3000); const BASE = `http://127.0.0.1:${PORT}`; interface McpResponse { jsonrpc: "2.0"; id: number | null; result?: { content: { type: string; text: string }[] }; error?: { code: number; message: string }; } async function mcp( id: number, method: "tools/list" | "tools/call", params?: { name: string; arguments: Record } ): Promise { const body: Record = { jsonrpc: "2.0", id, method }; if (params) body.params = params; const res = await fetch(`${BASE}/api/mcp`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`, }, body: JSON.stringify(body), }); const json = (await res.json()) as McpResponse; if (json.error) { throw new Error(`MCP ${method} ${params?.name ?? ""} failed: ${json.error.message}`); } if (!json.result) { throw new Error(`MCP ${method} ${params?.name ?? ""} returned no result`); } // All our tools wrap the payload as content[0].text = JSON.stringify(payload). const text = json.result.content[0].text; return JSON.parse(text) as T; } async function main() { if (!process.env.AGENT_API_KEY) { throw new Error("AGENT_API_KEY must be set in env (matches the dev server's)."); } console.log(`[mcp-mgmt] Target: ${BASE}`); console.log(`[mcp-mgmt] Run id: ${RUN_ID}`); const createdIds = { company: "", customer: "", supplier: "" }; try { // ── 1. companies.create (with geoPoint) ───────────────────── const createdCompany = await mcp<{ company: { id: string; company: string; coordinates: { lon: number; lat: number } } }>( 1, "tools/call", { name: "companies.create", arguments: { company: CO_NAME, address: "1 MCP Test Road", vendor: `MCP-V-${RUN_ID}`, coordinates: { lon: 28.0473, lat: -26.2041 }, }, } ); createdIds.company = createdCompany.company.id; console.log( `[mcp-mgmt] companies.create → ${createdCompany.company.company} (${createdCompany.company.id})` ); // ── 2. companies.get (round-trip + coords) ────────────────── const fetchedCompany = await mcp<{ company: { id: string; company: string; coordinates: { lon: number; lat: number } } }>( 2, "tools/call", { name: "companies.get", arguments: { id: createdIds.company } } ); if (fetchedCompany.company.id !== createdIds.company) { throw new Error(`FAIL: company.get id mismatch: ${fetchedCompany.company.id}`); } if (fetchedCompany.company.coordinates.lon !== 28.0473 || fetchedCompany.company.coordinates.lat !== -26.2041) { throw new Error( `FAIL: company coords round-trip wrong: ${JSON.stringify(fetchedCompany.company.coordinates)}` ); } console.log( `[mcp-mgmt] companies.get round-trip OK, coords=${JSON.stringify(fetchedCompany.company.coordinates)}` ); // ── 3. companies.update (rename + new coords) ─────────────── const renamed = `MCP-SMOKE-CO-RENAMED-${RUN_ID}`; const updatedCompany = await mcp<{ company: { company: string } }>( 3, "tools/call", { name: "companies.update", arguments: { id: createdIds.company, patch: { company: renamed, coordinates: { lon: 18.4241, lat: -33.9249 } }, }, } ); if (updatedCompany.company.company !== renamed) { throw new Error(`FAIL: companies.update didn't rename: got ${updatedCompany.company.company}`); } console.log(`[mcp-mgmt] companies.update → ${updatedCompany.company.company}`); // Rename back so other assertions stay readable. await mcp(4, "tools/call", { name: "companies.update", arguments: { id: createdIds.company, patch: { company: CO_NAME } }, }); // ── 4. companies.list (search filter finds our row) ───────── const companyList = await mcp<{ items: { id: string }[]; totalItems: number }>( 5, "tools/call", { name: "companies.list", arguments: { search: CO_NAME, perPage: 5 } } ); if (!companyList.items.some((i) => i.id === createdIds.company)) { throw new Error( `FAIL: companies.list missing our row: ${JSON.stringify(companyList.items.map((i) => i.id))}` ); } console.log(`[mcp-mgmt] companies.list found our row via search`); // ── 5. Look up a real user for the rep relation ───────────── // The agents-list lives in the (currently bootstrap-only) // /api/v1/users/lookup endpoint. We can also resolve via the // admin PB directly to keep this script independent of whether // the user lookup endpoint is wired up. const adminLookupRes = await fetch(`${BASE}/api/v1/users/lookup`, { headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` }, }); if (!adminLookupRes.ok) { throw new Error(`users/lookup not OK: ${adminLookupRes.status}`); } const users = (await adminLookupRes.json()) as { id: string; name: string; email: string }[]; if (users.length === 0) { throw new Error("No users in auth collection — can't back the rep relation."); } const repNameOrId = users[0].name || users[0].email; console.log(`[mcp-mgmt] Resolved rep via name: ${repNameOrId}`); // ── 6. customers.create (relations + geoPoint via name) ────── const createdCustomer = await mcp<{ customer: { id: string; company: string; rep: string; coordinates: { lon: number; lat: number } } }>( 6, "tools/call", { name: "customers.create", arguments: { customer: CU_NAME, email: `mcp-cu-${RUN_ID}@example.com`, number: "0123456789", company: CO_NAME, // resolved to id by the service rep: repNameOrId, // resolved to id by the service coordinates: { lon: 18.4241, lat: -33.9249 }, }, } ); createdIds.customer = createdCustomer.customer.id; if (createdCustomer.customer.company !== createdIds.company) { throw new Error( `FAIL: customer.company not resolved: got ${createdCustomer.customer.company}, want ${createdIds.company}` ); } if (!createdCustomer.customer.rep) { throw new Error("FAIL: customer.rep not resolved (empty string)"); } console.log( `[mcp-mgmt] customers.create → ${CU_NAME} (${createdIds.customer}) with company+rep relations resolved` ); // ── 7. customers.get with expand (relation names inline) ─── const fetchedCustomer = await mcp<{ customer: { id: string; expand?: { company?: { company: string }; rep?: { name: string; email: string } } }; }>( 7, "tools/call", { name: "customers.get", arguments: { id: createdIds.customer, expand: "company,rep" } } ); if (fetchedCustomer.customer.expand?.company?.company !== CO_NAME) { throw new Error( `FAIL: customer.expand.company.company mismatch: ${fetchedCustomer.customer.expand?.company?.company}` ); } if (!fetchedCustomer.customer.expand?.rep) { throw new Error("FAIL: customer.expand.rep missing"); } console.log( `[mcp-mgmt] customers.get expand OK: company="${fetchedCustomer.customer.expand.company.company}", rep="${fetchedCustomer.customer.expand.rep.name || fetchedCustomer.customer.expand.rep.email}"` ); // ── 8. customers.update (change coords, keep relations) ──── const updatedCustomer = await mcp<{ customer: { coordinates: { lon: number; lat: number } } }>( 8, "tools/call", { name: "customers.update", arguments: { id: createdIds.customer, patch: { coordinates: { lon: 0, lat: 0 } }, }, } ); // The service treats {0,0} as the default (PB geoPoint has no // null), but the round-trip still has to succeed. if ( updatedCustomer.customer.coordinates.lon !== 0 || updatedCustomer.customer.coordinates.lat !== 0 ) { throw new Error( `FAIL: customers.update coords: ${JSON.stringify(updatedCustomer.customer.coordinates)}` ); } console.log("[mcp-mgmt] customers.update coords round-trip OK"); // ── 9. customers.list (search finds our row) ─────────────── const customerList = await mcp<{ items: { id: string }[] }>( 9, "tools/call", { name: "customers.list", arguments: { search: CU_NAME, perPage: 5 } } ); if (!customerList.items.some((i) => i.id === createdIds.customer)) { throw new Error("FAIL: customers.list missing our row"); } console.log("[mcp-mgmt] customers.list found our row via search"); // ── 10. suppliers.create (text coords — unchanged) ─────────── const createdSupplier = await mcp<{ supplier: { id: string; suppliername: string; coordinates: string } }>( 10, "tools/call", { name: "suppliers.create", arguments: { suppliername: SU_NAME, email: `mcp-su-${RUN_ID}@example.com`, suppliernumber: `MCP-S-${RUN_ID}`, coordinates: "-33.9249, 18.4241", }, } ); createdIds.supplier = createdSupplier.supplier.id; console.log(`[mcp-mgmt] suppliers.create → ${SU_NAME} (${createdIds.supplier})`); // ── 11. suppliers.get + update + list ─────────────────────── const fetchedSupplier = await mcp<{ supplier: { suppliername: string } }>( 11, "tools/call", { name: "suppliers.get", arguments: { id: createdIds.supplier } } ); if (fetchedSupplier.supplier.suppliername !== SU_NAME) { throw new Error(`FAIL: suppliers.get name mismatch`); } await mcp(12, "tools/call", { name: "suppliers.update", arguments: { id: createdIds.supplier, patch: { closingtime: "16:30" } }, }); const supplierList = await mcp<{ items: { id: string }[] }>( 13, "tools/call", { name: "suppliers.list", arguments: { search: SU_NAME, perPage: 5 } } ); if (!supplierList.items.some((i) => i.id === createdIds.supplier)) { throw new Error("FAIL: suppliers.list missing our row"); } console.log("[mcp-mgmt] suppliers CRUD round-trip OK"); // ── 12. Cleanup via delete tools ──────────────────────────── // Delete order matters for cascade rules: customer before company. await mcp(20, "tools/call", { name: "customers.delete", arguments: { id: createdIds.customer } }); console.log(`[mcp-mgmt] customers.delete → ${createdIds.customer}`); await mcp(21, "tools/call", { name: "suppliers.delete", arguments: { id: createdIds.supplier } }); console.log(`[mcp-mgmt] suppliers.delete → ${createdIds.supplier}`); await mcp(22, "tools/call", { name: "companies.delete", arguments: { id: createdIds.company } }); console.log(`[mcp-mgmt] companies.delete → ${createdIds.company}`); console.log("\n[mcp-mgmt] All 15 MCP management tools exercised successfully."); } catch (err) { // Best-effort cleanup so a mid-run failure doesn't leave rows // in production. Each delete is independently best-effort. console.error("[mcp-mgmt] FAILED, attempting cleanup..."); if (createdIds.customer) { await mcp(99, "tools/call", { name: "customers.delete", arguments: { id: createdIds.customer } }).catch(() => {}); } if (createdIds.supplier) { await mcp(99, "tools/call", { name: "suppliers.delete", arguments: { id: createdIds.supplier } }).catch(() => {}); } if (createdIds.company) { await mcp(99, "tools/call", { name: "companies.delete", arguments: { id: createdIds.company } }).catch(() => {}); } throw err; } } main().catch((err) => { console.error("[mcp-mgmt] FAILED:", err); process.exit(1); });