Files
siteglass-mcp/server.js
T

191 lines
8.6 KiB
JavaScript

#!/usr/bin/env node
// siteglass MCP server — exposes siteglass.io's web-QA loop as agent tools.
//
// An AI coding agent (Claude Code, Cursor, Claude Desktop, …) drives the
// whole loop over MCP: register the app it just built, prove ownership,
// scan it, derive + run E2E test flows, and read what broke.
//
// Tools return bounded (≤~50s) to stay under MCP client request timeouts:
// long ops (scan, run) return an id and you poll with siteglass_get_scan /
// siteglass_get_run.
//
// Config (env): SITEGLASS_BASE_URL (default https://siteglass.io),
// SITEGLASS_API_KEY (optional — auto-creates + caches one
// at ~/.siteglass/key if absent).
//
// Run: node server.js (stdio transport — how MCP clients launch it)
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
const BASE = (process.env.SITEGLASS_BASE_URL || "https://siteglass.io").replace(/\/$/, "");
const KEY_FILE = path.join(os.homedir(), ".siteglass", "key");
let KEY = process.env.SITEGLASS_API_KEY || null;
function loadKey() {
if (KEY) return KEY;
try { KEY = fs.readFileSync(KEY_FILE, "utf8").trim() || null; } catch {}
return KEY;
}
async function ensureKey() {
if (loadKey()) return KEY;
const r = await fetch(`${BASE}/api/signup`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
KEY = (await r.json()).api_key;
try { fs.mkdirSync(path.dirname(KEY_FILE), { recursive: true }); fs.writeFileSync(KEY_FILE, KEY, { mode: 0o600 }); } catch {}
return KEY;
}
async function api(method, p, body) {
await ensureKey();
const r = await fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", Authorization: "Bearer " + KEY },
body: body ? JSON.stringify(body) : undefined,
});
const txt = await r.text();
let j; try { j = JSON.parse(txt); } catch { j = { ok: false, error: txt.slice(0, 200) }; }
return j;
}
// Poll a status endpoint, but give up after maxMs so the tool call returns
// inside the client's request timeout. Returns the last response seen.
async function poll(p, { maxMs = 50000, ms = 2500 } = {}) {
const deadline = Date.now() + maxMs;
let last = await api("GET", p);
while (Date.now() < deadline && last.ok && last.status !== "DONE" && last.status !== "ERROR") {
await new Promise((res) => setTimeout(res, ms));
last = await api("GET", p);
}
return last;
}
const text = (s) => ({ content: [{ type: "text", text: typeof s === "string" ? s : JSON.stringify(s, null, 2) }] });
function scanSummary(j) {
const f = (j.findings || []).map((x) => ` [${x.kind}/${x.severity}] ${x.title}`).join("\n");
return `crawl_id: ${j.crawl_id}\n${j.page_count} pages (${j.discovered} discovered)\n\n` +
`FINDINGS (${(j.findings || []).length}):\n${f}\n\nREPORT:\n${j.report_md || ""}\n\n` +
`Next: siteglass_generate_flows with crawl_id=${j.crawl_id}.`;
}
function runSummary(j) {
const steps = (j.steps || []).map((s) =>
` [${s.status}] ${s.action}${s.description}` + (s.error ? ` (${s.error})` : "") +
(s.status === "repaired" ? ` [repaired: ${JSON.stringify(s.target)} -> ${s.repaired_to}]` : "")).join("\n");
return `${j.passed}${j.failed}${j.skipped} skipped ${j.repaired} repaired\n${steps}\n\n` +
`replay: ${BASE}${j.rrweb_url}\nvideo: ${BASE}${j.video_url}`;
}
const server = new McpServer({ name: "siteglass", version: "0.1.0" });
server.tool(
"siteglass_register_site",
"Register a web app (its deployed URL) with siteglass and get an ownership-proof token to place on it. Step 1.",
{ url: z.string().describe("The app's URL, e.g. https://myapp.lovable.app") },
async ({ url }) => {
const j = await api("POST", "/api/sites", { domain: url });
if (!j.ok) return text(`Error: ${j.error}`);
const i = j.instructions || {};
return text(`Registered. site_id: ${j.site_id}\n\nProve you control ${j.domain} with ANY ONE of:\n` +
`• Meta tag (easiest for AI-built apps): ${i.meta_tag}\n• Hosted file: ${i.well_known}\n• DNS: ${i.dns_txt}\n\n` +
`Add one (the meta tag goes in the homepage <head>), deploy, then call siteglass_verify_site with site_id=${j.site_id}.`);
}
);
server.tool(
"siteglass_verify_site",
"Check that the ownership-proof token is live on the site. Step 2 (after placing the token).",
{ site_id: z.string() },
async ({ site_id }) => {
const j = await api("POST", `/api/sites/${site_id}/verify`);
if (!j.ok) return text(`Error: ${j.error}`);
return text(j.verified ? `Verified via ${j.method}. Now call siteglass_scan with site_id=${site_id}.`
: `Not verified yet — the token isn't reachable. Place it, redeploy, and retry.`);
}
);
server.tool(
"siteglass_scan",
"Crawl a verified site (first scan per site is free). Returns a crawl_id; if still running, poll with siteglass_get_scan. Step 3.",
{ site_id: z.string() },
async ({ site_id }) => {
const start = await api("POST", `/api/sites/${site_id}/crawl`);
if (!start.ok) return text(`Cannot scan: ${start.error}`);
const j = await poll(`/api/crawls/${start.crawl_id}`);
if (j.status === "DONE") return text(scanSummary(j));
return text(`Scan started. crawl_id: ${start.crawl_id} (status ${j.status || "RUNNING"}). ` +
`Call siteglass_get_scan with crawl_id=${start.crawl_id} in ~20s.`);
}
);
server.tool(
"siteglass_get_scan",
"Read a scan's report + findings by crawl_id (use after siteglass_scan if it was still running).",
{ crawl_id: z.string() },
async ({ crawl_id }) => {
const j = await api("GET", `/api/crawls/${crawl_id}`);
if (!j.ok) return text(`Error: ${j.error}`);
return text(j.status === "DONE" ? scanSummary(j) : `Scan status: ${j.status}. Check again shortly.`);
}
);
server.tool(
"siteglass_generate_flows",
"Derive executable E2E test flows from a scan (LLM). Step 4. Returns flow ids.",
{ crawl_id: z.string() },
async ({ crawl_id }) => {
const j = await api("POST", `/api/crawls/${crawl_id}/flows`);
if (!j.ok) return text(`Error: ${j.error}`);
return text(`Generated ${j.count} flows:\n` +
(j.flows || []).map((x) => ` ${x.id} [${x.destructive ? "destructive" : "safe"}] ${x.name}${x.description}`).join("\n") +
`\n\nRun one with siteglass_run_flow. Destructive flows: full=false dry-runs the final submit; full=true submits for real (a passing Run Full of a sign-up flow auto-saves the new account as this site's test credentials).`);
}
);
server.tool(
"siteglass_run_flow",
"Run one test flow. Returns a run_id; if still running, poll with siteglass_get_run. Step 5.",
{ flow_id: z.string(), full: z.boolean().optional().describe("true = submit destructive actions for real (default false)") },
async ({ flow_id, full }) => {
const start = await api("POST", `/api/flows/${flow_id}/run`, { full: !!full });
if (!start.ok) return text(`Cannot run: ${start.error}`);
const j = await poll(`/api/flow-runs/${start.run_id}`);
if (j.status === "DONE") return text(runSummary(j));
return text(`Run started. run_id: ${start.run_id} (status ${j.status || "RUNNING"}). ` +
`Call siteglass_get_run with run_id=${start.run_id} in ~20s.`);
}
);
server.tool(
"siteglass_get_run",
"Read a flow run's per-step results + capture URLs by run_id (use after siteglass_run_flow if still running).",
{ run_id: z.string() },
async ({ run_id }) => {
const j = await api("GET", `/api/flow-runs/${run_id}`);
if (!j.ok) return text(`Error: ${j.error}`);
return text(j.status === "DONE" ? runSummary(j) : `Run status: ${j.status}. Check again shortly.`);
}
);
server.tool(
"siteglass_set_credentials",
"Provide a test account (username/password) so authenticated flows can log in to the site.",
{ site_id: z.string(), username: z.string(), password: z.string() },
async ({ site_id, username, password }) => {
const j = await api("POST", `/api/sites/${site_id}/credentials`, { vars: { username, password } });
return text(j.ok ? "Test credentials saved." : `Error: ${j.error}`);
}
);
server.tool(
"siteglass_feedback",
"Send feedback to the siteglass team (bugs, missing features, anything). Works for agents and people alike.",
{ message: z.string(), context: z.string().optional() },
async ({ message, context }) => {
const j = await api("POST", "/api/feedback", { message, context });
return text(j.ok ? "Thanks — feedback received." : `Error: ${j.error}`);
}
);
await server.connect(new StdioServerTransport());