137 lines
7.1 KiB
JavaScript
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;
|