scenescout 1.0.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.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +429 -0
- package/dist/cli.js +269 -0
- package/dist/engine/authloss.js +125 -0
- package/dist/engine/browser.js +1954 -0
- package/dist/engine/collector.js +266 -0
- package/dist/engine/design.js +716 -0
- package/dist/engine/dispatch.js +100 -0
- package/dist/engine/fingerprint.js +100 -0
- package/dist/engine/fixtures.js +162 -0
- package/dist/engine/journey.js +71 -0
- package/dist/engine/launch.js +25 -0
- package/dist/engine/memory.js +1116 -0
- package/dist/engine/oracles.js +187 -0
- package/dist/engine/ownership.js +223 -0
- package/dist/engine/policy.js +84 -0
- package/dist/engine/probes.js +293 -0
- package/dist/engine/reaper.js +72 -0
- package/dist/engine/report.js +515 -0
- package/dist/engine/uploads.js +74 -0
- package/dist/installer.js +315 -0
- package/dist/mcp-server.js +810 -0
- package/dist/scan.js +335 -0
- package/package.json +86 -0
- package/skills/scenescout/SKILL.md +96 -0
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SceneScout MCP server (stdio).
|
|
4
|
+
*
|
|
5
|
+
* Exposes deterministic browser-exploration tools — Playwright actions, state
|
|
6
|
+
* memory, oracles, findings, report — to any MCP client. No LLM calls happen
|
|
7
|
+
* here: the client (e.g. Claude Code on a subscription) is the brain.
|
|
8
|
+
*
|
|
9
|
+
* The server process is a per-conversation daemon and behaves like one:
|
|
10
|
+
* - Multi-session, genuinely concurrent: named sessions each own a live
|
|
11
|
+
* browser (scout_attach {session}); every per-session tool takes an optional
|
|
12
|
+
* `session` override so a controller can dispatch commands to MULTIPLE
|
|
13
|
+
* sessions in parallel — the two calls actually run concurrently, not
|
|
14
|
+
* one at a time — while calls targeting the SAME session still serialize
|
|
15
|
+
* (a single browser's ref table/fingerprint is shared mutable state and
|
|
16
|
+
* cannot process overlapping actions). scout_session sets a convenience
|
|
17
|
+
* default so single-session workflows never need to pass `session`.
|
|
18
|
+
* - Watchdog: every tool call has a hard time budget — a wedged browser
|
|
19
|
+
* returns a diagnosable error instead of hanging the conversation, and
|
|
20
|
+
* that session's queue keeps moving (other sessions are unaffected).
|
|
21
|
+
* - Self-healing: orphaned browser processes from crashed runs are reaped at
|
|
22
|
+
* startup and on launch failure; attach retries once after reaping.
|
|
23
|
+
* - Observable: .scenescout/status.json in the tested project always shows
|
|
24
|
+
* what each session is doing right now (`scenescout status <project>`).
|
|
25
|
+
*/
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
29
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
import { BrowserEngine } from "./engine/browser.js";
|
|
32
|
+
import { reapOrphanBrowsers } from "./engine/reaper.js";
|
|
33
|
+
import { MemoryStore, redactSecrets } from "./engine/memory.js";
|
|
34
|
+
import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
|
|
35
|
+
import { FIXTURE_KINDS } from "./engine/fixtures.js";
|
|
36
|
+
import { computeGaps, formatRouteCoverage, generateReport } from "./engine/report.js";
|
|
37
|
+
import { formatScan, scanProject } from "./scan.js";
|
|
38
|
+
/** Live sessions: each name owns an independent BrowserEngine (browser + auth). */
|
|
39
|
+
const engines = new Map();
|
|
40
|
+
/**
|
|
41
|
+
* One MemoryStore per project, shared by every session attached to it:
|
|
42
|
+
* findings and coverage from all roles merge, and concurrent engines never
|
|
43
|
+
* race each other's memory.json writes (MemoryStore's own writes are
|
|
44
|
+
* synchronous, so Node's single-threaded execution already serializes them).
|
|
45
|
+
*/
|
|
46
|
+
const memories = new Map();
|
|
47
|
+
/** Convenience default: which session a tool call targets when it omits `session`. */
|
|
48
|
+
let activeName = "default";
|
|
49
|
+
/**
|
|
50
|
+
* Whether the operator CHOSE the current default (via scout_session) rather than
|
|
51
|
+
* it drifting there because that session attached last. Only the drifting case
|
|
52
|
+
* is worth warning about; nagging after a deliberate choice trains the reader
|
|
53
|
+
* to ignore the warning, and scout_session's own description recommends exactly
|
|
54
|
+
* that workflow for sequential single-role stretches.
|
|
55
|
+
*/
|
|
56
|
+
let activeNameIsExplicit = false;
|
|
57
|
+
function engineFor(session) {
|
|
58
|
+
let e = engines.get(session);
|
|
59
|
+
if (!e) {
|
|
60
|
+
e = new BrowserEngine();
|
|
61
|
+
e.sessionKey = session;
|
|
62
|
+
engines.set(session, e);
|
|
63
|
+
}
|
|
64
|
+
return e;
|
|
65
|
+
}
|
|
66
|
+
const PKG_VERSION = (() => {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return "0.0.0";
|
|
72
|
+
}
|
|
73
|
+
})();
|
|
74
|
+
const server = new McpServer({ name: "scenescout", version: PKG_VERSION });
|
|
75
|
+
function text(t, session) {
|
|
76
|
+
const eng = engines.get(session);
|
|
77
|
+
const prefix = engines.size > 1 ? `[session ${session}${eng ? ` · ${eng.role}` : ""}]\n` : "";
|
|
78
|
+
return { content: [{ type: "text", text: prefix + t }] };
|
|
79
|
+
}
|
|
80
|
+
function errorText(err) {
|
|
81
|
+
return {
|
|
82
|
+
content: [{ type: "text", text: `ERROR: ${err instanceof Error ? err.message : String(err)}` }],
|
|
83
|
+
isError: true,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Live status for the tested project (`scenescout status <project>` or any
|
|
88
|
+
* supervising layer reads this): which session/tool is running right now.
|
|
89
|
+
* Best-effort — observability must never break the tool call itself.
|
|
90
|
+
*/
|
|
91
|
+
function writeStatus(session, phase, tool) {
|
|
92
|
+
const dir = engines.get(session)?.memory?.dir;
|
|
93
|
+
if (!dir)
|
|
94
|
+
return;
|
|
95
|
+
// Fire-and-forget async write: status is best-effort observability and runs
|
|
96
|
+
// on every tool call's hot path — it must never add blocking filesystem
|
|
97
|
+
// latency. Two sessions writing concurrently is a benign last-write-wins on
|
|
98
|
+
// this one project-level file; each session's OWN status still reaches disk.
|
|
99
|
+
void fs.promises
|
|
100
|
+
.writeFile(path.join(dir, "status.json"), JSON.stringify({
|
|
101
|
+
pid: process.pid,
|
|
102
|
+
phase,
|
|
103
|
+
tool,
|
|
104
|
+
session,
|
|
105
|
+
role: engines.get(session)?.role ?? "anonymous",
|
|
106
|
+
sessions: [...engines.keys()],
|
|
107
|
+
// status.json is a poll target that gets pasted into bug reports.
|
|
108
|
+
url: redactSecrets(engines.get(session)?.currentUrl ?? ""),
|
|
109
|
+
at: new Date().toISOString(),
|
|
110
|
+
}, null, 2))
|
|
111
|
+
.catch(() => { });
|
|
112
|
+
}
|
|
113
|
+
/** The watchdog's timeout answer — a diagnosable result, not a hang. */
|
|
114
|
+
function watchdogTimeout(label, ms) {
|
|
115
|
+
return errorText(new Error(`${label} timed out after ${Math.round(ms / 1000)}s — the browser may be wedged (stuck navigation, dialog, or hung renderer). ` +
|
|
116
|
+
`The operation may still complete in the background; if subsequent calls misbehave, scout_attach again to reset the session (orphaned browser processes are reaped automatically).`));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Per-SESSION serialization: a single browser's ref table/fingerprint is
|
|
120
|
+
* shared mutable state, so two calls against the SAME session must never
|
|
121
|
+
* interleave. Two calls against DIFFERENT sessions have no shared state
|
|
122
|
+
* (each BrowserEngine is independent) and run genuinely concurrently — this is
|
|
123
|
+
* what makes `scout_click({session:"admin"})` and `scout_click({session:"qa"})`
|
|
124
|
+
* issued in one turn actually execute in parallel instead of queueing behind
|
|
125
|
+
* each other. The queue itself lives in engine/dispatch.ts, where it is tested.
|
|
126
|
+
*/
|
|
127
|
+
const sessionQueue = new SessionQueue();
|
|
128
|
+
function serializedPerSession(label, fn, timeoutMs = 60_000) {
|
|
129
|
+
return (args) => {
|
|
130
|
+
const session = args.session ?? activeName;
|
|
131
|
+
const exec = async () => {
|
|
132
|
+
writeStatus(session, "running", label);
|
|
133
|
+
try {
|
|
134
|
+
const out = await withWatchdog(label, fn(args, session), timeoutMs, watchdogTimeout);
|
|
135
|
+
// `activeName` is process-global and every scout_attach moves it. With
|
|
136
|
+
// several sessions live — the multi-role runs this tool encourages —
|
|
137
|
+
// an omitted `session` silently binds to whichever browser attached
|
|
138
|
+
// most recently, which may belong to another agent entirely. Say so
|
|
139
|
+
// rather than letting the call look deliberate.
|
|
140
|
+
if (!args.session && engines.size > 1 && !activeNameIsExplicit) {
|
|
141
|
+
out.content.push({
|
|
142
|
+
type: "text",
|
|
143
|
+
text: `\n⚠ AMBIGUOUS SESSION — ${engines.size} sessions are live and this call named none, so it ran against '${session}' ` +
|
|
144
|
+
`(whichever attached most recently). Pass session:"…" explicitly; the default is not stable while other sessions are attaching.`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
writeStatus(session, "idle", label);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
return sessionQueue.run(session, exec);
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/** Control-plane tools (scout_scan/scout_session/scout_close-all) don't target one browser — their own tiny chain keeps them off session queues without racing each other. */
|
|
157
|
+
let controlChain = Promise.resolve();
|
|
158
|
+
function serializedControl(fn) {
|
|
159
|
+
return (...args) => {
|
|
160
|
+
const run = controlChain.then(() => fn(...args), () => fn(...args));
|
|
161
|
+
controlChain = run.catch(() => { });
|
|
162
|
+
return run;
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const sessionParam = z
|
|
166
|
+
.string()
|
|
167
|
+
.max(40)
|
|
168
|
+
.optional()
|
|
169
|
+
.describe("Target this session directly instead of the active one — pass it explicitly when dispatching to MULTIPLE sessions in one turn (e.g. two scout_click calls with different `session`), which then run CONCURRENTLY rather than queueing. Omit for single-session sequential use.");
|
|
170
|
+
server.registerTool("scout_scan", {
|
|
171
|
+
description: "Scan a project directory to discover the frontend workspace, framework, routes, dev command, Playwright auth storage states, and testid conventions. Run this first.",
|
|
172
|
+
inputSchema: { projectPath: z.string().describe("Absolute path to the project root") },
|
|
173
|
+
}, serializedControl(async ({ projectPath }) => {
|
|
174
|
+
try {
|
|
175
|
+
return text(formatScan(scanProject(projectPath)), activeName);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
return errorText(err);
|
|
179
|
+
}
|
|
180
|
+
}));
|
|
181
|
+
server.registerTool("scout_attach", {
|
|
182
|
+
description: "Launch a browser and attach to a running web app. Write policy is enforced at the NETWORK layer: mode='read-only' (default) blocks destructive-labeled elements AND all PUT/PATCH/DELETE + destructive POSTs; mode='safe-write' allows creating data and permits updates/deletes ONLY on resources this session created (use when the user wants create/edit flows tested); mode='destructive' allows everything — ONLY when the user explicitly confirmed a disposable/seeded environment. Pass a Playwright storage-state JSON to explore as an authenticated role. Pass `session` to keep MULTIPLE roles alive at once (one browser each, genuinely concurrent) for collaboration testing — target each directly with every tool's `session` param, or use scout_session to set which one is the default; coverage and findings merge into one project memory.",
|
|
183
|
+
inputSchema: {
|
|
184
|
+
url: z.string().describe("Base URL of the running app, e.g. http://localhost:3000"),
|
|
185
|
+
projectPath: z.string().describe("Absolute path to the project (memory + report live in .scenescout/ here)"),
|
|
186
|
+
storageStatePath: z.string().optional().describe("Optional Playwright storage-state JSON path for authenticated exploration"),
|
|
187
|
+
mode: z
|
|
188
|
+
.enum(["read-only", "safe-write", "destructive"])
|
|
189
|
+
.default("read-only")
|
|
190
|
+
.describe("Write policy (see tool description). Never choose 'destructive' yourself — user opt-in only."),
|
|
191
|
+
headed: z.boolean().default(false).describe("Show the browser window"),
|
|
192
|
+
viewportWidth: z.number().int().min(320).max(3840).optional().describe("Viewport width (default 1280); use e.g. 390 for a mobile pass"),
|
|
193
|
+
viewportHeight: z.number().int().min(480).max(2400).optional().describe("Viewport height (default 900)"),
|
|
194
|
+
session: z
|
|
195
|
+
.string()
|
|
196
|
+
.max(40)
|
|
197
|
+
.optional()
|
|
198
|
+
.describe("Session name for multi-role runs (e.g. 'admin', 'qa'). Creates/replaces that session's browser and makes it the default. Default: 'default'."),
|
|
199
|
+
},
|
|
200
|
+
}, serializedControl(async ({ url, projectPath, storageStatePath, mode, headed, viewportWidth, viewportHeight, session, }) => {
|
|
201
|
+
try {
|
|
202
|
+
const target = session ?? activeName;
|
|
203
|
+
if (session) {
|
|
204
|
+
activeName = session;
|
|
205
|
+
activeNameIsExplicit = false;
|
|
206
|
+
}
|
|
207
|
+
const eng = engineFor(target);
|
|
208
|
+
// Key by the RESOLVED, symlink-free path. Keyed by the raw string,
|
|
209
|
+
// "/p" and "/p/" — or a symlink, or a case-variant on a
|
|
210
|
+
// case-insensitive filesystem — built two MemoryStore instances over
|
|
211
|
+
// one file inside a single process. Each held its own snapshot and
|
|
212
|
+
// flushed it wholesale, so the second one to write silently erased the
|
|
213
|
+
// first one's findings, with no second process involved.
|
|
214
|
+
// The directory must EXIST before realpath can resolve it, and on a
|
|
215
|
+
// first attach it does not — MemoryStore's constructor is what creates
|
|
216
|
+
// it. Resolving before that threw, fell back to the raw string, and the
|
|
217
|
+
// next attach then resolved successfully to a different key: two stores
|
|
218
|
+
// over one file, which is the exact bug this keying prevents. (On macOS
|
|
219
|
+
// any path under /tmp hits this, since /tmp is a symlink to /private/tmp.)
|
|
220
|
+
fs.mkdirSync(path.resolve(projectPath), { recursive: true });
|
|
221
|
+
let storeKey;
|
|
222
|
+
try {
|
|
223
|
+
storeKey = fs.realpathSync(path.resolve(projectPath));
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
storeKey = path.resolve(projectPath);
|
|
227
|
+
}
|
|
228
|
+
let store = memories.get(storeKey);
|
|
229
|
+
if (!store) {
|
|
230
|
+
store = new MemoryStore(projectPath);
|
|
231
|
+
memories.set(storeKey, store);
|
|
232
|
+
}
|
|
233
|
+
// Cross-process conflict detection: another live SceneScout attached
|
|
234
|
+
// to the same project shares .scenescout memory files with this one.
|
|
235
|
+
let conflictNote = "";
|
|
236
|
+
try {
|
|
237
|
+
const statusPath = path.join(projectPath, ".scenescout", "status.json");
|
|
238
|
+
if (fs.existsSync(statusPath)) {
|
|
239
|
+
const st = JSON.parse(fs.readFileSync(statusPath, "utf8"));
|
|
240
|
+
if (st.pid && st.pid !== process.pid) {
|
|
241
|
+
let alive = false;
|
|
242
|
+
try {
|
|
243
|
+
process.kill(st.pid, 0);
|
|
244
|
+
alive = true;
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
/* stale */
|
|
248
|
+
}
|
|
249
|
+
if (alive)
|
|
250
|
+
conflictNote =
|
|
251
|
+
`\nNote: another SceneScout process (pid ${st.pid}) is also attached to this project. ` +
|
|
252
|
+
`Findings and coverage from both are merged on write, so neither loses work; ` +
|
|
253
|
+
`named sessions in ONE server (scout_attach {session: "…"}) are still preferred, since only they share safe-write ownership.`;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
/* conflict detection is best-effort */
|
|
259
|
+
}
|
|
260
|
+
const viewport = viewportWidth && viewportHeight ? { width: viewportWidth, height: viewportHeight } : undefined;
|
|
261
|
+
const out = await eng.attach({ url, projectDir: projectPath, storageStatePath, mode, headed, viewport, memoryStore: store });
|
|
262
|
+
eng.role = storageStatePath ? path.basename(storageStatePath).replace(/\.json$/i, "") : "anonymous";
|
|
263
|
+
return text(out + conflictNote + (engines.size > 1 ? `\n${sessionLines()}` : ""), target);
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
return errorText(err);
|
|
267
|
+
}
|
|
268
|
+
}));
|
|
269
|
+
function sessionLines() {
|
|
270
|
+
const lines = ["Live sessions:"];
|
|
271
|
+
for (const [name, eng] of engines) {
|
|
272
|
+
lines.push(` ${name === activeName ? "▶" : " "} ${name} — ${eng.role} · ${eng.mode}${eng.attached ? ` · ${eng.currentUrl || eng.baseUrl}` : " · (closed)"}`);
|
|
273
|
+
}
|
|
274
|
+
return lines.join("\n");
|
|
275
|
+
}
|
|
276
|
+
server.registerTool("scout_session", {
|
|
277
|
+
description: "List live sessions, or set which one is the DEFAULT (used by any tool call that omits `session`). Prefer passing `session` directly on each tool call for multi-role work — that's what lets concurrent dispatch happen; scout_session is for sequential convenience (skip repeating `session` on every call) and for checking what's live. Both browsers stay live and authenticated regardless of which is default — re-snapshot a session after a break to see what changed while it was away.",
|
|
278
|
+
inputSchema: {
|
|
279
|
+
name: z.string().max(40).optional().describe("Session to make the default; omit to list sessions"),
|
|
280
|
+
// Every other session-aware tool spells this `session`. Accepting both
|
|
281
|
+
// costs nothing and removes a guaranteed first-try rejection, since all
|
|
282
|
+
// schemas are additionalProperties:false and reject the near-miss hard.
|
|
283
|
+
session: z.string().max(40).optional().describe("Alias for `name`."),
|
|
284
|
+
},
|
|
285
|
+
}, serializedControl(async ({ name, session }) => {
|
|
286
|
+
try {
|
|
287
|
+
name = name ?? session;
|
|
288
|
+
if (!name)
|
|
289
|
+
return text(sessionLines(), activeName);
|
|
290
|
+
if (!engines.has(name)) {
|
|
291
|
+
return text(`No session named "${name}" yet — create it with scout_attach { session: "${name}", … }.\n${sessionLines()}`, activeName);
|
|
292
|
+
}
|
|
293
|
+
activeName = name;
|
|
294
|
+
activeNameIsExplicit = true;
|
|
295
|
+
const eng = engines.get(name);
|
|
296
|
+
return text(`Default session → ${name} (${eng.role}, ${eng.mode}) · ${eng.attached ? `currently at ${eng.currentUrl}` : "browser not attached"}.\nTake scout_snapshot to see where this role left off (the page may have changed while another role was working).`, name);
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
return errorText(err);
|
|
300
|
+
}
|
|
301
|
+
}));
|
|
302
|
+
server.registerTool("scout_snapshot", {
|
|
303
|
+
description: "Capture the current page state: URL, state fingerprint, interactable elements with refs (e1, e2, …), geometry issues, coverage, and oracle violations since the last action. Re-snapshotting the same route returns a DIFF (refs stay stable). Cheap — prefer this over screenshots.",
|
|
304
|
+
inputSchema: {
|
|
305
|
+
full: z.boolean().default(false).describe("Force a full element list instead of a diff"),
|
|
306
|
+
session: sessionParam,
|
|
307
|
+
},
|
|
308
|
+
}, serializedPerSession("scout_snapshot", async ({ full }, session) => {
|
|
309
|
+
try {
|
|
310
|
+
return text(await engineFor(session).snapshot(full), session);
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
return errorText(err);
|
|
314
|
+
}
|
|
315
|
+
}));
|
|
316
|
+
server.registerTool("scout_crawl", {
|
|
317
|
+
description: "Engine-side route sweep in ONE call: visits each path (default: all known routes not yet visited), records states into coverage memory, and returns a per-route health summary (HTTP status, element count, oracle violations, dead-ends, auth-redirects). Navigation-only — safe in read-only mode. Use this FIRST for broad coverage; explore interactively only where it flags problems or where journeys matter.",
|
|
318
|
+
inputSchema: {
|
|
319
|
+
paths: z.array(z.string()).max(150).optional().describe("Paths to visit, e.g. ['/orders','/settings']. Omit to crawl all unvisited known routes."),
|
|
320
|
+
session: sessionParam,
|
|
321
|
+
},
|
|
322
|
+
}, serializedPerSession("scout_crawl", async ({ paths }, session) => {
|
|
323
|
+
try {
|
|
324
|
+
return text(await engineFor(session).crawl(paths), session);
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
return errorText(err);
|
|
328
|
+
}
|
|
329
|
+
}, 600_000));
|
|
330
|
+
server.registerTool("scout_run_plan", {
|
|
331
|
+
description: "Execute up to 20 actions in ONE call — use for mechanical sequences (fill a form, walk a wizard) so each step doesn't cost a round-trip. Targets resolve at execution time by semantic locator: 'testid=…', 'text=…', or 'label=…' (never snapshot refs). An `upload` step attaches a file as scout_upload does (target required — the file input or the control that opens its chooser; value = a fixture kind or a project-relative path). The plan ABORTS at the first NEW oracle violation, policy refusal, or failed step, returning a transcript of how far it got; repeats of already-reported violations do not abort (they stay logged for the report).",
|
|
332
|
+
inputSchema: {
|
|
333
|
+
steps: z
|
|
334
|
+
.array(z.object({
|
|
335
|
+
action: z.enum(["navigate", "click", "type", "select", "press", "hover", "scroll", "upload"]),
|
|
336
|
+
target: z
|
|
337
|
+
.string()
|
|
338
|
+
.optional()
|
|
339
|
+
.describe("testid=…, text=…, label=… (or a path for navigate; 'top'/'bottom'/±px for scroll; for upload: the file input or the control that opens its chooser)"),
|
|
340
|
+
value: z
|
|
341
|
+
.string()
|
|
342
|
+
.optional()
|
|
343
|
+
.describe("Text to type / option to select / key to press / for upload: a fixture kind (pdf, png, txt, csv, json — blank infers from accept) or a project-relative file path"),
|
|
344
|
+
pressEnter: z.boolean().optional().describe("For type: press Enter after filling"),
|
|
345
|
+
replace: z.boolean().optional().describe("For type: clear the field first instead of appending to existing content"),
|
|
346
|
+
}))
|
|
347
|
+
.min(1)
|
|
348
|
+
.max(20),
|
|
349
|
+
session: sessionParam,
|
|
350
|
+
},
|
|
351
|
+
}, serializedPerSession("scout_run_plan", async ({ steps }, session) => {
|
|
352
|
+
try {
|
|
353
|
+
return text(await engineFor(session).runPlan(steps), session);
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
return errorText(err);
|
|
357
|
+
}
|
|
358
|
+
}, 240_000));
|
|
359
|
+
server.registerTool("scout_click", {
|
|
360
|
+
description: "Click an element by its ref from the latest scout_snapshot. Returns the outcome plus any oracle violations triggered. clicks=2 (or 3) probes IMPATIENT-USER behaviour: a rapid multi-click that fires the same state-changing request twice means the control is not guarded against double submission (button stays enabled, endpoint not idempotent) — use it on every important submit/create button once; the result says explicitly whether duplicates fired.",
|
|
361
|
+
inputSchema: {
|
|
362
|
+
ref: z.string().describe("Element ref, e.g. e12"),
|
|
363
|
+
clicks: z.number().int().min(1).max(3).default(1).describe("1 = normal; 2-3 = rapid repeated clicks (double-submit probe)"),
|
|
364
|
+
session: sessionParam,
|
|
365
|
+
},
|
|
366
|
+
}, serializedPerSession("scout_click", async ({ ref, clicks }, session) => {
|
|
367
|
+
try {
|
|
368
|
+
return text(await engineFor(session).click(ref, clicks ?? 1), session);
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
return errorText(err);
|
|
372
|
+
}
|
|
373
|
+
}));
|
|
374
|
+
server.registerTool("scout_type", {
|
|
375
|
+
description: "Type into a text input/textarea/composer by ref, the way a real user does: if the field already holds content (e.g. an @-mention chip a menu click inserted), the text is APPENDED at the end — preserving that content — and the result reports what was already there (a separating space is added only at a word-to-word boundary). Pass replace=true to clear the field first (correcting a previous entry); an empty textValue always clears. Appending fires input events but not keydown, so keydown-driven triggers (slash/mention menus) will not react to appended text. Use for both valid values and boundary/fuzz values (empty, very long, unicode, script tags).",
|
|
376
|
+
inputSchema: {
|
|
377
|
+
ref: z.string().describe("Element ref, e.g. e12"),
|
|
378
|
+
textValue: z.string().optional().describe("Text to type"),
|
|
379
|
+
// A `type` step inside scout_run_plan spells this `value`, as does scout_select.
|
|
380
|
+
// One vocabulary for "the text going in", whichever tool takes it.
|
|
381
|
+
value: z.string().optional().describe("Alias for `textValue`."),
|
|
382
|
+
pressEnter: z.boolean().default(false).describe("Press Enter after typing"),
|
|
383
|
+
replace: z.boolean().default(false).describe("Clear the field before typing instead of appending to existing content"),
|
|
384
|
+
session: sessionParam,
|
|
385
|
+
},
|
|
386
|
+
}, serializedPerSession("scout_type", async ({ ref, textValue, value, pressEnter, replace }, session) => {
|
|
387
|
+
try {
|
|
388
|
+
// An explicitly empty string is meaningful here (it clears the field),
|
|
389
|
+
// so fall back on `undefined` rather than on falsiness — and reject a
|
|
390
|
+
// call that named neither. Defaulting to "" turned a malformed call
|
|
391
|
+
// into a silent field-wipe reported as success.
|
|
392
|
+
if (textValue === undefined && value === undefined) {
|
|
393
|
+
return text(`Pass the text to type: scout_type { ref, textValue: "…" }. Pass "" explicitly to clear the field.`, session);
|
|
394
|
+
}
|
|
395
|
+
const toType = textValue ?? value ?? "";
|
|
396
|
+
return text(await engineFor(session).type(ref, toType, pressEnter, replace), session);
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
return errorText(err);
|
|
400
|
+
}
|
|
401
|
+
}));
|
|
402
|
+
server.registerTool("scout_upload", {
|
|
403
|
+
description: "Attach a file to an upload control the way a user does. `ref` is either a visible <input type=file> (snapshots list these with role `file`) or the button/label/dropzone that opens the file chooser — the chooser is intercepted and answered, which is how the hidden input behind a styled 'Choose file' control is reached. Omit `ref` to target the page's only file input, hidden or not (snapshots disclose hidden ones on a FILE INPUTS line). Nothing needs to exist on disk: a small VALID fixture (real PDF/PNG structure) is generated in memory, its kind inferred from the input's accept attribute or chosen with `fixture`; `filePath` uploads a real file but must live inside the attached project (fenced like navigation is fenced to the origin); `name` overrides the filename for boundary tests (wrong extension vs accept, very long, unicode). The result names the input, how the file reached it, flags a file that violates accept (a mismatch the app then accepts is a validation finding), warns if the app cleared the input after selection, and says whether a state-changing request fired on selection — if none did, click the form's submit, or check the next snapshot for a client-side rejection.",
|
|
404
|
+
inputSchema: {
|
|
405
|
+
ref: z
|
|
406
|
+
.string()
|
|
407
|
+
.optional()
|
|
408
|
+
.describe("Element ref of the file input OR of the control that opens the file chooser; omit when the page has exactly one file input"),
|
|
409
|
+
filePath: z
|
|
410
|
+
.string()
|
|
411
|
+
.optional()
|
|
412
|
+
.describe("A real file to upload — absolute or relative to the project; must be inside the attached project. Exclusive with fixture."),
|
|
413
|
+
fixture: z
|
|
414
|
+
.enum(FIXTURE_KINDS)
|
|
415
|
+
.optional()
|
|
416
|
+
.describe("Generated fixture kind; default: inferred from the input's accept attribute (pdf when there is none, or none we can generate)"),
|
|
417
|
+
name: z.string().min(1).max(512).optional().describe("Filename override (default scenescout-fixture.<kind>, or the disk file's own name)"),
|
|
418
|
+
session: sessionParam,
|
|
419
|
+
},
|
|
420
|
+
}, serializedPerSession("scout_upload", async ({ ref, filePath, fixture, name }, session) => {
|
|
421
|
+
try {
|
|
422
|
+
return text(await engineFor(session).upload({ ref, filePath, fixture, name }), session);
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
return errorText(err);
|
|
426
|
+
}
|
|
427
|
+
}));
|
|
428
|
+
server.registerTool("scout_hover", {
|
|
429
|
+
description: "Hover an element by ref like a user pausing the pointer on it, and report what it reveals: tooltips/popovers (diffed against pre-hover state), any other new page text that appeared (labelled as possibly unrelated on busy pages), the title attribute, and aria-describedby text — each item truncated to 300 chars. Hovering does not count as exercising the element. Use on badges, icons, truncated text, and error indicators BEFORE concluding an element 'does nothing' — hover-gated UI is invisible to snapshots and clicks.",
|
|
430
|
+
inputSchema: { ref: z.string().describe("Element ref, e.g. e12"), session: sessionParam },
|
|
431
|
+
}, serializedPerSession("scout_hover", async ({ ref }, session) => {
|
|
432
|
+
try {
|
|
433
|
+
return text(await engineFor(session).hover(ref), session);
|
|
434
|
+
}
|
|
435
|
+
catch (err) {
|
|
436
|
+
return errorText(err);
|
|
437
|
+
}
|
|
438
|
+
}));
|
|
439
|
+
server.registerTool("scout_select", {
|
|
440
|
+
description: "Select an option in a <select> by ref.",
|
|
441
|
+
inputSchema: { ref: z.string(), value: z.string().describe("Option value or label"), session: sessionParam },
|
|
442
|
+
}, serializedPerSession("scout_select", async ({ ref, value }, session) => {
|
|
443
|
+
try {
|
|
444
|
+
return text(await engineFor(session).select(ref, value), session);
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
return errorText(err);
|
|
448
|
+
}
|
|
449
|
+
}));
|
|
450
|
+
server.registerTool("scout_navigate", {
|
|
451
|
+
description: "Navigate to a URL or a path relative to the attached base URL (e.g. '/orders'). Also supports 'back' via scout_back.",
|
|
452
|
+
inputSchema: { target: z.string().describe("Absolute URL or path like /settings"), session: sessionParam },
|
|
453
|
+
}, serializedPerSession("scout_navigate", async ({ target }, session) => {
|
|
454
|
+
try {
|
|
455
|
+
return text(await engineFor(session).navigate(target), session);
|
|
456
|
+
}
|
|
457
|
+
catch (err) {
|
|
458
|
+
return errorText(err);
|
|
459
|
+
}
|
|
460
|
+
}));
|
|
461
|
+
server.registerTool("scout_back", {
|
|
462
|
+
description: "Go back in browser history (tests back-button resilience).",
|
|
463
|
+
inputSchema: { session: sessionParam },
|
|
464
|
+
}, serializedPerSession("scout_back", async (_args, session) => {
|
|
465
|
+
try {
|
|
466
|
+
return text(await engineFor(session).goBack(), session);
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
return errorText(err);
|
|
470
|
+
}
|
|
471
|
+
}));
|
|
472
|
+
server.registerTool("scout_scroll", {
|
|
473
|
+
description: "Scroll like a user — real apps hide their bugs below the fold. Reports the resulting position (px and %), and explicitly flags SCROLL LOCKED: scrollable content exists but the page will not move (the classic leaked modal scroll-lock that silently cuts users off from everything below the fold — snapshots also detect this passively as an OVERLAY line). Without `target` it scrolls the page, falling back to the largest scrollable pane on app-shell layouts. Pass `target` to scroll ONE region instead (a sidebar nav, a dialog body, a table pane): the page-level pick is the LARGEST scroll port, so a smaller region beside it never moves and its content looks truncated when it is only scrolled away — never call a nav item missing without scrolling its own container first. Use before judging a long page: the design audit measures at the current scroll position, so scroll + re-snapshot/re-audit deep sections; scroll also triggers lazy-loaded content whose failures then surface as oracle violations.",
|
|
474
|
+
inputSchema: {
|
|
475
|
+
to: z.enum(["top", "bottom"]).optional().describe("Jump to an edge"),
|
|
476
|
+
by: z.number().int().min(-20000).max(20000).optional().describe("Scroll by px instead (positive = down). Default 600 when neither given."),
|
|
477
|
+
target: z
|
|
478
|
+
.string()
|
|
479
|
+
.optional()
|
|
480
|
+
.describe('Scroll ONE region instead of the page: "testid=…", "text=…" or "label=…". Scrolls that element\'s nearest scrollable ancestor.'),
|
|
481
|
+
session: sessionParam,
|
|
482
|
+
},
|
|
483
|
+
}, serializedPerSession("scout_scroll", async ({ to, by, target }, session) => {
|
|
484
|
+
try {
|
|
485
|
+
return text(await engineFor(session).scroll(to, by, target), session);
|
|
486
|
+
}
|
|
487
|
+
catch (err) {
|
|
488
|
+
return errorText(err);
|
|
489
|
+
}
|
|
490
|
+
}));
|
|
491
|
+
server.registerTool("scout_press", {
|
|
492
|
+
description: "Press a keyboard key (e.g. Escape, Tab, Enter) — useful for closing modals and testing keyboard navigation.",
|
|
493
|
+
inputSchema: { key: z.string(), session: sessionParam },
|
|
494
|
+
}, serializedPerSession("scout_press", async ({ key }, session) => {
|
|
495
|
+
try {
|
|
496
|
+
return text(await engineFor(session).press(key), session);
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
return errorText(err);
|
|
500
|
+
}
|
|
501
|
+
}));
|
|
502
|
+
server.registerTool("scout_design_audit", {
|
|
503
|
+
description: "Computed-style design audit of the current page — a design connoisseur's read WITHOUT screenshots. Measurable defects (⚠): WCAG contrast, tiny targets, clipped text, aspect-distorted images, horizontal overflow, missing keyboard-focus indicators (sampled with real Tab presses). Craft suggestions (→): line measure and line-height rhythm, spacing-scale adherence, typography entropy, palette discipline (gray census, accent hue families, pure-#000 body text), elevation/control consistency, heading structure, indistinguishable links, and AI-slop tells (gradient text, glassmorphism, side-stripe borders, neon glows, violet gradients, identical card grids). Ends with a SYSTEM SUMMARY of design-system coherence. Run once per representative page; the → tier is improvement feedback — file genuine opportunities as ux-polish findings with the concrete numbers, not just defects.",
|
|
504
|
+
inputSchema: { session: sessionParam },
|
|
505
|
+
}, serializedPerSession("scout_design_audit", async (_args, session) => {
|
|
506
|
+
try {
|
|
507
|
+
return text(await engineFor(session).designAudit(), session);
|
|
508
|
+
}
|
|
509
|
+
catch (err) {
|
|
510
|
+
return errorText(err);
|
|
511
|
+
}
|
|
512
|
+
}));
|
|
513
|
+
server.registerTool("scout_journey", {
|
|
514
|
+
description: "Measure how EASY a real task is, not just whether it works — the question pass/fail e2e suites never answer. Wrap one user goal: scout_journey {action:'start', goal:'Create an order'}, perform it the way a first-time user would (navigate by CLICKING through the UI, not by jumping to a known deep URL — a shortcut invalidates the measurement), then scout_journey {action:'end', completed:true|false}. Returns interaction cost (clicks, navigations, distinct screens, elapsed), the actual path taken, and friction signals: BACKTRACKS (returning to a screen already left — the clearest sign the next step wasn't discoverable), screen count, and over-interaction. Run it on each module's primary journey; an abandoned journey is a high-severity finding.",
|
|
515
|
+
inputSchema: {
|
|
516
|
+
action: z.enum(["start", "end"]).describe("'start' before attempting the task, 'end' when done or blocked"),
|
|
517
|
+
goal: z.string().optional().describe("For start: the user-facing task, e.g. 'Create an order and assign it'"),
|
|
518
|
+
completed: z.boolean().default(true).describe("For end: did the user actually achieve the goal? false is a strong finding."),
|
|
519
|
+
note: z.string().optional().describe("For end: what made it hard or easy, in one line"),
|
|
520
|
+
session: sessionParam,
|
|
521
|
+
},
|
|
522
|
+
}, serializedPerSession("scout_journey", async ({ action, goal, completed, note }, session) => {
|
|
523
|
+
try {
|
|
524
|
+
const eng = engineFor(session);
|
|
525
|
+
if (action === "start") {
|
|
526
|
+
if (!goal)
|
|
527
|
+
throw new Error("scout_journey {action:'start'} needs a goal.");
|
|
528
|
+
return text(eng.startJourney(goal), session);
|
|
529
|
+
}
|
|
530
|
+
return text(eng.endJourney(completed ?? true, note), session);
|
|
531
|
+
}
|
|
532
|
+
catch (err) {
|
|
533
|
+
return errorText(err);
|
|
534
|
+
}
|
|
535
|
+
}));
|
|
536
|
+
server.registerTool("scout_note", {
|
|
537
|
+
description: "Cumulative WRITTEN knowledge about the tested app — .scenescout/ASSUMPTIONS.md, in prose a human can read and correct. memory.json stores coverage; this stores UNDERSTANDING, so every run starts smarter than the last. READ it at the start of every session ({action:'read'}). ADD durable learnings as you go ({action:'add', section, note}): what the app is for (app-model), who each role is and what they're FOR — infer the persona from what the role can see and do, e.g. 'qa-role = reviewer: approves orders, cannot administer' (roles), UI patterns the app follows (conventions), rules discovered the hard way like 'an order can only ship once approved' (constraints), fragile areas worth re-testing every run (risks), domain terms (glossary). Notes are dated, attributed to the acting role, and deduplicated. Do NOT record session-specific facts (ids, counts) — only durable knowledge.",
|
|
538
|
+
inputSchema: {
|
|
539
|
+
action: z.enum(["read", "add"]).describe("'read' the accumulated knowledge, or 'add' one durable learning"),
|
|
540
|
+
section: z
|
|
541
|
+
.enum(["app-model", "roles", "conventions", "constraints", "risks", "glossary"])
|
|
542
|
+
.optional()
|
|
543
|
+
.describe("For add: which knowledge section this belongs to"),
|
|
544
|
+
note: z.string().max(500).optional().describe("For add: the learning, one or two sentences, written for a future reader with no context"),
|
|
545
|
+
session: sessionParam,
|
|
546
|
+
},
|
|
547
|
+
}, serializedPerSession("scout_note", async ({ action, section, note }, session) => {
|
|
548
|
+
try {
|
|
549
|
+
const eng = engineFor(session);
|
|
550
|
+
if (!eng.memory)
|
|
551
|
+
throw new Error("Not attached — knowledge lives in the project's .scenescout/.");
|
|
552
|
+
if (action === "read")
|
|
553
|
+
return text(eng.memory.readAssumptions(), session);
|
|
554
|
+
if (!section || !note)
|
|
555
|
+
throw new Error("scout_note {action:'add'} needs section and note.");
|
|
556
|
+
const added = eng.memory.addAssumption(section, note, eng.role);
|
|
557
|
+
return text(added
|
|
558
|
+
? `Noted under "${section}". ASSUMPTIONS.md grows with every run — future sessions will start knowing this.`
|
|
559
|
+
: `Already known (duplicate note) — not added.`, session);
|
|
560
|
+
}
|
|
561
|
+
catch (err) {
|
|
562
|
+
return errorText(err);
|
|
563
|
+
}
|
|
564
|
+
}));
|
|
565
|
+
server.registerTool("scout_screenshot", {
|
|
566
|
+
description: "Take a JPEG screenshot of the current viewport. LAST RESORT: geometry issues are in scout_snapshot and style/contrast/spacing issues are in scout_design_audit — use a screenshot only for pixel-native content (broken images, canvas, visual gestalt) that computed data cannot capture.",
|
|
567
|
+
inputSchema: { session: sessionParam },
|
|
568
|
+
}, serializedPerSession("scout_screenshot", async (_args, session) => {
|
|
569
|
+
try {
|
|
570
|
+
const { base64, mimeType } = await engineFor(session).screenshot();
|
|
571
|
+
return { content: [{ type: "image", data: base64, mimeType }] };
|
|
572
|
+
}
|
|
573
|
+
catch (err) {
|
|
574
|
+
return errorText(err);
|
|
575
|
+
}
|
|
576
|
+
}));
|
|
577
|
+
server.registerTool("scout_finding", {
|
|
578
|
+
description: "Record a structured finding (bug, UX issue, or improvement). Deduplicates across runs; automatically captures the recent action trace as the repro. Use for anything worth reporting: crashes, oracle violations you confirmed, dead ends, confusing UX, permission leaks, missing testids — and design-audit improvement opportunities (ux-polish) with their concrete measurements.",
|
|
579
|
+
inputSchema: {
|
|
580
|
+
severity: z.enum(["high", "medium", "low"]),
|
|
581
|
+
category: z
|
|
582
|
+
.enum([
|
|
583
|
+
"console-error",
|
|
584
|
+
"page-error",
|
|
585
|
+
"http-error",
|
|
586
|
+
"network",
|
|
587
|
+
"dead-end",
|
|
588
|
+
"ux-confusing",
|
|
589
|
+
"ux-polish",
|
|
590
|
+
"visual",
|
|
591
|
+
"a11y",
|
|
592
|
+
"permission-leak",
|
|
593
|
+
"data-inconsistency",
|
|
594
|
+
"stale-state",
|
|
595
|
+
"data-loss",
|
|
596
|
+
"performance",
|
|
597
|
+
"security",
|
|
598
|
+
"missing-testid",
|
|
599
|
+
"other",
|
|
600
|
+
])
|
|
601
|
+
.describe("Pick the closest — use 'other' only when nothing fits"),
|
|
602
|
+
title: z.string().describe("One-line summary of the defect"),
|
|
603
|
+
detail: z.string().describe("What happened, what was expected, and the evidence"),
|
|
604
|
+
evidence: z
|
|
605
|
+
.string()
|
|
606
|
+
.optional()
|
|
607
|
+
.describe("Canonical machine signature for dedup, e.g. 'GET /api/reports/dashboard 403' or 'widget dashboard-summary-widget shows 0'. Same bug re-found later should produce the same string."),
|
|
608
|
+
session: sessionParam,
|
|
609
|
+
},
|
|
610
|
+
}, serializedPerSession("scout_finding", async ({ severity, category, title, detail, evidence, }, session) => {
|
|
611
|
+
try {
|
|
612
|
+
const eng = engineFor(session);
|
|
613
|
+
if (!eng.memory)
|
|
614
|
+
throw new Error("Not attached — findings need an active session.");
|
|
615
|
+
const [finding, isNew] = eng.memory.addFinding({
|
|
616
|
+
severity,
|
|
617
|
+
category: category,
|
|
618
|
+
title,
|
|
619
|
+
detail,
|
|
620
|
+
evidence,
|
|
621
|
+
url: eng.currentUrl,
|
|
622
|
+
state: eng.currentState || "(unknown)",
|
|
623
|
+
});
|
|
624
|
+
return text(isNew
|
|
625
|
+
? `Finding recorded: [${finding.severity}] ${finding.title} (id ${finding.id})`
|
|
626
|
+
: finding.regressedAt
|
|
627
|
+
? `⟳ REOPENED as a REGRESSION: finding ${finding.id} was previously resolved but the evidence reproduces again (seen in ${finding.runs} runs). Worth calling out to the user.`
|
|
628
|
+
: `Duplicate of existing finding ${finding.id} (seen in ${finding.runs} runs) — already known, keep exploring.`, session);
|
|
629
|
+
}
|
|
630
|
+
catch (err) {
|
|
631
|
+
return errorText(err);
|
|
632
|
+
}
|
|
633
|
+
}));
|
|
634
|
+
server.registerTool("scout_coverage", {
|
|
635
|
+
description: "Show exploration coverage: states visited across all runs and which elements remain unexercised. Use to decide where to explore next and when the level's budget is satisfied.",
|
|
636
|
+
inputSchema: { session: sessionParam },
|
|
637
|
+
}, serializedPerSession("scout_coverage", async (_args, session) => {
|
|
638
|
+
try {
|
|
639
|
+
const eng = engineFor(session);
|
|
640
|
+
if (!eng.memory)
|
|
641
|
+
throw new Error("Not attached.");
|
|
642
|
+
const cov = eng.memory.coverage();
|
|
643
|
+
const unvisited = eng.unvisitedKnownRoutes();
|
|
644
|
+
const lines = [
|
|
645
|
+
...(eng.memory.lastSaveError
|
|
646
|
+
? [
|
|
647
|
+
`⚠ MEMORY WRITE FAILING: ${eng.memory.lastSaveError} — coverage/findings since the last successful write are NOT persisted to disk. If this doesn't clear on its own, check the project directory still exists and is writable.`,
|
|
648
|
+
]
|
|
649
|
+
: []),
|
|
650
|
+
`States known: ${cov.states} · Elements exercised: ${cov.elementsExercised}/${cov.elementsTotal}`,
|
|
651
|
+
formatRouteCoverage(eng.allKnownRoutes(), unvisited),
|
|
652
|
+
`Unexercised elements by route:`,
|
|
653
|
+
...cov.unexercised.slice(0, 25).map((u) => ` ${u.state}: ${u.keys.slice(0, 6).join(", ")}${u.keys.length > 6 ? ` … +${u.keys.length - 6}` : ""}`),
|
|
654
|
+
];
|
|
655
|
+
return text(lines.join("\n"), session);
|
|
656
|
+
}
|
|
657
|
+
catch (err) {
|
|
658
|
+
return errorText(err);
|
|
659
|
+
}
|
|
660
|
+
}));
|
|
661
|
+
server.registerTool("scout_report", {
|
|
662
|
+
description: "Generate the final markdown report — findings, page quality scores (worst first), role capability matrix, oracle rollup, and the GAP LEDGER (an explicit list of what was NOT tested). Writes the full document to .scenescout/report.md and returns a bounded SUMMARY (full reports exceed client token limits). Gates by level: 'minimal' needs all routes visited + ≥1 design audit; 'medium' additionally needs several routes audited; 'extensive' REFUSES while the gap ledger is non-empty — that refusal is the completeness guarantee: an extensive report only generates when nothing known is left untested. force=true overrides (only when the user capped the budget).",
|
|
663
|
+
inputSchema: {
|
|
664
|
+
force: z.boolean().default(false).describe("Generate even though gates are unmet (only when the user capped the budget)"),
|
|
665
|
+
level: z
|
|
666
|
+
.enum(["minimal", "medium", "extensive"])
|
|
667
|
+
.default("medium")
|
|
668
|
+
.describe("Which completion contract to enforce — match the level the run was asked for"),
|
|
669
|
+
session: sessionParam,
|
|
670
|
+
},
|
|
671
|
+
}, serializedPerSession("scout_report", async ({ force, level }, session) => {
|
|
672
|
+
try {
|
|
673
|
+
const eng = engineFor(session);
|
|
674
|
+
if (!eng.memory)
|
|
675
|
+
throw new Error("Not attached.");
|
|
676
|
+
const unvisited = eng.unvisitedKnownRoutes();
|
|
677
|
+
const gates = [];
|
|
678
|
+
if (unvisited.length > 0) {
|
|
679
|
+
gates.push(`${unvisited.length} known route(s) never visited:\n` +
|
|
680
|
+
unvisited
|
|
681
|
+
.slice(0, 30)
|
|
682
|
+
.map((r) => ` ${r}`)
|
|
683
|
+
.join("\n") +
|
|
684
|
+
(unvisited.length > 30 ? `\n … +${unvisited.length - 30} more` : "") +
|
|
685
|
+
`\n→ Run scout_crawl (no args) to cover them in one call.`);
|
|
686
|
+
}
|
|
687
|
+
if (eng.designAuditCount === 0) {
|
|
688
|
+
gates.push(`No scout_design_audit was run this session — run it on at least one representative page (visual/a11y coverage is part of every level).`);
|
|
689
|
+
}
|
|
690
|
+
const lvl = level ?? "medium";
|
|
691
|
+
const auditedRoutes = Object.values(eng.memory.routeFacts).filter((f) => f.audited).length;
|
|
692
|
+
const visitedCount = new Set(Object.values(eng.memory.states).map((st) => st.route)).size;
|
|
693
|
+
if (lvl !== "minimal") {
|
|
694
|
+
const needed = Math.min(3, Math.max(1, Math.ceil(visitedCount / 10)));
|
|
695
|
+
if (auditedRoutes < needed) {
|
|
696
|
+
gates.push(`Level '${lvl}' needs design audits on ≥${needed} distinct routes (have ${auditedRoutes}) — audit the representative pages (dashboard, a form, a detail view, a table).`);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const all = eng.allKnownRoutes();
|
|
700
|
+
const gapList = computeGaps(eng.memory, {
|
|
701
|
+
routesVisited: all.length - unvisited.length,
|
|
702
|
+
routesTotal: all.length,
|
|
703
|
+
designAudits: eng.designAuditCount,
|
|
704
|
+
unvisitedRoutes: unvisited,
|
|
705
|
+
});
|
|
706
|
+
if (lvl === "extensive" && gapList.length > 0) {
|
|
707
|
+
gates.push(`Level 'extensive' claims completeness, so it refuses while the GAP LEDGER is non-empty:\n` +
|
|
708
|
+
gapList.map((g) => ` ⚠ ${g}`).join("\n") +
|
|
709
|
+
`\nClose the gaps (or report at level 'medium', which discloses them instead).`);
|
|
710
|
+
}
|
|
711
|
+
if (gates.length > 0 && !force) {
|
|
712
|
+
return text(`NOT GENERATED — the '${lvl}' completion contract is unmet:\n\n${gates.join("\n\n")}\n\n` +
|
|
713
|
+
`Then call scout_report again. Pass force=true ONLY if the user explicitly capped the budget.`, session);
|
|
714
|
+
}
|
|
715
|
+
const { path: p, summary } = generateReport(eng.memory, eng.oracleLog.all, {
|
|
716
|
+
routesVisited: all.length - unvisited.length,
|
|
717
|
+
routesTotal: all.length,
|
|
718
|
+
designAudits: eng.designAuditCount,
|
|
719
|
+
createdResources: eng.createdResources,
|
|
720
|
+
unvisitedRoutes: unvisited,
|
|
721
|
+
policyAttributed: eng.oracleLog.policyAttributed,
|
|
722
|
+
});
|
|
723
|
+
void p;
|
|
724
|
+
return text(summary, session);
|
|
725
|
+
}
|
|
726
|
+
catch (err) {
|
|
727
|
+
return errorText(err);
|
|
728
|
+
}
|
|
729
|
+
}));
|
|
730
|
+
server.registerTool("scout_resolve", {
|
|
731
|
+
description: "Mark a finding as resolved (by its id, shown when recorded and in the report). Resolved findings move to the report's green ✅ Resolved section, and reopen automatically as flagged REGRESSIONS if re-found later. Use when the user says a bug is fixed, or when re-testing shows the evidence no longer reproduces.",
|
|
732
|
+
inputSchema: {
|
|
733
|
+
findingId: z.string().optional().describe("Finding id, e.g. a1b2c3d4e5"),
|
|
734
|
+
// scout_finding prints "(id a1b2c3d4e5)" and the report renders "**Id:**",
|
|
735
|
+
// so `id` is the name a caller reaches for first — and every schema here
|
|
736
|
+
// is additionalProperties:false, so the near-miss was a hard rejection.
|
|
737
|
+
id: z.string().optional().describe("Alias for `findingId`."),
|
|
738
|
+
session: sessionParam,
|
|
739
|
+
},
|
|
740
|
+
}, serializedPerSession("scout_resolve", async ({ findingId, id }, session) => {
|
|
741
|
+
try {
|
|
742
|
+
const eng = engineFor(session);
|
|
743
|
+
if (!eng.memory)
|
|
744
|
+
throw new Error("Not attached.");
|
|
745
|
+
const wanted = findingId ?? id;
|
|
746
|
+
if (!wanted)
|
|
747
|
+
return text(`Pass the finding id: scout_resolve { id: "a1b2c3d4e5" }.`, session);
|
|
748
|
+
const f = eng.memory.resolveFinding(wanted);
|
|
749
|
+
return text(f ? `Resolved: [${f.severity}] ${f.title}` : `No finding with id ${wanted}.`, session);
|
|
750
|
+
}
|
|
751
|
+
catch (err) {
|
|
752
|
+
return errorText(err);
|
|
753
|
+
}
|
|
754
|
+
}));
|
|
755
|
+
server.registerTool("scout_close", {
|
|
756
|
+
description: "Close a session's browser (memory persists on disk). Default: the DEFAULT session. Pass session to close a specific one, or all=true to close every live session at the end of a multi-role run.",
|
|
757
|
+
inputSchema: {
|
|
758
|
+
session: z.string().max(40).optional().describe("Session to close (default: the default session)"),
|
|
759
|
+
all: z.boolean().default(false).describe("Close every live session"),
|
|
760
|
+
},
|
|
761
|
+
}, serializedControl(async ({ session, all }) => {
|
|
762
|
+
try {
|
|
763
|
+
if (all) {
|
|
764
|
+
const names = [...engines.keys()];
|
|
765
|
+
// Closes are independent per-browser — run them in parallel so N wedged
|
|
766
|
+
// sessions cost one 8s teardown cap total, not N of them.
|
|
767
|
+
await Promise.allSettled([...engines.values()].map((e) => e.close()));
|
|
768
|
+
engines.clear();
|
|
769
|
+
sessionQueue.clear();
|
|
770
|
+
return text(`All sessions closed (${names.join(", ") || "none were live"}). Memory and reports remain in .scenescout/.`, activeName);
|
|
771
|
+
}
|
|
772
|
+
const name = session ?? activeName;
|
|
773
|
+
const eng = engines.get(name);
|
|
774
|
+
if (!eng)
|
|
775
|
+
return text(`No live session "${name}".`, name);
|
|
776
|
+
await eng.close();
|
|
777
|
+
const saveError = eng.memory?.lastSaveError;
|
|
778
|
+
engines.delete(name);
|
|
779
|
+
sessionQueue.forget(name);
|
|
780
|
+
if (activeName === name)
|
|
781
|
+
activeName = engines.keys().next().value ?? "default";
|
|
782
|
+
return text(`Session "${name}" closed. Memory and report remain in .scenescout/.` +
|
|
783
|
+
(engines.size > 0 ? ` Default session → ${activeName}.` : "") +
|
|
784
|
+
(saveError ? `\n⚠ The final memory write failed (${saveError}) — some coverage/findings from this session may not have been persisted to disk.` : ""), activeName);
|
|
785
|
+
}
|
|
786
|
+
catch (err) {
|
|
787
|
+
return errorText(err);
|
|
788
|
+
}
|
|
789
|
+
}));
|
|
790
|
+
async function main() {
|
|
791
|
+
const transport = new StdioServerTransport();
|
|
792
|
+
await server.connect(transport);
|
|
793
|
+
// Self-heal across restarts: browsers whose parent crashed/was killed can
|
|
794
|
+
// linger and have been observed to wedge fresh launches. After connect —
|
|
795
|
+
// the stdio handshake must not wait on a full process-table scan.
|
|
796
|
+
setImmediate(() => reapOrphanBrowsers());
|
|
797
|
+
}
|
|
798
|
+
async function shutdown() {
|
|
799
|
+
await Promise.allSettled([...engines.values()].map((e) => e.close()));
|
|
800
|
+
}
|
|
801
|
+
process.on("SIGINT", () => {
|
|
802
|
+
void shutdown().finally(() => process.exit(0));
|
|
803
|
+
});
|
|
804
|
+
process.on("SIGTERM", () => {
|
|
805
|
+
void shutdown().finally(() => process.exit(0));
|
|
806
|
+
});
|
|
807
|
+
main().catch((err) => {
|
|
808
|
+
console.error("SceneScout MCP server failed:", err);
|
|
809
|
+
process.exit(1);
|
|
810
|
+
});
|