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