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/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 r = spawnSync(command, args, { encoding: "utf8", timeout: 60_000, cwd: opts?.cwd });
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
- // On Windows node refuses to start a .cmd or .bat file directly (EINVAL). For
24
- // the caller that is the same situation as a missing binary: nothing ran, and
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
- // npm on Windows is a .cmd shim, which node cannot start directly.
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
+ }
@@ -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: http://127.0.0.1:${liveAddress.port}/${liveAddress.token}/ — give this address to the user so they can watch every session ` +
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 same method as a prompt, for clients that list server prompts as commands.
544
- // Registered on the protocol server directly: the SDK's prompt helper rejects a
545
- // request that carries no `arguments` object, which is exactly what a client
546
- // sends when the person typed none, and every argument here is optional.
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
- if (request.params.name !== PLAYBOOK_PROMPT)
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
- message = explorePrompt(loadPlaybook(PACKAGE_ROOT), request.params.arguments);
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", `http://127.0.0.1:${liveAddress.port}/${liveAddress.token}/`);
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, ask me these questions in one message, then choose the settings from my answers as the method says.\n${introQuestions()}`;
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.",
@@ -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.16.0",
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.12.0",
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.8",
121
+ "prettier": "3.9.9",
122
122
  "tsx": "^4.23.15",
123
123
  "typescript": "^5.9.3",
124
124
  "yaml": "^2.9.1"