pursr 0.7.3 → 0.8.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/README.md CHANGED
@@ -33,8 +33,8 @@
33
33
  Most teams need **five separate tools** to do visual QA: a screenshot CLI, a regression diff runner, an accessibility auditor, a way to share captures with an AI assistant, and a way to **turn all of that into a PDF report** for stakeholders. **pursr is all five** - built as a single Node.js package with:
34
34
 
35
35
  - **A unified CLI** (`pursr`) for every capture, diff, sweep, and audit.
36
- - **An MCP stdio server** (`pursr-mcp`) so Claude Code, Cursor, and Continue can take screenshots, run sweeps, and inspect prior captures as MCP resources.
37
- - **A library** with 34 named exports and 18 subpath modules, so you can embed it in your own tooling.
36
+ - **An agent-grade MCP stdio server** (`pursr-mcp`) built on the official Model Context Protocol SDK, with persistent tabs, direct image responses, rendered-state inspection, actions, diagnostics, screenshots, sweeps, and resources.
37
+ - **A library API** with 23 subpath modules, so you can embed the browser and QA primitives in your own tooling.
38
38
  - **A plugin system** for custom viewports, sweep ops, and capture hooks.
39
39
  - **PDF reports + AI diff summaries** built in - render a sweep to a styled PDF or ask a vision LLM to describe the regression in plain language.
40
40
  - **Zero browser bundled** - drives your system Chrome via Playwright. No 200 MB Chromium download.
@@ -96,7 +96,7 @@ pursr sweep ./plan.json # see plans/ for an example
96
96
  | HAR capture | HAR 1.2 spec, written next to your shot | `--har ./req.har.json` |
97
97
  | Auth state | Playwright storageState, reuse logged-in sessions | `--auth-state admin` |
98
98
  | Plugins | custom viewports, sweep ops, before/after hooks | `pursr-plugin-*` |
99
- | MCP server | 7 tools + resources/list & resources/read for Claude/Cursor | `npx pursr-mcp` |
99
+ | MCP server | Official MCP SDK transport, 16 tools, and resources for Claude/Cursor/Codex | `npx pursr-mcp` |
100
100
  | PDF report | render sweep.json to a styled, embedded-PNG A4 PDF | `pursr report --sweep ./sweep.json` |
101
101
  | AI diff summary | vision LLM describes the diff in plain language | `pursr diff ... --ai` |
102
102
 
@@ -184,17 +184,51 @@ npx pursr-mcp
184
184
  npx pursr-mcp --verbose
185
185
  ```
186
186
 
187
- ### Exposed Tools
188
-
189
- | Tool | Description |
190
- | --- | --- |
191
- | `pursr_shoot` | Rich screenshot capture (viewport, grid, layer, cursor, camera, animation freeze, HAR) |
187
+ ### Exposed Tools
188
+
189
+ | Tool | Description |
190
+ | --- | --- |
191
+ | `pursr_session_open` | Open a persistent browser tab for iterative agent work |
192
+ | `pursr_sessions` | List active browser sessions |
193
+ | `pursr_snapshot` | Visible rendered nodes, geometry, semantics, and computed styles |
194
+ | `pursr_act` | Click, hover, fill, type, scroll, navigate, reload, and more |
195
+ | `pursr_screenshot` | Return the current PNG directly to the vision model |
196
+ | `pursr_inspect` | Inspect exact geometry, computed styles, and stacking ancestors |
197
+ | `pursr_diagnostics` | Read console, page errors, failed requests, and HTTP failures |
198
+ | `pursr_session_close` | Close the tab and release its browser process |
199
+ | `pursr_shoot` | Rich screenshot capture (viewport, grid, layer, cursor, camera, animation freeze, HAR) |
192
200
  | `pursr_diff` | Pixel-diff a URL against a reference PNG |
193
201
  | `pursr_sweep` | Execute a batch sweep plan |
194
202
  | `pursr_frames` | Capture an N-frame animation timeline |
195
203
  | `pursr_probe` | Health-check a URL |
196
204
  | `pursr_audit` | axe-core WCAG audit + highlighted screenshot |
197
- | `pursr_dom_snapshot` | Full DOM + selector map snapshot |
205
+ | `pursr_dom_snapshot` | Full DOM + selector map snapshot |
206
+ | `pursr_check` | CI visual regression check against a stable baseline |
207
+
208
+ ### Agent workflow
209
+
210
+ Use persistent sessions for the same inspect-act-verify loop as an interactive browser agent:
211
+
212
+ 1. Call `pursr_session_open` once with a stable `sessionId`.
213
+ 2. Call `pursr_snapshot` to understand the rendered page before acting.
214
+ 3. Use `pursr_act` for a small, ordered interaction sequence.
215
+ 4. Call `pursr_screenshot` when visual judgment matters; the model receives the PNG directly.
216
+ 5. Use `pursr_inspect` for layout, clipping, typography, or stacking problems.
217
+ 6. Read `pursr_diagnostics`, then reload and verify after source changes.
218
+ 7. Call `pursr_session_close` when the review is complete.
219
+
220
+ Example action arguments:
221
+
222
+ ```json
223
+ {
224
+ "sessionId": "farm",
225
+ "actions": [
226
+ { "type": "hover", "selector": "role=button|Build" },
227
+ { "type": "click", "selector": "text=Barn" },
228
+ { "type": "wait", "selector": "role=dialog" }
229
+ ]
230
+ }
231
+ ```
198
232
 
199
233
  ### Exposed Resources
200
234
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pursr",
3
- "version": "0.7.3",
3
+ "version": "0.8.1",
4
4
  "private": false,
5
5
  "description": "pursr — Visual QA, audit, and MCP for the browser. One CLI + one MCP server for screenshots, sweeps, baselines, diffs, axe-core a11y audits, HAR capture, and auth state — with parallel sweep workers, auto-healing selectors, and a plugin system. Zero browser bundled: drives your system Chrome via Playwright.",
6
6
  "homepage": "https://github.com/0xheycat/pursr",
@@ -12,8 +12,8 @@
12
12
  "funding": "https://github.com/sponsors/0xheycat",
13
13
  "type": "module",
14
14
  "bin": {
15
- "pursr": "./bin/pursr.mjs",
16
- "pursr-mcp": "./bin/pursr-mcp.mjs"
15
+ "pursr": "bin/pursr.mjs",
16
+ "pursr-mcp": "bin/pursr-mcp.mjs"
17
17
  },
18
18
  "main": "./src/index.js",
19
19
  "exports": {
@@ -38,7 +38,8 @@
38
38
  "./watch": "./src/watch.js",
39
39
  "./snap": "./src/snap.js",
40
40
  "./report": "./src/report.js",
41
- "./ai-diff": "./src/ai-diff.js"
41
+ "./ai-diff": "./src/ai-diff.js",
42
+ "./session": "./src/session.js"
42
43
  },
43
44
  "files": [
44
45
  "bin",
@@ -76,10 +77,12 @@
76
77
  ],
77
78
  "license": "MIT",
78
79
  "dependencies": {
80
+ "@modelcontextprotocol/sdk": "^1.29.0",
79
81
  "axe-core": "^4.12.1",
80
82
  "pdfkit": "^0.19.1",
81
83
  "pixelmatch": "^5.3.0",
82
- "pngjs": "^7.0.0"
84
+ "pngjs": "^7.0.0",
85
+ "zod": "^4.4.3"
83
86
  },
84
87
  "peerDependencies": {
85
88
  "playwright-core": "*"
@@ -95,7 +95,7 @@ const SNAPSHOT_PAGE_SCRIPT = `(() => {
95
95
  const href = el.getAttribute('href');
96
96
  const src = el.getAttribute('src');
97
97
 
98
- const entry = {
98
+ const entry = {
99
99
  tag,
100
100
  id,
101
101
  css: getCSSSelector(el),
@@ -108,8 +108,29 @@ const SNAPSHOT_PAGE_SCRIPT = `(() => {
108
108
  href: href || null,
109
109
  src: src || null,
110
110
  rect: visible ? { x: round(rect.x), y: round(rect.y), w: round(rect.width), h: round(rect.height) } : null,
111
- visible,
112
- };
111
+ visible,
112
+ };
113
+
114
+ if (visible) {
115
+ const computed = getComputedStyle(el);
116
+ entry.computedStyle = {
117
+ display: computed.display,
118
+ position: computed.position,
119
+ zIndex: computed.zIndex,
120
+ overflowX: computed.overflowX,
121
+ overflowY: computed.overflowY,
122
+ opacity: computed.opacity,
123
+ visibility: computed.visibility,
124
+ color: computed.color,
125
+ backgroundColor: computed.backgroundColor,
126
+ fontFamily: computed.fontFamily,
127
+ fontSize: computed.fontSize,
128
+ fontWeight: computed.fontWeight,
129
+ lineHeight: computed.lineHeight,
130
+ transform: computed.transform,
131
+ boxShadow: computed.boxShadow,
132
+ };
133
+ }
113
134
 
114
135
  // get computed role from accessibility tree
115
136
  try { entry.ariaRole = el.computedRole || el.getAttribute('role') || null; } catch {}
package/src/index.js CHANGED
@@ -43,7 +43,8 @@ import { startWatch, matchGlob, shouldFire } from "./watch.js";
43
43
  import { runSnap, approveSnapsAsBaselines } from "./snap.js";
44
44
  import { runCheck } from "./check.js";
45
45
  import { renderSweepPdf } from "./report.js";
46
- import { aiDiffSummary, aiDiffSidecar } from "./ai-diff.js";
46
+ import { aiDiffSummary, aiDiffSidecar } from "./ai-diff.js";
47
+ import { BrowserSessionManager } from "./session.js";
47
48
 
48
49
 
49
50
  // Derive VERSION from package.json to prevent drift
@@ -85,8 +86,9 @@ export {
85
86
  // v6: PDF report, AI diff summary
86
87
  runDiffWithAi,
87
88
  renderSweepPdf,
88
- aiDiffSummary, aiDiffSidecar,
89
- VERSION,
89
+ aiDiffSummary, aiDiffSidecar,
90
+ BrowserSessionManager,
91
+ VERSION,
90
92
  };
91
93
 
92
94
  export default {
@@ -106,6 +108,7 @@ export default {
106
108
  validateSweepPlan, registerSweepOp,
107
109
  listResources, readResource, recordResource,
108
110
  // v6: PDF report, AI diff summary
109
- runDiffWithAi, renderSweepPdf, aiDiffSummary, aiDiffSidecar,
110
- VERSION,
111
- };
111
+ runDiffWithAi, renderSweepPdf, aiDiffSummary, aiDiffSidecar,
112
+ BrowserSessionManager,
113
+ VERSION,
114
+ };
package/src/mcp.js CHANGED
@@ -1,8 +1,7 @@
1
1
  // pursr — MCP stdio server (Model Context Protocol).
2
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.
3
+ // Uses the official Model Context Protocol SDK over stdio and exposes every
4
+ // pursr capability to Claude Code, Cursor, Codex, and other MCP hosts.
6
5
  //
7
6
  // Config via PURSR_MCP_CONFIG env or ~/./mcp-config.json:
8
7
  // { "plugins": ["./my-plugin.js"], "defaultOutDir": "./mcp-output" }
@@ -23,13 +22,22 @@ import { runAudit } from "./plugin-audit.js";
23
22
  import { loadPlugins, listPlugins } from "./plugin.js";
24
23
  import { makeOut, nowIso } from "./util.js";
25
24
  import { listResources, readResource, recordResource } from "./mcp-resources.js";
26
- import { createRequire } from "node:module";
25
+ import { createRequire } from "node:module";
26
+ import { BrowserSessionManager } from "./session.js";
27
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
28
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
29
+ import {
30
+ CallToolRequestSchema,
31
+ ListResourcesRequestSchema,
32
+ ListToolsRequestSchema,
33
+ ReadResourceRequestSchema,
34
+ } from "@modelcontextprotocol/sdk/types.js";
27
35
 
28
36
  const __require = createRequire(import.meta.url);
29
37
  let _pkg = { version: "0.1.0" };
30
38
  try { _pkg = __require("../package.json"); } catch {}
31
39
 
32
- const MCP_VERSION = "0.1.0";
40
+ const MCP_VERSION = _pkg.version || "0.1.0";
33
41
 
34
42
  // ─── Config ──────────────────────────────────────────────────────────────
35
43
 
@@ -59,138 +67,68 @@ class McpError extends Error {
59
67
 
60
68
  // ─── Server ──────────────────────────────────────────────────────────────
61
69
 
62
- class PursrMCPServer {
63
- constructor(config = {}) {
64
- this.config = config;
65
- this._buffer = Buffer.alloc(0);
66
- this._contentLength = -1;
67
- this._initialized = false;
68
- this._verbose = !!config.verbose;
69
- }
70
+ class PursrMCPServer {
71
+ constructor(config = {}) {
72
+ this.config = config;
73
+ this._verbose = !!config.verbose;
74
+ this.sessions = new BrowserSessionManager({ outputDir: config.defaultOutDir || process.cwd() });
75
+ this.sdk = new McpServer(
76
+ { name: "pursr", version: MCP_VERSION },
77
+ {
78
+ capabilities: { tools: {}, resources: {} },
79
+ instructions: "Use a persistent pursr session for iterative visual work: open, snapshot, act, screenshot, inspect, diagnose, then close.",
80
+ },
81
+ );
82
+ this.server = this.sdk.server;
83
+ this.transport = null;
84
+ this._registerSdkHandlers();
85
+ }
70
86
 
71
87
  log(...args) {
72
88
  if (this._verbose) console.error("[pursr-mcp]", ...args);
73
89
  }
74
90
 
75
- async start() {
91
+ async start() {
76
92
  if (this.config.plugins?.length) {
77
93
  await loadPlugins(this.config.plugins);
78
94
  }
79
- this.log("server started, plugins:", listPlugins());
80
-
81
- process.stdin.on("data", (chunk) => {
82
- this._buffer = Buffer.concat([this._buffer, chunk]);
83
- this._processBuffer();
84
- });
85
- process.stdin.on("end", () => {
86
- this.log("stdin closed");
87
- });
88
-
89
- process.on("uncaughtException", (err) => {
90
- console.error("[pursr-mcp] uncaught:", err.message);
91
- });
92
- }
93
-
94
- // ── Buffer framing ──────────────────────────────────────────────────
95
-
96
- _processBuffer() {
97
- while (true) {
98
- if (this._contentLength < 0) {
99
- const idx = this._buffer.indexOf(Buffer.from("\r\n\r\n"));
100
- if (idx === -1) break;
101
- const header = this._buffer.slice(0, idx).toString("utf8");
102
- const m = header.match(/Content-Length:\s*(\d+)/i);
103
- if (m) this._contentLength = parseInt(m[1], 10);
104
- this._buffer = this._buffer.slice(idx + 4);
105
- }
106
- if (this._contentLength > 0 && this._buffer.length >= this._contentLength) {
107
- const raw = this._buffer.slice(0, this._contentLength).toString("utf8");
108
- this._buffer = this._buffer.slice(this._contentLength);
109
- this._contentLength = -1;
110
- try {
111
- const msg = JSON.parse(raw);
112
- this._handleMessage(msg);
113
- } catch (e) {
114
- console.error("[pursr-mcp] invalid JSON:", e.message);
115
- }
116
- } else break;
117
- }
118
- }
119
-
120
- _send(msg) {
121
- const json = JSON.stringify(msg);
122
- const bytes = Buffer.from(json, "utf8");
123
- const header = `Content-Length: ${bytes.length}\r\n\r\n`;
124
- process.stdout.write(header);
125
- process.stdout.write(bytes);
126
- }
127
-
128
- // ── JSON-RPC dispatcher ─────────────────────────────────────────────
129
-
130
- async _handleMessage(msg) {
131
- if (!msg || msg.jsonrpc !== "2.0" || !msg.method) {
132
- console.error("[pursr-mcp] skipping non-JSON-RPC message");
133
- return;
134
- }
135
- const { method, id } = msg;
136
-
137
- // Notifications — no id → no response
138
- if (method === "notifications/initialized" || method === "notifications/cancelled") {
139
- if (method === "notifications/initialized") this._initialized = true;
140
- return;
141
- }
142
- if (id === undefined || id === null) return; // unnamed notification
143
-
144
- try {
145
- switch (method) {
146
- case "initialize":
147
- this._initialized = true;
148
- this._send({
149
- jsonrpc: "2.0", id,
150
- result: {
151
- protocolVersion: msg.params?.protocolVersion || "2024-11-05",
152
- capabilities: { tools: {} },
153
- serverInfo: { name: "pursr", version: MCP_VERSION },
154
- },
155
- });
156
- break;
157
-
158
- case "tools/list":
159
- this._send({ jsonrpc: "2.0", id, result: { tools: this._toolDefs() } });
160
- break;
161
-
162
- case "resources/list":
163
- this._send({ jsonrpc: "2.0", id, result: { resources: listResources().map(this._toMcpResource, this) } });
164
- break;
165
-
166
- case "resources/read":
167
- if (!msg.params?.uri) throw new McpError(-32602, "Missing uri");
168
- const data = readResource(msg.params.uri);
169
- if (!data) throw new McpError(-32602, "Resource not found: " + msg.params.uri);
170
- this._send({ jsonrpc: "2.0", id, result: { contents: [data] } });
171
- break;
172
-
173
- case "tools/call":
174
- if (!msg.params?.name) throw new McpError(-32602, "Missing tool name");
175
- const result = await this._callTool(msg.params.name, msg.params.arguments || {});
176
- this._send({ jsonrpc: "2.0", id, result: { content: result } });
177
- break;
178
-
179
- default:
180
- this._send({
181
- jsonrpc: "2.0", id,
182
- error: { code: -32601, message: `Unknown method: ${method}` },
183
- });
184
- }
185
- } catch (e) {
186
- if (e instanceof McpError) {
187
- this._send({ jsonrpc: "2.0", id, error: { code: e.code, message: e.message } });
188
- } else {
189
- console.error("[pursr-mcp] handler error:", e.stack || e.message);
190
- this._send({ jsonrpc: "2.0", id, error: { code: -32603, message: e.message } });
191
- }
192
- }
193
- }
95
+ this.transport = new StdioServerTransport();
96
+ this.transport.onclose = () => {
97
+ this.log("stdio transport closed");
98
+ this.sessions.closeAll().catch(() => {});
99
+ };
100
+ await this.sdk.connect(this.transport);
101
+ this.log("server started with official MCP SDK, plugins:", listPlugins());
102
+ }
103
+
104
+ async close() {
105
+ await this.sessions.closeAll();
106
+ await this.sdk.close();
107
+ }
108
+
109
+ _registerSdkHandlers() {
110
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: this._toolDefs() }));
111
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
112
+ try {
113
+ const content = await this._callTool(request.params.name, request.params.arguments || {});
114
+ return { content };
115
+ } catch (error) {
116
+ this.log("tool error:", error.stack || error.message);
117
+ return {
118
+ isError: true,
119
+ content: [{ type: "text", text: JSON.stringify({ error: error.message, code: error.code || -32603 }, null, 2) }],
120
+ };
121
+ }
122
+ });
123
+ this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({
124
+ resources: listResources().map(this._toMcpResource, this),
125
+ }));
126
+ this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
127
+ const data = readResource(request.params.uri);
128
+ if (!data) throw new Error("Resource not found: " + request.params.uri);
129
+ return { contents: [data] };
130
+ });
131
+ }
194
132
 
195
133
  // ── Resource shape adapter ─────────────────────────────────────────
196
134
 
@@ -205,10 +143,88 @@ class PursrMCPServer {
205
143
 
206
144
  // ── Tool definitions ────────────────────────────────────────────────
207
145
 
208
- _toolDefs() {
209
- return [
210
- {
211
- name: "pursr_shoot",
146
+ _toolDefs() {
147
+ return [
148
+ {
149
+ name: "pursr_session_open",
150
+ description: "Open a persistent browser tab for iterative agent work. State, hover, scroll, dialogs, and navigation persist until closed.",
151
+ inputSchema: {
152
+ type: "object",
153
+ properties: {
154
+ url: { type: "string", description: "Initial URL" },
155
+ sessionId: { type: "string", description: "Stable session name; generated when omitted" },
156
+ preset: { type: "string", description: "Viewport preset" },
157
+ width: { type: "number" }, height: { type: "number" }, dpr: { type: "number" },
158
+ storageState: { description: "Playwright storageState object or file path" },
159
+ },
160
+ required: ["url"],
161
+ },
162
+ },
163
+ {
164
+ name: "pursr_sessions",
165
+ description: "List active persistent browser sessions.",
166
+ inputSchema: { type: "object", properties: {} },
167
+ },
168
+ {
169
+ name: "pursr_snapshot",
170
+ description: "Read the current rendered state from a persistent session as concise visible nodes, geometry, semantics, and computed visual styles.",
171
+ inputSchema: {
172
+ type: "object",
173
+ properties: {
174
+ sessionId: { type: "string" }, selector: { type: "string", description: "CSS root selector (default body)" },
175
+ maxNodes: { type: "number", description: "Maximum returned nodes, 1-1000" },
176
+ includeStyles: { type: "boolean", description: "Include compact computed styles (default true)" },
177
+ },
178
+ required: ["sessionId"],
179
+ },
180
+ },
181
+ {
182
+ name: "pursr_act",
183
+ description: "Perform ordered actions in a persistent session. Supported types: click, hover, fill, type, check, select, press, scroll, wait, sleep, navigate, reload, eval.",
184
+ inputSchema: {
185
+ type: "object",
186
+ properties: {
187
+ sessionId: { type: "string" },
188
+ actions: { type: "array", minItems: 1, maxItems: 50, items: { type: "object" } },
189
+ },
190
+ required: ["sessionId", "actions"],
191
+ },
192
+ },
193
+ {
194
+ name: "pursr_screenshot",
195
+ description: "Capture the current persistent session and return the PNG directly to the model as image content.",
196
+ inputSchema: {
197
+ type: "object",
198
+ properties: {
199
+ sessionId: { type: "string" }, out: { type: "string" }, full: { type: "boolean" },
200
+ selector: { type: "string", description: "Capture only the first matching element" },
201
+ },
202
+ required: ["sessionId"],
203
+ },
204
+ },
205
+ {
206
+ name: "pursr_inspect",
207
+ description: "Inspect one rendered element: HTML, exact geometry, computed style, and clipping/stacking ancestors.",
208
+ inputSchema: {
209
+ type: "object", properties: { sessionId: { type: "string" }, selector: { type: "string" } }, required: ["sessionId", "selector"],
210
+ },
211
+ },
212
+ {
213
+ name: "pursr_diagnostics",
214
+ description: "Read console messages, page errors, failed requests, and HTTP 4xx/5xx responses accumulated during a persistent session.",
215
+ inputSchema: {
216
+ type: "object", properties: { sessionId: { type: "string" }, clear: { type: "boolean" } }, required: ["sessionId"],
217
+ },
218
+ },
219
+ {
220
+ name: "pursr_session_close",
221
+ description: "Close a persistent browser session and release its browser process.",
222
+ inputSchema: {
223
+ type: "object", properties: { sessionId: { type: "string" } }, required: ["sessionId"],
224
+ },
225
+ },
226
+ {
227
+ name: "pursr_shoot",
212
228
  description: "Capture a screenshot of a URL with full feature control (viewport, grid, layer, cursor, camera, animation freeze). Returns PNG path and sidecar metadata.",
213
229
  inputSchema: {
214
230
  type: "object",
@@ -354,9 +370,17 @@ class PursrMCPServer {
354
370
 
355
371
  // ── Tool dispatcher ─────────────────────────────────────────────────
356
372
 
357
- async _callTool(name, args) {
358
- switch (name) {
359
- case "pursr_shoot": return await this._shoot(args);
373
+ async _callTool(name, args) {
374
+ switch (name) {
375
+ case "pursr_session_open": return await this._sessionOpen(args);
376
+ case "pursr_sessions": return this._text(this.sessions.list());
377
+ case "pursr_snapshot": return await this._sessionSnapshot(args);
378
+ case "pursr_act": return await this._sessionAct(args);
379
+ case "pursr_screenshot": return await this._sessionScreenshot(args);
380
+ case "pursr_inspect": return await this._sessionInspect(args);
381
+ case "pursr_diagnostics": return this._sessionDiagnostics(args);
382
+ case "pursr_session_close": return await this._sessionClose(args);
383
+ case "pursr_shoot": return await this._shoot(args);
360
384
  case "pursr_diff": return await this._diff(args);
361
385
  case "pursr_sweep": return await this._sweep(args);
362
386
  case "pursr_frames": return await this._frames(args);
@@ -368,9 +392,61 @@ class PursrMCPServer {
368
392
  }
369
393
  }
370
394
 
371
- // ── Tool implementations ────────────────────────────────────────────
372
-
373
- async _shoot(args) {
395
+ // ── Tool implementations ────────────────────────────────────────────
396
+
397
+ _text(value) {
398
+ return [{ type: "text", text: JSON.stringify(value, null, 2) }];
399
+ }
400
+
401
+ _requireSessionId(args) {
402
+ if (!args.sessionId) throw new McpError(-32602, "Missing required: sessionId");
403
+ return args.sessionId;
404
+ }
405
+
406
+ async _sessionOpen(args) {
407
+ if (!args.url) throw new McpError(-32602, "Missing required: url");
408
+ const flags = { preset: args.preset, width: args.width, height: args.height, dpr: args.dpr };
409
+ const result = await this.sessions.open({ sessionId: args.sessionId, url: args.url, flags, storageState: args.storageState });
410
+ return this._text(result);
411
+ }
412
+
413
+ async _sessionSnapshot(args) {
414
+ const result = await this.sessions.snapshot(this._requireSessionId(args), args);
415
+ return this._text(result);
416
+ }
417
+
418
+ async _sessionAct(args) {
419
+ const result = await this.sessions.act(this._requireSessionId(args), args.actions);
420
+ return this._text(result);
421
+ }
422
+
423
+ async _sessionScreenshot(args) {
424
+ const result = await this.sessions.screenshot(this._requireSessionId(args), args);
425
+ recordResource({
426
+ kind: "session", id: args.sessionId, name: `session screenshot: ${args.sessionId}`,
427
+ description: result.url, uri: `pursr://session/${encodeURIComponent(args.sessionId)}`,
428
+ mimeType: result.mimeType, file: result.out, meta: { url: result.url, ts: nowIso() },
429
+ });
430
+ return [
431
+ { type: "text", text: JSON.stringify({ sessionId: result.sessionId, out: result.out, url: result.url }, null, 2) },
432
+ { type: "image", data: result.data, mimeType: result.mimeType },
433
+ ];
434
+ }
435
+
436
+ async _sessionInspect(args) {
437
+ const result = await this.sessions.inspect(this._requireSessionId(args), args.selector);
438
+ return this._text(result);
439
+ }
440
+
441
+ _sessionDiagnostics(args) {
442
+ return this._text(this.sessions.diagnostics(this._requireSessionId(args), { clear: !!args.clear }));
443
+ }
444
+
445
+ async _sessionClose(args) {
446
+ return this._text(await this.sessions.close(this._requireSessionId(args)));
447
+ }
448
+
449
+ async _shoot(args) {
374
450
  const url = args.url;
375
451
  if (!url) throw new McpError(-32602, "Missing required: url");
376
452
 
@@ -397,7 +473,9 @@ class PursrMCPServer {
397
473
  file: out, meta: { url, flags, ts: sidecar?.ts },
398
474
  });
399
475
 
400
- return [{ type: "text", text: JSON.stringify({ out, meta: sidecar }, null, 2) }];
476
+ const content = [{ type: "text", text: JSON.stringify({ out, meta: sidecar }, null, 2) }];
477
+ if (existsSync(out)) content.push({ type: "image", data: readFileSync(out).toString("base64"), mimeType: "image/png" });
478
+ return content;
401
479
  }
402
480
 
403
481
  async _diff(args) {
@@ -414,7 +492,9 @@ class PursrMCPServer {
414
492
  if (k !== "url" && k !== "ref" && k !== "out" && k !== "threshold") flags[k] = v;
415
493
  }
416
494
  const result = await runDiff(url, ref, out, threshold, flags);
417
- return [{ type: "text", text: JSON.stringify(result, null, 2) }];
495
+ const content = [{ type: "text", text: JSON.stringify(result, null, 2) }];
496
+ if (existsSync(out)) content.push({ type: "image", data: readFileSync(out).toString("base64"), mimeType: "image/png" });
497
+ return content;
418
498
  }
419
499
 
420
500
  async _sweep(args) {
package/src/session.js ADDED
@@ -0,0 +1,204 @@
1
+ // Persistent browser sessions for agent-driven visual QA.
2
+
3
+ import { mkdirSync, readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { launch, newPage } from "./runway.js";
6
+ import { resolveViewport } from "./viewport.js";
7
+ import { gotoOrThrow, settle, CLICK_TIMEOUT_MS, WAIT_DEFAULT_TIMEOUT_MS } from "./overlays.js";
8
+ import { resolveLocator } from "./selector.js";
9
+
10
+ const MAX_DIAGNOSTICS = 250;
11
+ const MAX_ACTIONS = 50;
12
+
13
+ function cleanId(value) {
14
+ const id = String(value || "").trim();
15
+ if (!id) return `session-${Date.now().toString(36)}`;
16
+ if (!/^[a-zA-Z0-9._-]{1,80}$/.test(id)) throw new Error("sessionId must use only letters, numbers, dot, underscore, or dash");
17
+ return id;
18
+ }
19
+
20
+ function pushCapped(list, value) {
21
+ list.push(value);
22
+ if (list.length > MAX_DIAGNOSTICS) list.splice(0, list.length - MAX_DIAGNOSTICS);
23
+ }
24
+
25
+ function attachDiagnostics(page, diagnostics) {
26
+ page.on("console", (msg) => pushCapped(diagnostics.console, { type: msg.type(), text: msg.text(), ts: new Date().toISOString() }));
27
+ page.on("pageerror", (error) => pushCapped(diagnostics.errors, { message: error.message, stack: error.stack || null, ts: new Date().toISOString() }));
28
+ page.on("requestfailed", (request) => pushCapped(diagnostics.requests, {
29
+ method: request.method(), url: request.url(), failure: request.failure()?.errorText || "failed", ts: new Date().toISOString(),
30
+ }));
31
+ page.on("response", (response) => {
32
+ if (response.status() < 400) return;
33
+ pushCapped(diagnostics.responses, {
34
+ status: response.status(), method: response.request().method(), url: response.url(), ts: new Date().toISOString(),
35
+ });
36
+ });
37
+ }
38
+
39
+ export class BrowserSessionManager {
40
+ constructor({ launchBrowser = launch, outputDir = process.cwd() } = {}) {
41
+ this.launchBrowser = launchBrowser;
42
+ this.outputDir = outputDir;
43
+ this.sessions = new Map();
44
+ }
45
+
46
+ get size() { return this.sessions.size; }
47
+
48
+ get(sessionId) {
49
+ const session = this.sessions.get(String(sessionId || ""));
50
+ if (!session) throw new Error(`unknown session: ${sessionId}`);
51
+ return session;
52
+ }
53
+
54
+ list() {
55
+ return [...this.sessions.values()].map(({ id, page, viewport, createdAt }) => ({ sessionId: id, url: page.url(), viewport, createdAt }));
56
+ }
57
+
58
+ async open({ sessionId, url, flags = {}, storageState } = {}) {
59
+ if (!url) throw new Error("url is required");
60
+ const id = cleanId(sessionId);
61
+ if (this.sessions.has(id)) await this.close(id);
62
+ const browser = await this.launchBrowser();
63
+ try {
64
+ const viewport = resolveViewport(flags);
65
+ const page = await newPage(browser, viewport, { storageState });
66
+ const diagnostics = { console: [], errors: [], requests: [], responses: [] };
67
+ attachDiagnostics(page, diagnostics);
68
+ const nav = await gotoOrThrow(page, url, { timeoutMs: flags.timeoutMs });
69
+ await settle(page);
70
+ const session = { id, browser, page, context: page._pursrContext, viewport, diagnostics, createdAt: new Date().toISOString() };
71
+ this.sessions.set(id, session);
72
+ return { sessionId: id, url: page.url(), title: await page.title(), viewport, status: nav.status, createdAt: session.createdAt };
73
+ } catch (error) {
74
+ try { await browser.close(); } catch {}
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ async snapshot(sessionId, { selector = "body", maxNodes = 250, includeStyles = true } = {}) {
80
+ const { page } = this.get(sessionId);
81
+ const limit = Math.max(1, Math.min(1000, Number(maxNodes) || 250));
82
+ return await page.evaluate(({ selector, limit, includeStyles }) => {
83
+ const roots = [...document.querySelectorAll(selector)];
84
+ const elements = roots.flatMap((root) => [root, ...root.querySelectorAll("*")]);
85
+ const nodes = [];
86
+ for (const el of elements) {
87
+ if (nodes.length >= limit) break;
88
+ const rect = el.getBoundingClientRect();
89
+ const style = getComputedStyle(el);
90
+ if (rect.width <= 0 || rect.height <= 0 || style.visibility === "hidden" || style.display === "none") continue;
91
+ const text = (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 160) || null;
92
+ const item = {
93
+ node: nodes.length + 1, tag: el.tagName.toLowerCase(), id: el.id || null,
94
+ role: el.getAttribute("role") || null,
95
+ name: el.getAttribute("aria-label") || el.getAttribute("alt") || el.getAttribute("title") || text,
96
+ text, rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
97
+ state: { disabled: "disabled" in el ? !!el.disabled : undefined, checked: "checked" in el ? !!el.checked : undefined, expanded: el.getAttribute("aria-expanded") },
98
+ };
99
+ if (includeStyles) item.style = {
100
+ display: style.display, position: style.position, zIndex: style.zIndex,
101
+ overflow: `${style.overflowX} ${style.overflowY}`, opacity: style.opacity,
102
+ color: style.color, backgroundColor: style.backgroundColor,
103
+ font: `${style.fontWeight} ${style.fontSize}/${style.lineHeight} ${style.fontFamily}`,
104
+ transform: style.transform, boxShadow: style.boxShadow,
105
+ };
106
+ nodes.push(item);
107
+ }
108
+ return { url: location.href, title: document.title, selector, truncated: elements.length > limit, nodes };
109
+ }, { selector, limit, includeStyles: includeStyles !== false });
110
+ }
111
+
112
+ async inspect(sessionId, selector) {
113
+ if (!selector) throw new Error("selector is required");
114
+ const { page } = this.get(sessionId);
115
+ const locator = await resolveLocator(page, selector);
116
+ await locator.first().waitFor({ state: "attached", timeout: CLICK_TIMEOUT_MS });
117
+ return await locator.first().evaluate((el) => {
118
+ const rect = el.getBoundingClientRect();
119
+ const style = getComputedStyle(el);
120
+ const ancestors = [];
121
+ for (let node = el.parentElement; node && ancestors.length < 6; node = node.parentElement) {
122
+ const s = getComputedStyle(node);
123
+ ancestors.push({ tag: node.tagName.toLowerCase(), id: node.id || null, position: s.position, overflow: `${s.overflowX} ${s.overflowY}`, zIndex: s.zIndex, transform: s.transform });
124
+ }
125
+ const computedStyle = {};
126
+ for (const key of ["display","position","inset","width","height","margin","padding","gap","overflow","opacity","visibility","zIndex","transform","transformOrigin","color","background","border","borderRadius","boxShadow","fontFamily","fontSize","fontWeight","lineHeight","textAlign","objectFit","pointerEvents"]) computedStyle[key] = style[key];
127
+ return { tag: el.tagName.toLowerCase(), html: el.outerHTML.slice(0, 2000), rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, computedStyle, ancestors };
128
+ });
129
+ }
130
+
131
+ async act(sessionId, actions = []) {
132
+ if (!Array.isArray(actions) || !actions.length) throw new Error("actions must be a non-empty array");
133
+ if (actions.length > MAX_ACTIONS) throw new Error(`actions cannot exceed ${MAX_ACTIONS}`);
134
+ const { page } = this.get(sessionId);
135
+ const trace = [];
136
+ for (let i = 0; i < actions.length; i++) {
137
+ const action = actions[i] || {};
138
+ const op = action.type || action.op;
139
+ const step = { index: i, type: op };
140
+ try {
141
+ if (["click", "hover", "fill", "type", "check", "select"].includes(op)) {
142
+ const locator = await resolveLocator(page, action.selector);
143
+ await locator.first().waitFor({ state: "visible", timeout: action.timeoutMs || CLICK_TIMEOUT_MS });
144
+ if (op === "click") await locator.first().click();
145
+ else if (op === "hover") await locator.first().hover();
146
+ else if (op === "fill") await locator.first().fill(String(action.text ?? action.value ?? ""));
147
+ else if (op === "type") await locator.first().pressSequentially(String(action.text ?? ""), { delay: action.delayMs || 10 });
148
+ else if (op === "check") await locator.first().setChecked(action.checked !== false);
149
+ else await locator.first().selectOption(action.value);
150
+ step.selector = action.selector;
151
+ } else if (op === "press") await page.keyboard.press(String(action.key));
152
+ else if (op === "scroll") await page.mouse.wheel(Number(action.deltaX) || 0, Number(action.deltaY) || 0);
153
+ else if (op === "wait") await (await resolveLocator(page, action.selector)).first().waitFor({ state: action.state || "visible", timeout: action.timeoutMs || WAIT_DEFAULT_TIMEOUT_MS });
154
+ else if (op === "sleep") await page.waitForTimeout(Math.max(0, Number(action.ms) || 0));
155
+ else if (op === "navigate") await gotoOrThrow(page, action.url, { timeoutMs: action.timeoutMs });
156
+ else if (op === "reload") await page.reload({ waitUntil: "domcontentloaded" });
157
+ else if (op === "eval") step.result = await page.evaluate(String(action.js || ""));
158
+ else throw new Error(`unknown action type: ${op}`);
159
+ if (action.settleMs) await page.waitForTimeout(Number(action.settleMs));
160
+ step.ok = true;
161
+ } catch (error) {
162
+ step.ok = false; step.error = error.message; trace.push(step); break;
163
+ }
164
+ trace.push(step);
165
+ }
166
+ return { sessionId, url: page.url(), title: await page.title(), trace, failed: trace.some((step) => !step.ok) };
167
+ }
168
+
169
+ async screenshot(sessionId, { out, full = false, selector } = {}) {
170
+ const { page } = this.get(sessionId);
171
+ const file = out || join(this.outputDir, `pursr-${sessionId}-${Date.now()}.png`);
172
+ mkdirSync(dirname(file), { recursive: true });
173
+ if (selector) {
174
+ const locator = await resolveLocator(page, selector);
175
+ await locator.first().screenshot({ path: file });
176
+ } else await page.screenshot({ path: file, fullPage: !!full });
177
+ return { sessionId, out: file, url: page.url(), data: readFileSync(file).toString("base64"), mimeType: "image/png" };
178
+ }
179
+
180
+ diagnostics(sessionId, { clear = false } = {}) {
181
+ const session = this.get(sessionId);
182
+ const result = JSON.parse(JSON.stringify(session.diagnostics));
183
+ if (clear) {
184
+ session.diagnostics.console.length = 0;
185
+ session.diagnostics.errors.length = 0;
186
+ session.diagnostics.requests.length = 0;
187
+ session.diagnostics.responses.length = 0;
188
+ }
189
+ return { sessionId, ...result };
190
+ }
191
+
192
+ async close(sessionId) {
193
+ const id = String(sessionId || "");
194
+ const session = this.sessions.get(id);
195
+ if (!session) return { sessionId: id, closed: false };
196
+ this.sessions.delete(id);
197
+ try { await session.browser.close(); } catch {}
198
+ return { sessionId: id, closed: true };
199
+ }
200
+
201
+ async closeAll() {
202
+ await Promise.all([...this.sessions.keys()].map((id) => this.close(id)));
203
+ }
204
+ }