scenescout 3.16.0 → 3.18.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 +29 -0
- package/README.md +13 -7
- package/dist/check-run.js +15 -7
- package/dist/cli.js +3 -4
- package/dist/engine/browser.js +108 -32
- package/dist/engine/check.js +152 -8
- package/dist/engine/collector.js +62 -21
- package/dist/engine/crawl.js +37 -0
- package/dist/engine/design.js +73 -3
- package/dist/engine/fingerprint.js +16 -0
- package/dist/engine/memory.js +74 -20
- package/dist/engine/policy.js +31 -0
- package/dist/engine/probes.js +75 -16
- package/dist/engine/status-pane-page.js +441 -0
- package/dist/engine/status-pane.js +128 -0
- package/dist/first-run.js +8 -1
- package/dist/installer.js +111 -5
- package/dist/intake.js +161 -0
- package/dist/mcp-server.js +135 -9
- package/dist/playbook.js +1 -1
- package/dist/prompts.js +106 -0
- package/package.json +3 -3
- package/skills/scenescout/SKILL.md +2 -2
package/dist/installer.js
CHANGED
|
@@ -16,13 +16,118 @@ export const MCP_NAME = "scenescout";
|
|
|
16
16
|
/** Names this tool's skill and MCP server had before renames; cleaned up on install so the old slash command and a duplicate tool set do not linger. */
|
|
17
17
|
const LEGACY_SKILL_NAMES = ["frontend-tester", "scenecraft"];
|
|
18
18
|
const LEGACY_MCP_NAMES = ["scenecraft"];
|
|
19
|
+
/** Windows' own PATHEXT when the variable is unset. Each entry includes its dot. */
|
|
20
|
+
const WINDOWS_PATH_EXT = ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC";
|
|
21
|
+
/** cmd.exe meta characters. A caret in front of one of these keeps it literal. */
|
|
22
|
+
const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
|
|
23
|
+
/** A `.cmd` or `.bat` file. Node refuses to spawn one directly (EINVAL) unless a shell starts it. */
|
|
24
|
+
export function isBatchShim(file) {
|
|
25
|
+
return /\.(?:cmd|bat)$/i.test(file);
|
|
26
|
+
}
|
|
27
|
+
function escapeCmdMeta(value) {
|
|
28
|
+
return value.replace(CMD_META, "^$1");
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The file Windows would run for `command`, or null when nothing matches.
|
|
32
|
+
*
|
|
33
|
+
* The current directory is tried before PATH. A name that already contains a
|
|
34
|
+
* dot is tried as written, then with each PATHEXT suffix; a name without a
|
|
35
|
+
* dot only gains those suffixes. Suffixes stay in PATHEXT order, so an
|
|
36
|
+
* `.exe` listed ahead of `.cmd` wins when both exist.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveWindowsCommand(command, opts) {
|
|
39
|
+
const listed = (opts.pathExt?.trim() || WINDOWS_PATH_EXT).split(";").filter((ext) => ext !== "");
|
|
40
|
+
const exts = path.win32.basename(command).includes(".") ? ["", ...listed] : listed;
|
|
41
|
+
const rooted = path.win32.isAbsolute(command) || /[\\/]/.test(command);
|
|
42
|
+
const dirs = rooted ? [""] : [opts.cwd, ...windowsPathDirs(opts.pathEnv)];
|
|
43
|
+
for (const dir of dirs) {
|
|
44
|
+
const base = rooted ? (path.win32.isAbsolute(command) ? command : path.win32.join(opts.cwd, command)) : path.win32.join(dir, command);
|
|
45
|
+
for (const ext of exts) {
|
|
46
|
+
const candidate = base + ext;
|
|
47
|
+
if (opts.exists(candidate))
|
|
48
|
+
return candidate;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
function windowsPathDirs(pathEnv) {
|
|
54
|
+
const dirs = [];
|
|
55
|
+
for (const raw of pathEnv.split(";")) {
|
|
56
|
+
const dir = raw.trim();
|
|
57
|
+
if (!dir)
|
|
58
|
+
continue;
|
|
59
|
+
dirs.push(dir.length >= 2 && dir.startsWith('"') && dir.endsWith('"') ? dir.slice(1, -1) : dir);
|
|
60
|
+
}
|
|
61
|
+
return dirs;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* One argument on a `cmd /c` line that a batch shim forwards with `%*`.
|
|
65
|
+
*
|
|
66
|
+
* Quoted for the program that finally runs (backslashes before a quote follow
|
|
67
|
+
* CommandLineToArgvW). Every cmd meta character is then caret-escaped twice:
|
|
68
|
+
* once for `cmd.exe /c`, and once for the shim line that expands `%*`. A JSON
|
|
69
|
+
* value and a path containing spaces each stay a single argument.
|
|
70
|
+
*/
|
|
71
|
+
function escapeCmdArgument(arg) {
|
|
72
|
+
let value = `${arg}`;
|
|
73
|
+
// Backslashes that escape a following quote, then a trailing run that the
|
|
74
|
+
// wrapping quote would otherwise swallow. The match does not backtrack.
|
|
75
|
+
value = value.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"');
|
|
76
|
+
value = value.replace(/(?=(\\+?)?)\1$/, "$1$1");
|
|
77
|
+
value = escapeCmdMeta(escapeCmdMeta(`"${value}"`));
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
/** The string after `cmd.exe /d /s /c`. The outer quotes exist for `/s` to strip. */
|
|
81
|
+
function windowsBatchCommandLine(command, args) {
|
|
82
|
+
const program = escapeCmdMeta(path.win32.normalize(command));
|
|
83
|
+
return `"${[program, ...args.map(escapeCmdArgument)].join(" ")}"`;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* How to start `command`. On Windows a resolved `.cmd` or `.bat` goes through
|
|
87
|
+
* `cmd.exe`, because node will not spawn those itself. Everywhere else, and
|
|
88
|
+
* for an executable, the command and its arguments pass through unchanged.
|
|
89
|
+
*/
|
|
90
|
+
export function planSpawn(opts) {
|
|
91
|
+
if (opts.platform === "win32" && opts.resolved && isBatchShim(opts.resolved)) {
|
|
92
|
+
return {
|
|
93
|
+
command: opts.comSpec?.trim() || "cmd.exe",
|
|
94
|
+
args: ["/d", "/s", "/c", windowsBatchCommandLine(opts.resolved, opts.args)],
|
|
95
|
+
windowsVerbatimArguments: true,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return { command: opts.command, args: opts.args };
|
|
99
|
+
}
|
|
100
|
+
/** `planSpawn` for this platform, resolving a Windows batch shim before choosing cmd.exe. */
|
|
101
|
+
export function commandSpawnPlan(command, args, opts) {
|
|
102
|
+
if (opts.platform !== "win32")
|
|
103
|
+
return { command, args };
|
|
104
|
+
return planSpawn({
|
|
105
|
+
command,
|
|
106
|
+
args,
|
|
107
|
+
platform: "win32",
|
|
108
|
+
resolved: resolveWindowsCommand(command, { cwd: opts.cwd, pathEnv: opts.pathEnv ?? "", pathExt: opts.pathExt, exists: opts.exists }),
|
|
109
|
+
comSpec: opts.comSpec,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
19
112
|
/** Real runner. `missing` separates "the binary is not installed" from "it ran and failed". */
|
|
20
113
|
export const spawnRunner = (command, args, opts) => {
|
|
21
|
-
const
|
|
114
|
+
const planned = commandSpawnPlan(command, args, {
|
|
115
|
+
platform: process.platform,
|
|
116
|
+
cwd: opts?.cwd ?? process.cwd(),
|
|
117
|
+
pathEnv: process.env.PATH,
|
|
118
|
+
pathExt: process.env.PATHEXT,
|
|
119
|
+
comSpec: process.env.ComSpec,
|
|
120
|
+
exists: (file) => fs.existsSync(file),
|
|
121
|
+
});
|
|
122
|
+
const r = spawnSync(planned.command, planned.args, {
|
|
123
|
+
encoding: "utf8",
|
|
124
|
+
timeout: 60_000,
|
|
125
|
+
cwd: opts?.cwd,
|
|
126
|
+
windowsVerbatimArguments: planned.windowsVerbatimArguments,
|
|
127
|
+
});
|
|
22
128
|
const code = r.error?.code;
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// the command has to be handed to the person instead.
|
|
129
|
+
// A batch shim is started through cmd.exe above. EINVAL still means a shim
|
|
130
|
+
// was spawned directly and nothing ran: the same outcome as a missing binary.
|
|
26
131
|
const missing = code === "ENOENT" || (process.platform === "win32" && code === "EINVAL");
|
|
27
132
|
// With `encoding` set, a command that never ran still yields "" for stderr: the error is the only account of it.
|
|
28
133
|
const stderr = r.stderr || (code === "ETIMEDOUT" ? "npm did not finish within 60 s" : r.error ? String(r.error.message) : "");
|
|
@@ -295,7 +400,8 @@ export function planCommand(opts) {
|
|
|
295
400
|
if (mine || !checkout)
|
|
296
401
|
return { action: "present", at: opts.resolved };
|
|
297
402
|
}
|
|
298
|
-
//
|
|
403
|
+
// Putting `scenescout` on PATH stays a manual npm command on Windows. Client
|
|
404
|
+
// registration is separate: spawnRunner starts a `.cmd` shim itself.
|
|
299
405
|
if (opts.platform === "win32")
|
|
300
406
|
return { action: "manual", manual, why: "this step cannot start npm on Windows" };
|
|
301
407
|
const beside = path.join(path.dirname(opts.nodePath), "npm");
|
package/dist/intake.js
CHANGED
|
@@ -102,3 +102,164 @@ export function settingsFromAnswers(a) {
|
|
|
102
102
|
export function introQuestions() {
|
|
103
103
|
return INTAKE_QUESTIONS.map((q, i) => `${i + 1}. ${q.ask}`).join("\n");
|
|
104
104
|
}
|
|
105
|
+
// ── The questions as one form ────────────────────────────────────────────────
|
|
106
|
+
//
|
|
107
|
+
// A client that can show a form (MCP elicitation, form mode) gets the four
|
|
108
|
+
// questions as one, through scout_intake; every other client, and a person who
|
|
109
|
+
// declines or dismisses the form, gets them as text for the agent to ask in
|
|
110
|
+
// chat. Specification: https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation
|
|
111
|
+
// (the version the SDK speaks; the 2026-07-28 revision keeps the same capability,
|
|
112
|
+
// schema subset and three actions). Two of its rules shape this:
|
|
113
|
+
// - a server MUST NOT send a mode the client did not declare, and an empty
|
|
114
|
+
// `elicitation` capability means form mode only (formElicitationSupported);
|
|
115
|
+
// - a server MUST NOT ask for passwords, API keys or tokens in form mode. The
|
|
116
|
+
// form asks HOW the person signs in, never with what: the sign-in itself is
|
|
117
|
+
// scout_login's window.
|
|
118
|
+
/** The tool that asks the questions, as a form where the client can show one. */
|
|
119
|
+
export const INTAKE_TOOL = "scout_intake";
|
|
120
|
+
/**
|
|
121
|
+
* Whether the client can show a form. Declared `form` says so; an empty
|
|
122
|
+
* `elicitation` object is form mode by the specification's backwards-compatibility
|
|
123
|
+
* rule; `url` alone, or no `elicitation` at all, is not.
|
|
124
|
+
*/
|
|
125
|
+
export function formElicitationSupported(capabilities) {
|
|
126
|
+
const e = capabilities?.elicitation;
|
|
127
|
+
if (!e || typeof e !== "object")
|
|
128
|
+
return false;
|
|
129
|
+
return e.form !== undefined || e.url === undefined;
|
|
130
|
+
}
|
|
131
|
+
const choice = (value, title) => ({ const: value, title });
|
|
132
|
+
const askOf = (id) => INTAKE_QUESTIONS.find((q) => q.id === id).ask;
|
|
133
|
+
/** Every value the form offers for a choice, with the words the person sees. */
|
|
134
|
+
export const FORM_CHOICES = {
|
|
135
|
+
signIn: [
|
|
136
|
+
choice("none", "No sign-in"),
|
|
137
|
+
choice("sso", "Google or Microsoft single sign-on"),
|
|
138
|
+
choice("password", "An email and password"),
|
|
139
|
+
choice("one-time-code", "A one-time code"),
|
|
140
|
+
],
|
|
141
|
+
whatToCheck: [
|
|
142
|
+
choice("everything", "Everything: the whole site"),
|
|
143
|
+
choice("description", "What I describe below"),
|
|
144
|
+
choice("tickets", "The tickets I paste below"),
|
|
145
|
+
],
|
|
146
|
+
realData: [choice("yes", "Yes"), choice("no", "No"), choice("unsure", "Not sure")],
|
|
147
|
+
};
|
|
148
|
+
/** What the form says above its fields. */
|
|
149
|
+
export const INTAKE_FORM_MESSAGE = "Four questions before SceneScout starts testing. It never asks for a password here: if the site needs one, a browser window opens later for you to sign in as you normally would.";
|
|
150
|
+
/**
|
|
151
|
+
* The form, as an `elicitation/create` request's `requestedSchema`: a flat object
|
|
152
|
+
* of strings and single-choice lists, the only shapes form mode allows. `address`
|
|
153
|
+
* starts filled in when the agent already knows it. Real data starts at "Not sure",
|
|
154
|
+
* which tests as carefully as "Yes".
|
|
155
|
+
*/
|
|
156
|
+
export function intakeFormSchema(knownAddress) {
|
|
157
|
+
const address = knownAddress?.trim();
|
|
158
|
+
return {
|
|
159
|
+
type: "object",
|
|
160
|
+
properties: {
|
|
161
|
+
address: { type: "string", title: "Address", description: askOf("address"), minLength: 1, ...(address ? { default: address } : {}) },
|
|
162
|
+
signIn: { type: "string", title: "Sign-in", description: askOf("sign-in"), oneOf: [...FORM_CHOICES.signIn] },
|
|
163
|
+
whatToCheck: {
|
|
164
|
+
type: "string",
|
|
165
|
+
title: "What to check",
|
|
166
|
+
description: "What should I check?",
|
|
167
|
+
oneOf: [...FORM_CHOICES.whatToCheck],
|
|
168
|
+
default: "everything",
|
|
169
|
+
},
|
|
170
|
+
details: {
|
|
171
|
+
type: "string",
|
|
172
|
+
title: "Description or tickets",
|
|
173
|
+
description: "Describe what to check in a sentence, or paste the tickets with a line of --- between them. To upload a file of tickets instead, leave this empty and I will ask for it.",
|
|
174
|
+
},
|
|
175
|
+
realData: { type: "string", title: "Real data", description: askOf("real-data"), oneOf: [...FORM_CHOICES.realData], default: "unsure" },
|
|
176
|
+
},
|
|
177
|
+
required: ["address", "signIn", "whatToCheck", "realData"],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const oneOf = (list, value) => list.find((c) => c.const === value)?.const;
|
|
181
|
+
/** Pasted tickets, split on a line of three or more dashes, empty ones dropped. */
|
|
182
|
+
export function splitTickets(text) {
|
|
183
|
+
return text
|
|
184
|
+
.split(/^[ \t]*-{3,}[ \t]*$/m)
|
|
185
|
+
.map((t) => t.trim())
|
|
186
|
+
.filter(Boolean);
|
|
187
|
+
}
|
|
188
|
+
const SIGN_IN_WORDS = {
|
|
189
|
+
none: "no sign-in",
|
|
190
|
+
sso: "Google or Microsoft single sign-on",
|
|
191
|
+
password: "an email and password",
|
|
192
|
+
"one-time-code": "a one-time code",
|
|
193
|
+
};
|
|
194
|
+
/**
|
|
195
|
+
* What a form round comes to. Accepted answers become settings through
|
|
196
|
+
* settingsFromAnswers. Anything else asks in chat: all four questions when
|
|
197
|
+
* there was no form or the person turned it down, and only what is still
|
|
198
|
+
* missing when the form came back without it (tickets to upload, an empty
|
|
199
|
+
* description). An answer is never guessed.
|
|
200
|
+
*/
|
|
201
|
+
export function intakeFromForm(outcome) {
|
|
202
|
+
const all = (why) => ({ kind: "ask", questions: [...INTAKE_QUESTIONS], known: [], why });
|
|
203
|
+
if (outcome.kind === "unsupported")
|
|
204
|
+
return all("this client cannot show a form");
|
|
205
|
+
if (outcome.kind === "declined")
|
|
206
|
+
return all("the person declined the form");
|
|
207
|
+
if (outcome.kind === "cancelled")
|
|
208
|
+
return all("the person closed the form without answering");
|
|
209
|
+
if (outcome.kind === "failed")
|
|
210
|
+
return all(`the form failed: ${outcome.error}`);
|
|
211
|
+
const c = outcome.content;
|
|
212
|
+
const address = typeof c.address === "string" ? c.address.trim() : "";
|
|
213
|
+
const signIn = oneOf(FORM_CHOICES.signIn, c.signIn);
|
|
214
|
+
const kind = oneOf(FORM_CHOICES.whatToCheck, c.whatToCheck);
|
|
215
|
+
const realData = oneOf(FORM_CHOICES.realData, c.realData);
|
|
216
|
+
const details = typeof c.details === "string" ? c.details.trim() : "";
|
|
217
|
+
let whatToCheck;
|
|
218
|
+
// "Everything" with text in the box is a contradiction (the choice starts on everything, so pasted tickets
|
|
219
|
+
// would be dropped unseen): asked again rather than guessed either way.
|
|
220
|
+
if (kind === "everything" && !details)
|
|
221
|
+
whatToCheck = "everything";
|
|
222
|
+
else if (kind === "description" && details)
|
|
223
|
+
whatToCheck = { description: details };
|
|
224
|
+
else if (kind === "tickets" && splitTickets(details).length > 0)
|
|
225
|
+
whatToCheck = { tickets: splitTickets(details) };
|
|
226
|
+
const missing = INTAKE_QUESTIONS.filter((q) => (q.id === "address" && !address) || (q.id === "sign-in" && !signIn) || (q.id === "what-to-check" && !whatToCheck) || (q.id === "real-data" && !realData));
|
|
227
|
+
if (!address || !signIn || !whatToCheck || !realData) {
|
|
228
|
+
const known = [
|
|
229
|
+
address ? `Address: ${address}` : "",
|
|
230
|
+
signIn ? `Sign-in: ${SIGN_IN_WORDS[signIn]}` : "",
|
|
231
|
+
kind === "everything" && details
|
|
232
|
+
? "What to check: everything was chosen, but there is text in the box too"
|
|
233
|
+
: kind
|
|
234
|
+
? `What to check: ${kind}${whatToCheck ? "" : ", not given yet"}`
|
|
235
|
+
: "",
|
|
236
|
+
realData ? `Real data: ${FORM_CHOICES.realData.find((r) => r.const === realData).title.toLowerCase()}` : "",
|
|
237
|
+
].filter(Boolean);
|
|
238
|
+
return { kind: "ask", questions: missing, known, why: "the form came back without every answer" };
|
|
239
|
+
}
|
|
240
|
+
return { kind: "settings", settings: settingsFromAnswers({ address, signIn, whatToCheck, realData }) };
|
|
241
|
+
}
|
|
242
|
+
/** scout_intake's reply as text: the calls to make in order, or the questions to ask in chat. */
|
|
243
|
+
export function intakeReplyText(reply) {
|
|
244
|
+
if (reply.kind === "ask") {
|
|
245
|
+
const numbered = reply.questions.map((q, i) => `${i + 1}. ${q.ask}`).join("\n");
|
|
246
|
+
return [
|
|
247
|
+
`No settings from a form (${reply.why}). Ask the person ${reply.questions.length === 1 ? "this question" : "these questions"} in chat, in one message, then choose the settings from the answers as the method says.`,
|
|
248
|
+
...(reply.known.length > 0 ? ["", "Already answered in the form:", ...reply.known.map((k) => `- ${k}`)] : []),
|
|
249
|
+
"",
|
|
250
|
+
numbered,
|
|
251
|
+
].join("\n");
|
|
252
|
+
}
|
|
253
|
+
const s = reply.settings;
|
|
254
|
+
const steps = [];
|
|
255
|
+
if (s.login) {
|
|
256
|
+
steps.push(`Tell the person: "${s.login.tellUser}"`);
|
|
257
|
+
steps.push(`Call ${s.login.tool} ${JSON.stringify({ url: s.login.url, role: s.login.role })}, which returns once they are signed in.`);
|
|
258
|
+
}
|
|
259
|
+
steps.push(`Call scout_attach ${JSON.stringify(s.attach)}, with projectPath as the method's setup says.`);
|
|
260
|
+
if (s.readTickets)
|
|
261
|
+
steps.push(`Once attached, read the tickets: ${s.readTickets.tool} ${JSON.stringify({ text: s.readTickets.text })}.`);
|
|
262
|
+
if (s.focus)
|
|
263
|
+
steps.push(`Keep to this area: ${s.focus}`);
|
|
264
|
+
return ["The person answered the form. Use these settings; do not ask the questions again.", ...steps.map((step, i) => `${i + 1}. ${step}`)].join("\n");
|
|
265
|
+
}
|
package/dist/mcp-server.js
CHANGED
|
@@ -53,6 +53,8 @@ import { MAX_UNFILED_NAMED, unfiledDefects } from "./engine/calibration.js";
|
|
|
53
53
|
import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
|
|
54
54
|
import { FIXTURE_KINDS } from "./engine/fixtures.js";
|
|
55
55
|
import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, liveEngines, liveTokenFileName, pidAlive, statusFileName, LiveServer, StatusBoard, } from "./engine/live.js";
|
|
56
|
+
import { liveViewUrl, MCP_APP_MIME, paneData, paneText, STATUS_PANE_URI, STATUS_POLL_TOOL, STATUS_TOOL, } from "./engine/status-pane.js";
|
|
57
|
+
import { statusPanePage } from "./engine/status-pane-page.js";
|
|
56
58
|
import { formatBriefs, MAX_LANES, planLanes } from "./engine/brief.js";
|
|
57
59
|
import { decideOpen, OPEN_CHOICES, OPEN_ENV, openChoiceFromEnv, openInBrowser } from "./engine/open.js";
|
|
58
60
|
import { DEFAULT_EXPIRY_MARGIN_MINUTES, DEFAULT_RUN_MINUTES, judgeProfileFile } from "./engine/expiry.js";
|
|
@@ -69,7 +71,9 @@ import { chooseProjectFolder, PROJECTS_DIR_ENV, workspaceFromRoots } from "./eng
|
|
|
69
71
|
import { RECORD_MAX_FRAMES, resolveFrame } from "./engine/replay.js";
|
|
70
72
|
import { describePace, normalizePace } from "./engine/settle.js";
|
|
71
73
|
import { needsTask, taskRefusal, TASK_MAX } from "./engine/task.js";
|
|
74
|
+
import { formElicitationSupported, INTAKE_FORM_MESSAGE, INTAKE_TOOL, intakeFormSchema, intakeFromForm, intakeReplyText } from "./intake.js";
|
|
72
75
|
import { EXPLORE_PROMPT_ARGUMENTS, explorePrompt, loadPlaybook, PLAYBOOK_PROMPT, PLAYBOOK_TOOL, SERVER_INSTRUCTIONS } from "./playbook.js";
|
|
76
|
+
import { livePrompt, loginPrompt, LOGIN_PROMPT, LIVE_PROMPT, LOGIN_PROMPT_ARGUMENTS, LIVE_PROMPT_ARGUMENTS } from "./prompts.js";
|
|
73
77
|
import { formatScan, scanProject } from "./scan.js";
|
|
74
78
|
import { CAPTURE_MARGIN, CAPTURES_DIRNAME, captureFileName, captureResultText, describePicture, EVIDENCE_ENV, EVIDENCE_LIMITS, EVIDENCE_MARGIN, EVIDENCE_MODES, evidenceFrame, evidenceSettings, findingPicturePath, MAX_CAPTURE_MARGIN, RECORD_ENV, recordChoice, returnsInline, } from "./engine/capture.js";
|
|
75
79
|
import { decodePng, fitPicture } from "./engine/png.js";
|
|
@@ -346,7 +350,7 @@ function startLiveServer() {
|
|
|
346
350
|
function liveLine() {
|
|
347
351
|
if (!liveAddress)
|
|
348
352
|
return liveError ? `\nLive view unavailable: ${liveError}` : "";
|
|
349
|
-
return (`\nLive view:
|
|
353
|
+
return (`\nLive view: ${liveViewUrl(liveAddress.port, liveAddress.token)} — give this address to the user so they can watch every session ` +
|
|
350
354
|
`(current tool, page thumbnail, optional live stream). It opens on this machine only and cannot act on the run.`);
|
|
351
355
|
}
|
|
352
356
|
/** What each session's attach decided to open (engine/open.ts). scout_report reads it for the report. */
|
|
@@ -540,10 +544,51 @@ server.registerTool(PLAYBOOK_TOOL, {
|
|
|
540
544
|
return errorText(err);
|
|
541
545
|
}
|
|
542
546
|
});
|
|
543
|
-
// The
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
+
// The start-of-run questions as one form, where the client can show one (MCP
|
|
548
|
+
// elicitation, form mode); otherwise, or when the person declines or closes it,
|
|
549
|
+
// the questions come back as text for the agent to ask in chat. The mapping is
|
|
550
|
+
// intake.ts's; this only shows the form and reports how it ended.
|
|
551
|
+
/** How long the form waits for the person before the questions go back to chat. */
|
|
552
|
+
const INTAKE_FORM_WAIT_MS = 10 * 60_000;
|
|
553
|
+
server.registerTool(INTAKE_TOOL, {
|
|
554
|
+
description: "Ask the start-of-run questions (the site's address, whether and how to sign in, what to check, whether the site holds real data) when the person gave no settings. " +
|
|
555
|
+
"Where this client can show a form, the person answers there and this returns the settings to use: the scout_login and scout_attach calls, in order. " +
|
|
556
|
+
"Otherwise, or when they decline or close the form, it returns the questions for you to ask in chat. Never asks for a password. Touches no browser.",
|
|
557
|
+
inputSchema: {
|
|
558
|
+
url: z.string().max(2048).optional().describe("The site's address, when the person already said it: the form starts with it filled in"),
|
|
559
|
+
},
|
|
560
|
+
}, async ({ url }, extra) => {
|
|
561
|
+
let outcome;
|
|
562
|
+
if (!formElicitationSupported(server.server.getClientCapabilities()))
|
|
563
|
+
outcome = { kind: "unsupported" };
|
|
564
|
+
else {
|
|
565
|
+
try {
|
|
566
|
+
const result = await server.server.elicitInput({ mode: "form", message: INTAKE_FORM_MESSAGE, requestedSchema: intakeFormSchema(url) }, { timeout: INTAKE_FORM_WAIT_MS, signal: extra.signal, relatedRequestId: extra.requestId });
|
|
567
|
+
outcome =
|
|
568
|
+
result.action === "accept"
|
|
569
|
+
? { kind: "accepted", content: result.content ?? {} }
|
|
570
|
+
: result.action === "decline"
|
|
571
|
+
? { kind: "declined" }
|
|
572
|
+
: { kind: "cancelled" };
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
// A client that advertised forms and then failed one: the questions still get asked, in chat, with why.
|
|
576
|
+
outcome = { kind: "failed", error: err instanceof Error ? err.message : String(err) };
|
|
577
|
+
console.error(`[scenescout] ${INTAKE_TOOL}: the form failed, asking in chat instead: ${outcome.error}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
return { content: [{ type: "text", text: intakeReplyText(intakeFromForm(outcome)) }] };
|
|
582
|
+
}
|
|
583
|
+
catch (err) {
|
|
584
|
+
return errorText(err);
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
// Prompts, for clients that list server prompts as commands. Registered on the
|
|
588
|
+
// protocol server directly: the SDK's prompt helper rejects a request that
|
|
589
|
+
// carries no `arguments` object, which is exactly what a client sends when the
|
|
590
|
+
// person typed none. `explore` and `live` accept that; `login` then says the
|
|
591
|
+
// role is missing. None of them takes a password.
|
|
547
592
|
server.server.registerCapabilities({ prompts: {} });
|
|
548
593
|
server.server.setRequestHandler(ListPromptsRequestSchema, () => ({
|
|
549
594
|
prompts: [
|
|
@@ -553,16 +598,36 @@ server.server.setRequestHandler(ListPromptsRequestSchema, () => ({
|
|
|
553
598
|
description: "Start an exploratory test session: loads the SceneScout method and states the target.",
|
|
554
599
|
arguments: EXPLORE_PROMPT_ARGUMENTS,
|
|
555
600
|
},
|
|
601
|
+
{
|
|
602
|
+
name: LIVE_PROMPT,
|
|
603
|
+
title: "Watch the live view",
|
|
604
|
+
description: "Return the loopback live-view URL for the current session. Takes no arguments and no password.",
|
|
605
|
+
arguments: LIVE_PROMPT_ARGUMENTS,
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
name: LOGIN_PROMPT,
|
|
609
|
+
title: "Sign in as a role",
|
|
610
|
+
description: "Call scout_login for a role and wait the way that tool waits. The person signs in in the window it opens. Takes the role, and the app URL when you have it. Never a password.",
|
|
611
|
+
arguments: LOGIN_PROMPT_ARGUMENTS,
|
|
612
|
+
},
|
|
556
613
|
],
|
|
557
614
|
}));
|
|
558
615
|
server.server.setRequestHandler(GetPromptRequestSchema, (request) => {
|
|
559
|
-
|
|
560
|
-
throw new McpError(ErrorCode.InvalidParams, `Unknown prompt: ${request.params.name}`);
|
|
616
|
+
const name = request.params.name;
|
|
561
617
|
let message;
|
|
562
618
|
try {
|
|
563
|
-
|
|
619
|
+
if (name === PLAYBOOK_PROMPT)
|
|
620
|
+
message = explorePrompt(loadPlaybook(PACKAGE_ROOT), request.params.arguments);
|
|
621
|
+
else if (name === LIVE_PROMPT)
|
|
622
|
+
message = livePrompt(request.params.arguments);
|
|
623
|
+
else if (name === LOGIN_PROMPT)
|
|
624
|
+
message = loginPrompt(request.params.arguments);
|
|
625
|
+
else
|
|
626
|
+
throw new McpError(ErrorCode.InvalidParams, `Unknown prompt: ${name}`);
|
|
564
627
|
}
|
|
565
628
|
catch (err) {
|
|
629
|
+
if (err instanceof McpError)
|
|
630
|
+
throw err;
|
|
566
631
|
throw new McpError(ErrorCode.InvalidParams, err instanceof Error ? err.message : String(err));
|
|
567
632
|
}
|
|
568
633
|
return { messages: [{ role: "user", content: { type: "text", text: message } }] };
|
|
@@ -950,7 +1015,7 @@ server.registerTool("scout_attach", {
|
|
|
950
1015
|
let openNote = "";
|
|
951
1016
|
if (opening.live && liveAddress && !liveOpened) {
|
|
952
1017
|
liveOpened = true;
|
|
953
|
-
openNote = openForUser("the live view",
|
|
1018
|
+
openNote = openForUser("the live view", liveViewUrl(liveAddress.port, liveAddress.token));
|
|
954
1019
|
}
|
|
955
1020
|
else if (!opening.live && liveAddress && !openWhySaid) {
|
|
956
1021
|
// Once per server, so a person wondering why no window appeared is told how to change it.
|
|
@@ -1760,6 +1825,67 @@ server.registerTool("scout_finding", {
|
|
|
1760
1825
|
return errorText(err);
|
|
1761
1826
|
}
|
|
1762
1827
|
}));
|
|
1828
|
+
// ---- The run-status pane: an MCP App (SEP-1865, `io.modelcontextprotocol/ui`, spec 2026-01-26). ----
|
|
1829
|
+
// scout_status links the pane through _meta.ui.resourceUri; the pane polls the
|
|
1830
|
+
// app-only tool. Neither goes through a session queue: like the live view, a
|
|
1831
|
+
// watcher must never wait behind the agent's calls, and both only read.
|
|
1832
|
+
function statusPaneData() {
|
|
1833
|
+
const eng = [lastWriter ? engines.get(lastWriter.session) : undefined, ...engines.values()].find((e) => !!e?.memory);
|
|
1834
|
+
const memory = eng?.memory ?? null;
|
|
1835
|
+
let coverage = null;
|
|
1836
|
+
if (eng && memory) {
|
|
1837
|
+
const all = eng.allKnownRoutes();
|
|
1838
|
+
const cov = memory.coverage();
|
|
1839
|
+
coverage = {
|
|
1840
|
+
routesVisited: all.length - eng.unvisitedKnownRoutes().length,
|
|
1841
|
+
routesTotal: all.length,
|
|
1842
|
+
states: cov.states,
|
|
1843
|
+
elementsExercised: cov.elementsExercised,
|
|
1844
|
+
elementsTotal: cov.elementsTotal,
|
|
1845
|
+
};
|
|
1846
|
+
}
|
|
1847
|
+
return paneData({
|
|
1848
|
+
nowMs: Date.now(),
|
|
1849
|
+
version: PKG_VERSION,
|
|
1850
|
+
live: liveAddress,
|
|
1851
|
+
liveError,
|
|
1852
|
+
liveOff: process.env[LIVE_ENV] === "off",
|
|
1853
|
+
sessions: board.list(),
|
|
1854
|
+
findings: memory ? memory.findings : null,
|
|
1855
|
+
runStart: memory?.sessionStart,
|
|
1856
|
+
coverage,
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
function statusResult() {
|
|
1860
|
+
try {
|
|
1861
|
+
const data = statusPaneData();
|
|
1862
|
+
return { content: [{ type: "text", text: paneText(data) }], structuredContent: { ...data } };
|
|
1863
|
+
}
|
|
1864
|
+
catch (err) {
|
|
1865
|
+
return errorText(err);
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
server.registerResource("scenescout-status", STATUS_PANE_URI, {
|
|
1869
|
+
title: "SceneScout run status",
|
|
1870
|
+
description: "The run's sessions, open findings by severity, coverage and the live view's address, refreshed every few seconds.",
|
|
1871
|
+
mimeType: MCP_APP_MIME,
|
|
1872
|
+
// No outside origin: the page is one self-contained document, so the host's restrictive default CSP applies.
|
|
1873
|
+
_meta: { ui: { csp: {}, prefersBorder: true } },
|
|
1874
|
+
}, () => ({
|
|
1875
|
+
contents: [{ uri: STATUS_PANE_URI, mimeType: MCP_APP_MIME, text: statusPanePage(PKG_VERSION), _meta: { ui: { csp: {}, prefersBorder: true } } }],
|
|
1876
|
+
}));
|
|
1877
|
+
server.registerTool(STATUS_TOOL, {
|
|
1878
|
+
title: "Run status",
|
|
1879
|
+
description: "Show the person running you how the run stands: each session's objective and current task, open findings by severity, coverage, and the live view's address. " +
|
|
1880
|
+
"Call it when the user wants to watch or asks how the run is going. A host that renders MCP Apps shows a pane that keeps itself up to date; every other host gets the same as text, and you pass the `Live view:` address on. Takes no input and touches no browser.",
|
|
1881
|
+
// The legacy flat key alongside _meta.ui.resourceUri, as the ext-apps SDK's registerAppTool sets both for hosts that read only the old one.
|
|
1882
|
+
_meta: { ui: { resourceUri: STATUS_PANE_URI }, "ui/resourceUri": STATUS_PANE_URI },
|
|
1883
|
+
}, async () => statusResult());
|
|
1884
|
+
server.registerTool(STATUS_POLL_TOOL, {
|
|
1885
|
+
title: "Run status (for the pane)",
|
|
1886
|
+
description: `Called by the ${STATUS_TOOL} pane every few seconds to refresh itself. Not for the agent: call ${STATUS_TOOL} instead.`,
|
|
1887
|
+
_meta: { ui: { visibility: ["app"] } },
|
|
1888
|
+
}, async () => statusResult());
|
|
1763
1889
|
server.registerTool("scout_coverage", {
|
|
1764
1890
|
description: "Show exploration coverage: states visited, which elements remain unexercised, which options of a dropdown used this run no session has chosen yet, and which forms seen this run no session has submitted with every text field blank. Use to decide where to explore next and when the level's budget is satisfied. In a parallel run it shows this session's own work by default — the routes it reached this run and the forms it saw — so one lane is not handed another's gaps; scope:\"project\" shows every session's, each form and route tagged with the sessions that saw it.",
|
|
1765
1891
|
inputSchema: {
|
package/dist/playbook.js
CHANGED
|
@@ -77,7 +77,7 @@ export function explorePrompt(playbook, args) {
|
|
|
77
77
|
}
|
|
78
78
|
// Nothing asked for at all: the person has chosen no settings, so the run starts with the plain questions.
|
|
79
79
|
if (given("url") === undefined && level === undefined && given("focus") === undefined) {
|
|
80
|
-
return `${playbook}\n\n---\n\nRun an exploratory test session following the method above. I gave no settings: before setup,
|
|
80
|
+
return `${playbook}\n\n---\n\nRun an exploratory test session following the method above. I gave no settings: before setup, call scout_intake, which asks me these questions in a form where this client can show one; when it returns them as text instead, ask me them in one message, then choose the settings from my answers as the method says.\n${introQuestions()}`;
|
|
81
81
|
}
|
|
82
82
|
const asks = [
|
|
83
83
|
given("url") ? `Target: ${given("url")}` : "Target: ask me for the URL of the running app, or find it from the project.",
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP prompts other than the testing method.
|
|
3
|
+
*
|
|
4
|
+
* `explore` lives in playbook.ts because its text is the skill. `live` and
|
|
5
|
+
* `login` are short instructions: hand back the loopback live-view address,
|
|
6
|
+
* or call scout_login for a role. Neither accepts a password. The protocol
|
|
7
|
+
* server in mcp-server.ts lists all three; the SDK prompt helper is not used,
|
|
8
|
+
* because it rejects a request that omits `arguments`.
|
|
9
|
+
*/
|
|
10
|
+
import { validateRoleName } from "./engine/profiles.js";
|
|
11
|
+
export const LIVE_PROMPT = "live";
|
|
12
|
+
export const LOGIN_PROMPT = "login";
|
|
13
|
+
/** `live` takes nothing. A password field here would ask the person for a credential the live view does not use. */
|
|
14
|
+
export const LIVE_PROMPT_ARGUMENTS = [];
|
|
15
|
+
/** `login` names a role, and the app's address when the caller has one. There is no password argument. */
|
|
16
|
+
export const LOGIN_PROMPT_ARGUMENTS = [
|
|
17
|
+
{
|
|
18
|
+
name: "role",
|
|
19
|
+
description: "The name to save the sign-in under, e.g. admin. scout_attach { role } uses it afterwards. Not a password.",
|
|
20
|
+
required: true,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "url",
|
|
24
|
+
description: "The app's address or its sign-in page, when you have it, e.g. http://localhost:3000. Omit when the conversation already has it.",
|
|
25
|
+
required: false,
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* Argument names that would carry a credential. Matched on the name only, so
|
|
30
|
+
* a refusal can name the argument without repeating the value the client sent.
|
|
31
|
+
*/
|
|
32
|
+
const CREDENTIAL_ARGUMENT = /^(?:password|passwd|pass|secret|token|credential|credentials|otp|totp|mfa|apikey|api_key)$/i;
|
|
33
|
+
function refuseCredentialArguments(args) {
|
|
34
|
+
if (!args)
|
|
35
|
+
return;
|
|
36
|
+
for (const key of Object.keys(args)) {
|
|
37
|
+
if (CREDENTIAL_ARGUMENT.test(key)) {
|
|
38
|
+
throw new Error(`this prompt does not take ${key}: never pass a password or any other credential`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The opening message of the `live` prompt. The address itself is printed by
|
|
44
|
+
* the engine on tool results (`Live view: http://127.0.0.1:…`); this only
|
|
45
|
+
* tells the model to hand that line back. Any argument is refused.
|
|
46
|
+
*/
|
|
47
|
+
export function livePrompt(args) {
|
|
48
|
+
refuseCredentialArguments(args);
|
|
49
|
+
const given = Object.keys(args ?? {}).filter((key) => args?.[key] !== undefined);
|
|
50
|
+
if (given.length > 0)
|
|
51
|
+
throw new Error("the live prompt takes no arguments");
|
|
52
|
+
return ("Return the loopback live-view URL for the current session.\n\n" +
|
|
53
|
+
"The live view is one address for every session of this run. It is served on 127.0.0.1 only, and a tool result prints it as a line starting " +
|
|
54
|
+
'"Live view: http://127.0.0.1:". If this conversation does not already contain that line, call scout_session with no arguments and read it from the result. ' +
|
|
55
|
+
"Calling scout_session with a name would change the default session; do not pass one.\n\n" +
|
|
56
|
+
"Reply with that address. Where you can open a URL for the user, you may open it as well. " +
|
|
57
|
+
"If the result says the live view is unavailable, say so and do not invent an address.\n\n" +
|
|
58
|
+
"Do not ask for a password or any other credential. This prompt takes none.");
|
|
59
|
+
}
|
|
60
|
+
/** An http(s) address with no userinfo. The raw string is returned only after that check, so a refusal never quotes a password embedded in it. */
|
|
61
|
+
function publicHttpUrl(raw) {
|
|
62
|
+
let url;
|
|
63
|
+
try {
|
|
64
|
+
url = new URL(raw);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
throw new Error("url must be an http or https address");
|
|
68
|
+
}
|
|
69
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
70
|
+
throw new Error(`only http and https URLs can be opened (got ${url.protocol})`);
|
|
71
|
+
if (url.username || url.password)
|
|
72
|
+
throw new Error("put no credentials in the URL: sign in in the window instead");
|
|
73
|
+
return raw;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The opening message of the `login` prompt: call scout_login for the role and
|
|
77
|
+
* wait the way that tool already waits. A missing or illegal role is refused.
|
|
78
|
+
* A password argument is refused without the value being repeated.
|
|
79
|
+
*/
|
|
80
|
+
export function loginPrompt(args) {
|
|
81
|
+
refuseCredentialArguments(args);
|
|
82
|
+
const given = args ?? {};
|
|
83
|
+
const unknown = Object.keys(given).filter((key) => key !== "role" && key !== "url");
|
|
84
|
+
if (unknown.length > 0)
|
|
85
|
+
throw new Error("the login prompt takes role and url only");
|
|
86
|
+
const checked = validateRoleName(typeof given.role === "string" ? given.role.trim() : given.role);
|
|
87
|
+
if (!checked.ok)
|
|
88
|
+
throw new Error(checked.error);
|
|
89
|
+
const url = publicHttpUrlOrNone(typeof given.url === "string" ? given.url.trim() : "");
|
|
90
|
+
// JSON so a quote in the address cannot rewrite the instruction. The address has already been refused when it carries credentials.
|
|
91
|
+
const where = url
|
|
92
|
+
? `Call scout_login with ${JSON.stringify({ role: checked.role, url })}.`
|
|
93
|
+
: `Call scout_login with role ${JSON.stringify(checked.role)} and the app's address as url. ` +
|
|
94
|
+
"Use the address this conversation already has; if it has none, ask for the app's address or its sign-in page, then pass that as url.";
|
|
95
|
+
return (`Sign in as the role "${checked.role}" by calling scout_login. ` +
|
|
96
|
+
"Do not ask for a password, and do not type credentials yourself: the person signs in in the browser window the tool opens.\n\n" +
|
|
97
|
+
'Tell them first, in plain words: "A browser window is opening. Sign in there as you normally would; it closes by itself once you are in."\n\n' +
|
|
98
|
+
`${where}\n\n` +
|
|
99
|
+
"The call returns once they are signed in and the login is saved, or after its wait with the window still open. " +
|
|
100
|
+
`When it is still waiting, call scout_login again with the same role ("${checked.role}") to keep waiting, once they say they are done or to check. ` +
|
|
101
|
+
"Never pass a password.");
|
|
102
|
+
}
|
|
103
|
+
/** `publicHttpUrl("")` would throw. An omitted url is the optional argument left out, not an illegal address. */
|
|
104
|
+
function publicHttpUrlOrNone(raw) {
|
|
105
|
+
return raw ? publicHttpUrl(raw) : "";
|
|
106
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scenescout",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.18.0",
|
|
4
4
|
"description": "SceneScout — exploratory UI testing for AI coding agents. An MCP server that gives any agent (Claude Code, Cursor, VS Code Copilot, Codex, Gemini CLI and others) a structured view of a running web app, always-on oracles, a network-level write policy, memory across runs and a gap-checked report.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "brunoboto96",
|
|
@@ -111,14 +111,14 @@
|
|
|
111
111
|
"dedup-bench": "tsx scripts/dedup-bench.ts"
|
|
112
112
|
},
|
|
113
113
|
"dependencies": {
|
|
114
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
114
|
+
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
115
115
|
"playwright": "^1.63.0",
|
|
116
116
|
"zod": "^3.25.0"
|
|
117
117
|
},
|
|
118
118
|
"devDependencies": {
|
|
119
119
|
"@changesets/cli": "3.0.3",
|
|
120
120
|
"@types/node": "^20.19.0",
|
|
121
|
-
"prettier": "3.9.
|
|
121
|
+
"prettier": "3.9.9",
|
|
122
122
|
"tsx": "^4.23.15",
|
|
123
123
|
"typescript": "^5.9.3",
|
|
124
124
|
"yaml": "^2.9.1"
|