pursor 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ // click, type, wait, seq — interaction primitives.
2
+
3
+ import { launch, newPage } from "./runway.js";
4
+ import { DEFAULT_VIEWPORT } from "./viewport.js";
5
+ import { gotoOrThrow, settle, CLICK_TIMEOUT_MS, WAIT_DEFAULT_TIMEOUT_MS } from "./overlays.js";
6
+ import { resolveLocator } from "./selector.js";
7
+ import { requireArg } from "./util.js";
8
+
9
+ export async function runClick(url, selector, out) {
10
+ requireArg("url", url, "string");
11
+ requireArg("selector", selector, "string");
12
+ const browser = await launch();
13
+ try {
14
+ const page = await newPage(browser, DEFAULT_VIEWPORT);
15
+ const r = await gotoOrThrow(page, url); await settle(page);
16
+ const loc = await resolveLocator(page, selector);
17
+ await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
18
+ await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
19
+ await settle(page);
20
+ if (out) await page.screenshot({ path: out, fullPage: false });
21
+ return { ...r, url, out, selector, clicked: true };
22
+ } finally { try { await browser.close(); } catch {} }
23
+ }
24
+
25
+ export async function runType(url, selector, text, out) {
26
+ requireArg("url", url, "string");
27
+ requireArg("selector", selector, "string");
28
+ const browser = await launch();
29
+ try {
30
+ const page = await newPage(browser, DEFAULT_VIEWPORT);
31
+ const r = await gotoOrThrow(page, url); await settle(page);
32
+ const loc = await resolveLocator(page, selector);
33
+ await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
34
+ await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
35
+ await page.keyboard.type(String(text ?? ""), { delay: 10 });
36
+ await settle(page);
37
+ if (out) await page.screenshot({ path: out, fullPage: false });
38
+ return { ...r, url, out, selector, text, typed: true };
39
+ } finally { try { await browser.close(); } catch {} }
40
+ }
41
+
42
+ export async function runWait(url, selector, timeoutMs) {
43
+ requireArg("url", url, "string");
44
+ requireArg("selector", selector, "string");
45
+ const browser = await launch();
46
+ try {
47
+ const page = await newPage(browser, DEFAULT_VIEWPORT);
48
+ const r = await gotoOrThrow(page, url);
49
+ const loc = await resolveLocator(page, selector);
50
+ const t = timeoutMs || WAIT_DEFAULT_TIMEOUT_MS;
51
+ try {
52
+ await loc.first().waitFor({ state: "visible", timeout: t });
53
+ return { ...r, url, selector, found: true, timeoutMs: t };
54
+ } catch {
55
+ return { ...r, url, selector, found: false, timeoutMs: t };
56
+ }
57
+ } finally { try { await browser.close(); } catch {} }
58
+ }
59
+
60
+ export async function runSeq(url, actionsJson, out) {
61
+ requireArg("url", url, "string");
62
+ let actions;
63
+ try { actions = JSON.parse(actionsJson); }
64
+ catch (e) { throw new Error(`invalid actions JSON: ${e.message}`, { cause: e }); }
65
+ if (!Array.isArray(actions)) throw new Error("actions must be a JSON array");
66
+ if (!actions.length) throw new Error("actions array is empty");
67
+ const browser = await launch();
68
+ try {
69
+ const page = await newPage(browser, DEFAULT_VIEWPORT);
70
+ const r = await gotoOrThrow(page, url); await settle(page);
71
+ const trace = [];
72
+ let failed = false;
73
+ for (let i = 0; i < actions.length; i++) {
74
+ const a = actions[i] || {};
75
+ const step = { i, op: a.op };
76
+ try {
77
+ switch (a.op) {
78
+ case "click": {
79
+ const loc = await resolveLocator(page, a.selector);
80
+ await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
81
+ await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
82
+ step.selector = a.selector; break;
83
+ }
84
+ case "type": {
85
+ const loc = await resolveLocator(page, a.selector);
86
+ await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
87
+ await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
88
+ await page.keyboard.type(String(a.text ?? ""), { delay: 10 });
89
+ step.selector = a.selector; step.text = a.text; break;
90
+ }
91
+ case "wait": {
92
+ const t = a.timeoutMs ? Number(a.timeoutMs) : WAIT_DEFAULT_TIMEOUT_MS;
93
+ const loc = await resolveLocator(page, a.selector);
94
+ await loc.first().waitFor({ state: "visible", timeout: t });
95
+ step.selector = a.selector; step.timeoutMs = t; break;
96
+ }
97
+ case "eval": { step.result = await page.evaluate(String(a.js ?? "")); break; }
98
+ case "shot": {
99
+ await page.screenshot({ path: a.out, fullPage: !!a.fullPage });
100
+ step.out = a.out; step.fullPage = !!a.fullPage; break;
101
+ }
102
+ case "scroll": {
103
+ const vp = page.viewportSize();
104
+ await page.mouse.move((vp?.width || 640) / 2, (vp?.height || 400) / 2);
105
+ await page.mouse.wheel(a.deltaX || 0, a.deltaY || 0);
106
+ step.deltaX = a.deltaX; step.deltaY = a.deltaY; break;
107
+ }
108
+ case "navigate": { await gotoOrThrow(page, a.url); step.url = a.url; break; }
109
+ case "press": {
110
+ // a.key can be a single key ("Escape") or comma-separated ("Tab,Enter")
111
+ const raw = String(a.key ?? "").trim();
112
+ if (!raw) throw new Error("press: missing key");
113
+ const keys = raw.split(",").map(k => k.trim()).filter(Boolean);
114
+ for (const k of keys) await page.keyboard.press(k);
115
+ step.key = raw; step.count = keys.length; break;
116
+ }
117
+ case "sleep": { await page.waitForTimeout(Number(a.ms ?? 1000)); step.ms = a.ms; break; }
118
+ case "hover": {
119
+ const loc = await resolveLocator(page, a.selector);
120
+ await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
121
+ await loc.first().hover({ timeout: CLICK_TIMEOUT_MS });
122
+ step.selector = a.selector; break;
123
+ }
124
+ default: throw new Error(`unknown op: ${a.op}`);
125
+ }
126
+ if (a.settleMs !== undefined) await page.waitForTimeout(Number(a.settleMs));
127
+ else await settle(page);
128
+ step.ok = true;
129
+ } catch (e) {
130
+ step.ok = false; step.error = e.message; failed = true;
131
+ }
132
+ trace.push(step);
133
+ if (failed) break;
134
+ }
135
+ if (out) await page.screenshot({ path: out, fullPage: false });
136
+ return { ...r, url, out, steps: trace, failed };
137
+ } finally { try { await browser.close(); } catch {} }
138
+ }
@@ -0,0 +1,111 @@
1
+ // pursor — MCP resources adapter.
2
+ //
3
+ // Exposes run results as MCP resources so hosts (Claude Code, Cursor, etc.)
4
+ // can browse, preview, and re-read captures without re-running captures.
5
+ //
6
+ // Resource shape (per MCP spec):
7
+ // uri: pursor://<kind>/<id>
8
+ // name: <human label>
9
+ // description: <what it is>
10
+ // mimeType: image/png | application/json | text/html
11
+ //
12
+ // We track "recent" sweep outputs in-memory + persist an index at
13
+ // $PURSOR_MCP_STATE/mcp-index.json so resources survive restarts.
14
+
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "node:fs";
16
+ import { join, basename, dirname } from "node:path";
17
+ import { homedir } from "node:os";
18
+ import { nowIso } from "./util.js";
19
+
20
+ function stateDir() {
21
+ const root = process.env.PURSOR_MCP_STATE || join(homedir(), ".pursor", "mcp");
22
+ mkdirSync(root, { recursive: true });
23
+ return root;
24
+ }
25
+
26
+ function indexPath() { return join(stateDir(), "mcp-index.json"); }
27
+
28
+ function loadIndex() {
29
+ const p = indexPath();
30
+ if (!existsSync(p)) return { resources: [] };
31
+ try { return JSON.parse(readFileSync(p, "utf8")); } catch { return { resources: [] }; }
32
+ }
33
+
34
+ function saveIndex(idx) {
35
+ writeFileSync(indexPath(), JSON.stringify(idx, null, 2), "utf8");
36
+ }
37
+
38
+ export function recordResource({ kind, id, name, description, uri, mimeType, file, meta }) {
39
+ const idx = loadIndex();
40
+ // De-dup by uri
41
+ idx.resources = idx.resources.filter(r => r.uri !== uri);
42
+ idx.resources.unshift({
43
+ kind, id, name, description, uri, mimeType, file, meta: meta || null, ts: nowIso(),
44
+ });
45
+ // Cap index size
46
+ if (idx.resources.length > 200) idx.resources = idx.resources.slice(0, 200);
47
+ saveIndex(idx);
48
+ return idx.resources[0];
49
+ }
50
+
51
+ export function listResources() {
52
+ // Combine persisted index + any in-memory scan of recent sweep dirs
53
+ const idx = loadIndex();
54
+ // Also include sidecars sitting next to a sweep.json under cwd
55
+ try {
56
+ const cwd = process.cwd();
57
+ for (const f of readdirSync(cwd)) {
58
+ if (f === "sweep.json") {
59
+ const sweepPath = join(cwd, f);
60
+ try {
61
+ const s = JSON.parse(readFileSync(sweepPath, "utf8"));
62
+ const dirUri = `pursor://sweep/${encodeURIComponent(s.name || basename(cwd))}`;
63
+ if (!idx.resources.some(r => r.uri === dirUri)) {
64
+ idx.resources.push({
65
+ kind: "sweep", id: s.name || basename(cwd),
66
+ name: `sweep: ${s.name || basename(cwd)}`,
67
+ description: `Sweep summary: ${(s.steps || []).length} steps`,
68
+ uri: dirUri, mimeType: "application/json",
69
+ file: sweepPath, meta: { steps: (s.steps || []).length, ts: s.ts }, ts: s.ts || nowIso(),
70
+ });
71
+ }
72
+ } catch {}
73
+ }
74
+ }
75
+ } catch {}
76
+ return idx.resources;
77
+ }
78
+
79
+ export function readResource(uri) {
80
+ if (typeof uri !== "string") return null;
81
+ if (!uri.startsWith("pursor://")) return null;
82
+ // Parse kind/id
83
+ const rest = uri.slice("pursor://".length);
84
+ const [kind, ...restParts] = rest.split("/");
85
+ const id = restParts.join("/");
86
+ const idx = loadIndex();
87
+ const r = idx.resources.find(x => x.uri === uri);
88
+ if (r) {
89
+ return readResourceFile(r);
90
+ }
91
+ // Resolve by kind/id from filesystem fallback
92
+ if (kind === "sweep") {
93
+ const file = join(process.cwd(), decodeURIComponent(id), "sweep.json");
94
+ if (existsSync(file)) {
95
+ return { uri, mimeType: "application/json", text: readFileSync(file, "utf8") };
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+
101
+ function readResourceFile(r) {
102
+ if (!r.file || !existsSync(r.file)) return { uri: r.uri, mimeType: r.mimeType, error: "file not found" };
103
+ const data = readFileSync(r.file);
104
+ if (r.mimeType && r.mimeType.startsWith("image/")) {
105
+ return { uri: r.uri, mimeType: r.mimeType, blob: data.toString("base64") };
106
+ }
107
+ if (r.mimeType === "application/json" || r.mimeType === "text/html") {
108
+ return { uri: r.uri, mimeType: r.mimeType, text: data.toString("utf8") };
109
+ }
110
+ return { uri: r.uri, mimeType: r.mimeType || "application/octet-stream", blob: data.toString("base64") };
111
+ }
package/src/mcp.js ADDED
@@ -0,0 +1,436 @@
1
+ // pursor — MCP stdio server (Model Context Protocol).
2
+ //
3
+ // Implements JSON-RPC 2.0 over stdio with Content-Length framing.
4
+ // Exposes every pursor capability as an MCP tool for use by
5
+ // Claude Code, Cursor, Continue, and any other MCP host.
6
+ //
7
+ // Config via PURSOR_MCP_CONFIG env or ~/.pursor/mcp-config.json:
8
+ // { "plugins": ["./my-plugin.js"], "defaultOutDir": "./mcp-output" }
9
+
10
+ import { readFileSync, existsSync, mkdirSync } from "node:fs";
11
+ import { join, dirname } from "node:path";
12
+ import { homedir } from "node:os";
13
+ import { runProbe } from "./probe.js";
14
+ import { runShoot } from "./shoot.js";
15
+ import { runDiff } from "./diff.js";
16
+ import { runSweep } from "./sweep.js";
17
+ import { runFrames } from "./frames.js";
18
+ import { runShootWithSidecar } from "./shoot.js";
19
+ import { captureDomSnapshot } from "./dom-snapshot.js";
20
+ import { runAudit } from "./plugin-audit.js";
21
+ import { loadPlugins, listPlugins } from "./plugin.js";
22
+ import { makeOut, nowIso } from "./util.js";
23
+ import { listResources, readResource, recordResource } from "./mcp-resources.js";
24
+ import { createRequire } from "node:module";
25
+
26
+ const __require = createRequire(import.meta.url);
27
+ let _pkg = { version: "0.1.0" };
28
+ try { _pkg = __require("../package.json"); } catch {}
29
+
30
+ const MCP_VERSION = "0.1.0";
31
+
32
+ // ─── Config ──────────────────────────────────────────────────────────────
33
+
34
+ function loadConfig() {
35
+ const envRaw = process.env.PURSOR_MCP_CONFIG;
36
+ if (envRaw) {
37
+ try { return JSON.parse(envRaw); } catch { /* not JSON, treat as path */ }
38
+ try { return JSON.parse(readFileSync(envRaw, "utf8")); } catch {}
39
+ }
40
+ const configDir = join(homedir(), ".pursor");
41
+ const configPath = join(configDir, "mcp-config.json");
42
+ if (existsSync(configPath)) {
43
+ try { return JSON.parse(readFileSync(configPath, "utf8")); } catch {}
44
+ }
45
+ return {};
46
+ }
47
+
48
+ // ─── MCP Error ───────────────────────────────────────────────────────────
49
+
50
+ class McpError extends Error {
51
+ constructor(code, message) {
52
+ super(message);
53
+ this.code = code;
54
+ this.name = "McpError";
55
+ }
56
+ }
57
+
58
+ // ─── Server ──────────────────────────────────────────────────────────────
59
+
60
+ class PursorMCPServer {
61
+ constructor(config = {}) {
62
+ this.config = config;
63
+ this._buffer = Buffer.alloc(0);
64
+ this._contentLength = -1;
65
+ this._initialized = false;
66
+ this._verbose = !!config.verbose;
67
+ }
68
+
69
+ log(...args) {
70
+ if (this._verbose) console.error("[pursor-mcp]", ...args);
71
+ }
72
+
73
+ async start() {
74
+ if (this.config.plugins?.length) {
75
+ await loadPlugins(this.config.plugins);
76
+ }
77
+ this.log("server started, plugins:", listPlugins());
78
+
79
+ process.stdin.on("data", (chunk) => {
80
+ this._buffer = Buffer.concat([this._buffer, chunk]);
81
+ this._processBuffer();
82
+ });
83
+ process.stdin.on("end", () => {
84
+ this.log("stdin closed");
85
+ });
86
+
87
+ process.on("uncaughtException", (err) => {
88
+ console.error("[pursor-mcp] uncaught:", err.message);
89
+ });
90
+ }
91
+
92
+ // ── Buffer framing ──────────────────────────────────────────────────
93
+
94
+ _processBuffer() {
95
+ while (true) {
96
+ if (this._contentLength < 0) {
97
+ const idx = this._buffer.indexOf(Buffer.from("\r\n\r\n"));
98
+ if (idx === -1) break;
99
+ const header = this._buffer.slice(0, idx).toString("utf8");
100
+ const m = header.match(/Content-Length:\s*(\d+)/i);
101
+ if (m) this._contentLength = parseInt(m[1], 10);
102
+ this._buffer = this._buffer.slice(idx + 4);
103
+ }
104
+ if (this._contentLength > 0 && this._buffer.length >= this._contentLength) {
105
+ const raw = this._buffer.slice(0, this._contentLength).toString("utf8");
106
+ this._buffer = this._buffer.slice(this._contentLength);
107
+ this._contentLength = -1;
108
+ try {
109
+ const msg = JSON.parse(raw);
110
+ this._handleMessage(msg);
111
+ } catch (e) {
112
+ console.error("[pursor-mcp] invalid JSON:", e.message);
113
+ }
114
+ } else break;
115
+ }
116
+ }
117
+
118
+ _send(msg) {
119
+ const json = JSON.stringify(msg);
120
+ const bytes = Buffer.from(json, "utf8");
121
+ const header = `Content-Length: ${bytes.length}\r\n\r\n`;
122
+ process.stdout.write(header);
123
+ process.stdout.write(bytes);
124
+ }
125
+
126
+ // ── JSON-RPC dispatcher ─────────────────────────────────────────────
127
+
128
+ async _handleMessage(msg) {
129
+ if (!msg || msg.jsonrpc !== "2.0" || !msg.method) {
130
+ console.error("[pursor-mcp] skipping non-JSON-RPC message");
131
+ return;
132
+ }
133
+ const { method, id } = msg;
134
+
135
+ // Notifications — no id → no response
136
+ if (method === "notifications/initialized" || method === "notifications/cancelled") {
137
+ if (method === "notifications/initialized") this._initialized = true;
138
+ return;
139
+ }
140
+ if (id === undefined || id === null) return; // unnamed notification
141
+
142
+ try {
143
+ switch (method) {
144
+ case "initialize":
145
+ this._initialized = true;
146
+ this._send({
147
+ jsonrpc: "2.0", id,
148
+ result: {
149
+ protocolVersion: msg.params?.protocolVersion || "2024-11-05",
150
+ capabilities: { tools: {} },
151
+ serverInfo: { name: "pursor", version: MCP_VERSION },
152
+ },
153
+ });
154
+ break;
155
+
156
+ case "tools/list":
157
+ this._send({ jsonrpc: "2.0", id, result: { tools: this._toolDefs() } });
158
+ break;
159
+
160
+ case "resources/list":
161
+ this._send({ jsonrpc: "2.0", id, result: { resources: listResources().map(this._toMcpResource, this) } });
162
+ break;
163
+
164
+ case "resources/read":
165
+ if (!msg.params?.uri) throw new McpError(-32602, "Missing uri");
166
+ const data = readResource(msg.params.uri);
167
+ if (!data) throw new McpError(-32602, "Resource not found: " + msg.params.uri);
168
+ this._send({ jsonrpc: "2.0", id, result: { contents: [data] } });
169
+ break;
170
+
171
+ case "tools/call":
172
+ if (!msg.params?.name) throw new McpError(-32602, "Missing tool name");
173
+ const result = await this._callTool(msg.params.name, msg.params.arguments || {});
174
+ this._send({ jsonrpc: "2.0", id, result: { content: result } });
175
+ break;
176
+
177
+ default:
178
+ this._send({
179
+ jsonrpc: "2.0", id,
180
+ error: { code: -32601, message: `Unknown method: ${method}` },
181
+ });
182
+ }
183
+ } catch (e) {
184
+ if (e instanceof McpError) {
185
+ this._send({ jsonrpc: "2.0", id, error: { code: e.code, message: e.message } });
186
+ } else {
187
+ console.error("[pursor-mcp] handler error:", e.stack || e.message);
188
+ this._send({ jsonrpc: "2.0", id, error: { code: -32603, message: e.message } });
189
+ }
190
+ }
191
+ }
192
+
193
+ // ── Resource shape adapter ─────────────────────────────────────────
194
+
195
+ _toMcpResource(r) {
196
+ return {
197
+ uri: r.uri,
198
+ name: r.name,
199
+ description: r.description || (r.kind + ": " + r.id),
200
+ mimeType: r.mimeType || "application/octet-stream",
201
+ };
202
+ }
203
+
204
+ // ── Tool definitions ────────────────────────────────────────────────
205
+
206
+ _toolDefs() {
207
+ return [
208
+ {
209
+ name: "pursor_shoot",
210
+ description: "Capture a screenshot of a URL with full feature control (viewport, grid, layer, cursor, camera, animation freeze). Returns PNG path and sidecar metadata.",
211
+ inputSchema: {
212
+ type: "object",
213
+ properties: {
214
+ url: { type: "string", description: "Target URL" },
215
+ out: { type: "string", description: "Output PNG path (auto-gen if omitted)" },
216
+ preset: { type: "string", description: "Viewport preset: desktop-1280, desktop-1440, desktop-1920, mobile-375, etc." },
217
+ width: { type: "number", description: "Viewport width (custom)" },
218
+ height: { type: "number", description: "Viewport height (custom)" },
219
+ dpr: { type: "number", description: "Device pixel ratio" },
220
+ full: { type: "boolean", description: "Full-page screenshot" },
221
+ cursor: { type: "string", description: "Cursor: default|pointer|grab|grabbing|crosshair|none" },
222
+ grid: { type: "boolean", description: "Overlay grid" },
223
+ "grid-tile": { type: "number", description: "Grid tile size (px)" },
224
+ "grid-color": { type: "string", description: "Grid line color" },
225
+ layer: { type: "string", description: "Layer isolation: all|entity|terrain|hud|ui" },
226
+ zoom: { type: "number", description: "Camera zoom factor" },
227
+ panX: { type: "number", description: "Camera pan X (px)" },
228
+ panY: { type: "number", description: "Camera pan Y (px)" },
229
+ "no-animation": { type: "boolean", description: "Freeze CSS animations" },
230
+ "wait-frame": { type: "number", description: "Wait for stable canvas frame (ms)" },
231
+ "no-hud": { type: "boolean", description: "Hide header/footer/nav elements" },
232
+ },
233
+ required: ["url"],
234
+ },
235
+ },
236
+ {
237
+ name: "pursor_diff",
238
+ description: "Pixel-diff a URL against a reference PNG. Returns diff stats and writes diff overlay image.",
239
+ inputSchema: {
240
+ type: "object",
241
+ properties: {
242
+ url: { type: "string", description: "URL to capture" },
243
+ ref: { type: "string", description: "Reference PNG path" },
244
+ out: { type: "string", description: "Diff output PNG (auto-gen if omitted)" },
245
+ threshold: { type: "number", description: "Pixelmatch threshold 0-1 (default 0.1)" },
246
+ },
247
+ required: ["url", "ref"],
248
+ },
249
+ },
250
+ {
251
+ name: "pursor_sweep",
252
+ description: "Execute a batch sweep plan (JSON file). Runs multiple capture steps sequentially, returns summary + HTML report.",
253
+ inputSchema: {
254
+ type: "object",
255
+ properties: {
256
+ plan: { type: "string", description: "Path to sweep plan JSON" },
257
+ outDir: { type: "string", description: "Output directory (default from plan)" },
258
+ },
259
+ required: ["plan"],
260
+ },
261
+ },
262
+ {
263
+ name: "pursor_frames",
264
+ description: "Capture an animation frame timeline — N screenshots at a given interval.",
265
+ inputSchema: {
266
+ type: "object",
267
+ properties: {
268
+ url: { type: "string", description: "Target URL" },
269
+ count: { type: "number", description: "Number of frames 1-120 (default 8)" },
270
+ intervalMs: { type: "number", description: "Interval between frames in ms (default 250)" },
271
+ outDir: { type: "string", description: "Output directory (auto-gen if omitted)" },
272
+ },
273
+ required: ["url"],
274
+ },
275
+ },
276
+ {
277
+ name: "pursor_probe",
278
+ description: "Health-check a URL: returns HTTP status, page title, nav errors. No screenshot.",
279
+ inputSchema: {
280
+ type: "object",
281
+ properties: {
282
+ url: { type: "string", description: "URL to probe" },
283
+ },
284
+ required: ["url"],
285
+ },
286
+ },
287
+ {
288
+ name: "pursor_audit",
289
+ description: "Run axe-core WCAG accessibility audit on a URL. Returns violation summary, saves full report + highlighted screenshot.",
290
+ inputSchema: {
291
+ type: "object",
292
+ properties: {
293
+ url: { type: "string", description: "Target URL" },
294
+ tags: { type: "string", description: "Comma-separated WCAG tags: wcag2a,wcag2aa,wcag21a,wcag21aa" },
295
+ outDir: { type: "string", description: "Output directory (auto-gen if omitted)" },
296
+ screenshot: { type: "boolean", description: "Capture highlighted screenshot (default true)" },
297
+ },
298
+ required: ["url"],
299
+ },
300
+ },
301
+ {
302
+ name: "pursor_dom_snapshot",
303
+ description: "Full DOM snapshot: serialized HTML, computed styles per visible element, selector map (id/role/text/xpath), bounding rects. Stored as .dom.json sidecar.",
304
+ inputSchema: {
305
+ type: "object",
306
+ properties: {
307
+ url: { type: "string", description: "Target URL" },
308
+ out: { type: "string", description: "Output .dom.json path (auto-gen if omitted)" },
309
+ },
310
+ required: ["url"],
311
+ },
312
+ },
313
+ ];
314
+ }
315
+
316
+ // ── Tool dispatcher ─────────────────────────────────────────────────
317
+
318
+ async _callTool(name, args) {
319
+ switch (name) {
320
+ case "pursor_shoot": return await this._shoot(args);
321
+ case "pursor_diff": return await this._diff(args);
322
+ case "pursor_sweep": return await this._sweep(args);
323
+ case "pursor_frames": return await this._frames(args);
324
+ case "pursor_probe": return await this._probe(args);
325
+ case "pursor_audit": return await this._audit(args);
326
+ case "pursor_dom_snapshot": return await this._domSnapshot(args);
327
+ default: throw new McpError(-32602, `Unknown tool: ${name}`);
328
+ }
329
+ }
330
+
331
+ // ── Tool implementations ────────────────────────────────────────────
332
+
333
+ async _shoot(args) {
334
+ const url = args.url;
335
+ if (!url) throw new McpError(-32602, "Missing required: url");
336
+
337
+ const defDir = this.config.defaultOutDir || process.cwd();
338
+ const out = args.out || join(defDir, `mcp-shoot-${Date.now()}.png`);
339
+ if (out) mkdirSync(dirname(out), { recursive: true });
340
+
341
+ const flags = {};
342
+ for (const [k, v] of Object.entries(args)) {
343
+ if (k !== "url" && k !== "out") flags[k] = v;
344
+ }
345
+
346
+ const meta = await runShootWithSidecar({ url, out, flags });
347
+ const sidecar = meta.out && existsSync(meta.out.replace(/\.png$/i, ".json"))
348
+ ? JSON.parse(readFileSync(meta.out.replace(/\.png$/i, ".json"), "utf8"))
349
+ : meta;
350
+
351
+ recordResource({
352
+ kind: "shoot", id: Date.now().toString(36),
353
+ name: "shoot: " + (flags.preset || "default") + " " + url,
354
+ description: "Screenshot capture",
355
+ uri: "pursor://shoot/" + encodeURIComponent(url + "|" + (flags.preset || "default")),
356
+ mimeType: "image/png",
357
+ file: out, meta: { url, flags, ts: sidecar?.ts },
358
+ });
359
+
360
+ return [{ type: "text", text: JSON.stringify({ out, meta: sidecar }, null, 2) }];
361
+ }
362
+
363
+ async _diff(args) {
364
+ const { url, ref } = args;
365
+ if (!url) throw new McpError(-32602, "Missing required: url");
366
+ if (!ref) throw new McpError(-32602, "Missing required: ref");
367
+ if (!existsSync(ref)) throw new McpError(-32602, `Reference file not found: ${ref}`);
368
+
369
+ const out = args.out || ref.replace(/\.png$/i, "-diff.png");
370
+ if (out) mkdirSync(dirname(out), { recursive: true });
371
+ const threshold = args.threshold ?? 0.1;
372
+ const result = await runDiff(url, ref, out, threshold);
373
+ return [{ type: "text", text: JSON.stringify(result, null, 2) }];
374
+ }
375
+
376
+ async _sweep(args) {
377
+ if (!args.plan) throw new McpError(-32602, "Missing required: plan");
378
+ if (!existsSync(args.plan)) throw new McpError(-32602, `Plan file not found: ${args.plan}`);
379
+ const summary = await runSweep(args.plan, args.outDir);
380
+ recordResource({
381
+ kind: "sweep", id: summary.name || "sweep",
382
+ name: "sweep: " + (summary.name || "(unnamed)"),
383
+ description: "Sweep plan: " + (summary.steps?.length || 0) + " steps",
384
+ uri: "pursor://sweep/" + encodeURIComponent(summary.name || "sweep"),
385
+ mimeType: "application/json",
386
+ file: (summary.outDir ? join(summary.outDir, "sweep.json") : null),
387
+ meta: { steps: summary.steps?.length || 0, ts: summary.ts },
388
+ });
389
+ return [{ type: "text", text: JSON.stringify(summary, null, 2) }];
390
+ }
391
+
392
+ async _frames(args) {
393
+ if (!args.url) throw new McpError(-32602, "Missing required: url");
394
+ const defDir = this.config.defaultOutDir || process.cwd();
395
+ const outDir = args.outDir || join(defDir, `mcp-frames-${Date.now()}`);
396
+ mkdirSync(outDir, { recursive: true });
397
+ const result = await runFrames({
398
+ url: args.url,
399
+ count: args.count,
400
+ intervalMs: args.intervalMs,
401
+ outDir,
402
+ });
403
+ return [{ type: "text", text: JSON.stringify(result, null, 2) }];
404
+ }
405
+
406
+ async _probe(args) {
407
+ if (!args.url) throw new McpError(-32602, "Missing required: url");
408
+ const result = await runProbe(args.url);
409
+ return [{ type: "text", text: JSON.stringify(result, null, 2) }];
410
+ }
411
+
412
+ async _audit(args) {
413
+ if (!args.url) throw new McpError(-32602, "Missing required: url");
414
+ const tags = args.tags ? args.tags.split(",").map(t => t.trim()).filter(Boolean) : undefined;
415
+ const defDir = this.config.defaultOutDir || process.cwd();
416
+ const outDir = args.outDir || join(defDir, `mcp-audit-${Date.now()}`);
417
+ const result = await runAudit({
418
+ url: args.url,
419
+ tags,
420
+ outDir,
421
+ screenshot: args.screenshot !== false,
422
+ });
423
+ return [{ type: "text", text: JSON.stringify(result, null, 2) }];
424
+ }
425
+
426
+ async _domSnapshot(args) {
427
+ if (!args.url) throw new McpError(-32602, "Missing required: url");
428
+ const defDir = this.config.defaultOutDir || process.cwd();
429
+ const out = args.out || join(defDir, `dom-snapshot-${Date.now()}.dom.json`);
430
+ mkdirSync(dirname(out), { recursive: true });
431
+ const result = await captureDomSnapshot({ url: args.url, out });
432
+ return [{ type: "text", text: JSON.stringify({ out, ...result }, null, 2) }];
433
+ }
434
+ }
435
+
436
+ export { PursorMCPServer, McpError, loadConfig, MCP_VERSION };