#!/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 ), 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());