MEGAINFRA-WEBAPP/scripts/check-connections.mjs

137 lines
7.1 KiB
JavaScript

/** Read-only live API/MCP smoke check. Prints metadata, never credentials or records. */
import { loadEnvFile } from "node:process";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const root = fileURLToPath(new URL("../", import.meta.url));
const envFile = path.resolve(root, process.env.MEGA_CONNECTION_ENV_FILE || ".env.local");
loadEnvFile(envFile);
const argumentsList=process.argv.slice(2);
const remoteIndex=argumentsList.indexOf("--mcp-url");
const remoteUrl=remoteIndex>=0?argumentsList.splice(remoteIndex,2)[1]:undefined;
if(remoteIndex>=0&&!remoteUrl)throw new Error("--mcp-url requires an endpoint URL");
if(remoteUrl){const url=new URL(remoteUrl);if(!["https:","http:"].includes(url.protocol)||url.username||url.password||url.pathname!=="/api/mcp"||url.search||url.hash)throw new Error("Invalid MCP endpoint URL");}
const origins = argumentsList;
if (!origins.length) origins.push("http://127.0.0.1:6004");
for (const origin of origins) {
const url = new URL(origin);
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
throw new Error("Pass app origins only, for example https://mega.dash.apphub.co.za");
}
}
const secrets = Object.entries(process.env)
.filter(([key, value]) => /SECRET|TOKEN|PASSWORD|API_KEY|ADMIN_EMAIL/.test(key) && value?.length >= 6)
.map(([, value]) => value);
const redact = (message) => secrets.reduce((text, value) => text.split(value).join("[redacted]"), String(message));
const report = {
checkedAt: new Date().toISOString(),
timezone: "Africa/Johannesburg",
envFile: path.relative(root, envFile),
localAuthMode: process.env.AUTH_MODE === "users" ? "users" : "pin",
checks: [],
};
function record(check, passed, details = {}) {
report.checks.push({ check, passed, ...details });
}
async function getJson(url, headers) {
const response = await fetch(url, { headers, redirect: "manual", signal: AbortSignal.timeout(30000) });
const text = await response.text();
let body;
try { body = JSON.parse(text); } catch { /* HTML response, e.g. a 404 */ }
return { status: response.status, body, contentType: response.headers.get("content-type") };
}
const transport = remoteUrl ? new StreamableHTTPClientTransport(new URL(remoteUrl),{requestInit:{headers:{Authorization:`Bearer ${process.env.MEGA_MCP_TOKEN||process.env.MEGA_MCP_READER_TOKEN||""}`}}}) : new StdioClientTransport({
command: process.execPath,
args: ["--import", "tsx", `--env-file=${envFile}`, path.join(root, "src/mcp/server.ts")],
cwd: root,
stderr: "pipe",
});
// Drain diagnostics; retain neither business records nor potentially sensitive SDK logs.
if(transport instanceof StdioClientTransport)transport.stderr?.on("data", () => {});
const client = new Client({ name: "mega-connection-check", version: "1.0.0" });
let poNumber;
try {
await client.connect(transport, { timeout: 15000 });
record("MCP initialize", client.getServerVersion()?.name === "mega-infrastructure", { server: client.getServerVersion() });
const { tools } = await client.listTools({}, { timeout: 15000 });
record("MCP tools/list", tools.length > 0, {
toolCount: tools.length,
tools: tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
});
for (const [name, args] of [
["list_sites", {}],
["list_employees", { limit: 1 }],
["list_purchase_orders", { limit: 1 }],
["get_pending_verification", {}],
]) {
try {
const result = await client.callTool({ name, arguments: args }, undefined, { timeout: 45000 });
const raw = result.content?.find((item) => item.type === "text")?.text;
let data;
try { data = JSON.parse(raw); } catch { /* Report invalid JSON as a failure. */ }
const passed = result.isError !== true && data != null && !data.error;
record(`MCP tools/call ${name}`, passed, {
shape: Array.isArray(data) ? "array" : typeof data,
...(Array.isArray(data) ? { rowCount: data.length } : { keys: data ? Object.keys(data) : [] }),
...(!passed ? { error: redact(raw || "No JSON response") } : {}),
});
if (name === "list_purchase_orders" && passed) poNumber = data.purchaseOrders?.[0]?.poNumber;
} catch (error) {
record(`MCP tools/call ${name}`, false, { error: redact(error.message) });
}
}
} catch (error) {
record("MCP connection", false, { error: redact(error.message) });
} finally {
await client.close();
}
const pbBase = process.env.NEXT_PUBLIC_POCKETBASE_URL?.replace(/\/$/, "");
if (pbBase) {
try {
const result = await getJson(`${pbBase}/api/health`);
record("PocketBase health", result.status === 200, { status: result.status });
if (process.env.POCKETBASE_ADMIN_TOKEN) {
const read = await getJson(`${pbBase}/api/collections/sites/records?perPage=1&fields=id`, { Authorization: process.env.POCKETBASE_ADMIN_TOKEN });
record("PocketBase authenticated records GET", read.status === 200 && Array.isArray(read.body?.items), { status: read.status });
}
} catch (error) {
record("PocketBase connection", false, { error: redact(error.message) });
}
}
for (const origin of origins) {
const base = new URL(origin).origin;
const lookupPath = `/api/purchase-orders/by-number/${encodeURIComponent(poNumber || "CONNECTION-CHECK")}/items`;
for (const [route, headers, accept, note] of [
["/api/health", undefined, (r) => r.status === 200 && r.body?.status === "ok", "Public app health"],
["/api/dashboard-attention", undefined, (r) => [200, 401].includes(r.status), "200 means no login was required; 401 means login is required"],
[lookupPath, undefined, (r) => r.status === 401, "Missing ingest secret must be rejected"],
...(process.env.N8N_INGEST_SECRET ? [[lookupPath, { "x-ingest-secret": process.env.N8N_INGEST_SECRET }, (r) => r.status === 200 && Array.isArray(r.body?.items), "Authenticated read-only PO lookup"]] : []),
["/api/mcp", undefined, (r) => [401, 404, 503].includes(r.status), "Unauthenticated MCP probe; initialization is checked separately"],
]) {
try {
const result = await getJson(`${base}${route}`, headers);
record(`${base}: ${note}`, accept(result), {
// A real PO number may be used for the request, but is never persisted.
route: route === lookupPath ? "/api/purchase-orders/by-number/[poNumber]/items" : route,
status: result.status,
contentType: result.contentType,
keys: result.body ? Object.keys(result.body) : [],
...(result.body?.error ? { error: redact(result.body.error) } : {}),
});
} catch (error) {
record(`${base}: ${note}`, false, { error: redact(error.message) });
}
}
}
report.summary = { passed: report.checks.filter((check) => check.passed).length, failed: report.checks.filter((check) => !check.passed).length };
console.log(JSON.stringify(report, null, 2));
if (report.summary.failed) process.exitCode = 1;