diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 305de00638..e1adc897ae 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -18,7 +18,7 @@ Inspect native SAZ or HAR 1.2 offline with REA, or connect Everywhere's separate - `inspect_web_page` / `rea inspect-web-page` captures DOM structure, accessibility nodes, scripts, resources, safe response/DOM metadata, attach-window network and console metadata, WebSocket frame sizes, workers, quota, and optionally selected storage key names, redacted storage-content fingerprints, or script sources. - `analyze_web_bundle` / `rea analyze-web-bundle` parses selected script artifacts without execution and derives chunk edges, route and endpoint candidates, vendor fingerprints, static WebMCP declarations, and optionally source-map/original-source evidence. - `observe_web_session` / `rea observe-web-session` arms an observation window of the requested duration for an external user action and records ordered reload, SPA navigation, redirect, failure, lifecycle, and target-termination metadata. -- `discover_webmcp_tools` / `rea discover-webmcp-tools` uses the experimental CDP WebMCP domain to return every in-scope registration and its complete observed `input_schema` declaration and canonical `input_schema_sha256` digest inline. Declarations are untrusted JSON data, including declared types, required fields, enums, examples and reference URLs. REA does not resolve schema references. These fields replace `input_schema_shape`. Registrations with the same name and URL in distinct observed frames retain separate identities. `tool_key` includes the observed frame ID; it is not a stable identity across navigation or browser sessions. Same-frame registration updates replace that owner only, and removal leaves other owners intact. Declared `readOnly`, `untrustedContent`, `consequential`, and `autosubmit` annotation hints are reported per registration: `true`/`false` are declared values, `null` means the declaration did not provide the hint in that capture, and a missing `consequential` key means the hint was not recorded, either in a historical capture or because the observed annotation was invalid. Consult discovery completeness and exclusions to distinguish these cases. Malformed annotation values retain the registration with incomplete coverage and an `invalid_protocol_value` exclusion. Other malformed protocol payloads can fail discovery explicitly. REA never exposes `WebMCP.invokeTool`. +- `discover_webmcp_tools` / `rea discover-webmcp-tools` uses the experimental CDP WebMCP domain to return every in-scope registration and its complete observed `input_schema` declaration and canonical `input_schema_sha256` digest inline. Declarations are untrusted JSON data, including declared types, required fields, enums, examples and reference URLs. REA does not resolve schema references. These fields replace `input_schema_shape`. Registrations with the same name and URL in distinct observed frames retain separate identities. `tool_key` includes the observed frame ID; it is not a stable identity across navigation or browser sessions. Same-frame registration updates replace that owner only, and removal leaves other owners intact. Declared `readOnly`, `untrustedContent`, `consequential`, and `autosubmit` annotation hints are reported per registration: `true`/`false` are declared values, `null` means the declaration did not provide the hint in that capture, and a missing `consequential` key means the hint was not recorded, either in a historical capture or because the observed annotation was invalid. Consult discovery completeness and exclusions to distinguish these cases. Malformed annotation values or registration stacks retain the registration with incomplete coverage and an `invalid_protocol_value` exclusion. Other malformed protocol payloads can fail discovery explicitly. REA never exposes `WebMCP.invokeTool`. - `compare_web_captures` / `rea compare-web-captures` compares passive captures or exact-step-aligned browser scenarios. Scenario results commit reproducible literal normalization and expose alignment failures plus artifact-level action, screenshot, DOM, accessibility, URL, history, storage, and event differences. Missing or truncated evidence is never treated as equivalence. @@ -361,7 +361,7 @@ REA retains local observation data and removes only URL userinfo credentials, ex WebSocket stream inventories and connection counts likewise require complete WebSocket coverage. Redirect resource types use only shared known metadata: an omitted type leaves equality unknown rather than proving a modification. -- Console observations with a stack source retain call type, argument types, the CDP `Runtime.Timestamp` in milliseconds since Unix epoch, and source location. An absent or invalid timestamp is `null` and makes `console_events` incomplete; an observed zero remains zero. This epoch-millisecond clock is separate from the monotonic-second timestamps in `observe-web-session`. When console text capture is selected, already-delivered primitive values are retained verbatim; objects, getters, and remote properties are never expanded. +- Console observations with a stack source retain call type, argument types, the CDP `Runtime.Timestamp` in milliseconds since Unix epoch, and source location. An absent or invalid timestamp is `null` and makes `console_events` incomplete; an observed zero remains zero. Argument types are listed by CDP argument position. An absent or non-array argument inventory is `null`, and a malformed argument keeps its position as `unknown`; either makes `console_events` incomplete, while an observed empty inventory remains `[]`. A malformed stack or top stack frame is not replaced by its caller: the console event is excluded, and a request initiator location or WebMCP `registration_source` is `null`; each is reported as `invalid_protocol_value`. This epoch-millisecond clock is separate from the monotonic-second timestamps in `observe-web-session`. When console text capture is selected, already-delivered primitive values are retained verbatim with their original `argument_index`; objects, getters, and remote properties are never expanded. Text capture is `unavailable` when the argument inventory is unknown. - WebSocket observations retain direction, opcode, and payload byte length. A missing or malformed CDP opcode is returned as `null`, with opcode-dependent payload byte length and shape also `null`, and marks `websocket_frames` incomplete. Missing or wrong-typed payload data is not treated as empty; its byte length is `null`, and a text payload shape is unknown. Numeric opcode `0` and an observed empty payload remain distinct from missing data. Opcode is nullable in the public inspection schema, so clients must handle unknown protocol values. When shape capture is selected, text messages are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw payloads are never retained. - Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache fingerprints hash all decoded response bytes, including bodies above 64 KiB, subject to the [64 MiB per-message CDP limit](#request-boundary). Missing or undecodable cache bodies and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`. - Unavailable or malformed storage inventories mark `storage_keys` unavailable, even without fingerprints. Comparisons suppress inferred key additions and removals while preserving modifications proven by complete fingerprints on both sides. Previously exported captures are not retroactively corrected; recapture them to obtain these coverage markers. diff --git a/src/browser/CdpCaptureEventHandlers.ts b/src/browser/CdpCaptureEventHandlers.ts index 493925d63f..9519cd785b 100644 --- a/src/browser/CdpCaptureEventHandlers.ts +++ b/src/browser/CdpCaptureEventHandlers.ts @@ -7,8 +7,8 @@ import { allowedSanitizedUrl, isHttpUrl, numberValue, + admitRecords, recordValue, - recordsValue, scriptMetadataValues, cdpStringValue, type UnknownRecord, @@ -21,11 +21,11 @@ import { consolePrimitive, exclusionReasonForUrl, executionContextKey, - firstCallFrame, initiatorLocation, integerOrNull, isJsonMediaType, cdpFrameIdentity, + topCallFrame, } from "./CdpCaptureEventHelpers.js"; import type { CdpCaptureEventsState } from "./CdpCaptureEventState.js"; import { @@ -305,7 +305,10 @@ export const handleRequestWillBeSent = ( }); } } - const initiatorFrame = initiatorLocation(initiator); + const location = initiatorLocation(initiator); + if (!location.ok) + state.completeness.exclude("network_initiators", "invalid_protocol_value"); + const initiatorFrame = location.ok ? location.value : undefined; const rawInitiatorUrl = cdpStringValue(initiatorFrame?.url); const initiatorUrl = allowedSanitizedUrl( rawInitiatorUrl, @@ -438,7 +441,12 @@ export const handleConsoleAPICalled = ( state: CdpCaptureEventsState, params: UnknownRecord, ): void => { - const frame = firstCallFrame(recordValue(params.stackTrace)); + const topFrame = topCallFrame(params.stackTrace); + if (!topFrame.ok) { + state.completeness.exclude("console_events", "invalid_protocol_value"); + return; + } + const frame = topFrame.value; if (frame === undefined) { state.completeness.exclude("console_events", "unattributed_origin"); return; @@ -451,16 +459,26 @@ export const handleConsoleAPICalled = ( ); return; } - const arguments_ = recordsValue(params.args); + // Malformed elements keep their position so retained siblings keep their + // CDP argument indices; a missing inventory is unknown, not empty. + const arguments_ = admitRecords(params.args)?.map((argument) => + recordValue(argument), + ); + const argumentTypes = arguments_?.map((argument) => + cdpStringValue(argument?.type), + ); const timestamp = numberValue(params.timestamp); - if (timestamp === undefined) + if ( + timestamp === undefined || + argumentTypes === undefined || + argumentTypes.includes(undefined) + ) state.completeness.exclude("console_events", "invalid_protocol_value"); state.console.push({ type: cdpStringValue(params.type) ?? "unknown", timestamp: timestamp ?? null, - argument_types: arguments_.map( - (argument) => cdpStringValue(argument.type) ?? "unknown", - ), + argument_types: + argumentTypes?.map((argumentType) => argumentType ?? "unknown") ?? null, url: source.url, line: integerOrNull(frame.lineNumber), column: integerOrNull(frame.columnNumber), @@ -470,7 +488,7 @@ export const handleConsoleAPICalled = ( const captureConsoleText = ( state: CdpCaptureEventsState, - arguments_: readonly UnknownRecord[], + arguments_: readonly (UnknownRecord | undefined)[] | undefined, ): WebPageInspection["console"]["events"][number]["text_capture"] => { if (!state.input.include_console_text) return { @@ -478,10 +496,17 @@ const captureConsoleText = ( values: [], retained_bytes: 0, }; + if (arguments_ === undefined) + return { + status: "unavailable", + values: [], + retained_bytes: 0, + }; const values: WebPageInspection["console"]["events"][number]["text_capture"]["values"] = []; let retainedBytes = 0; for (const [argumentIndex, argument] of arguments_.entries()) { + if (argument === undefined) continue; const primitive = consolePrimitive(argument); if (primitive === undefined) continue; const text = primitive.text; diff --git a/src/browser/CdpCaptureEventHelpers.ts b/src/browser/CdpCaptureEventHelpers.ts index d3e60f2450..e786ba09de 100644 --- a/src/browser/CdpCaptureEventHelpers.ts +++ b/src/browser/CdpCaptureEventHelpers.ts @@ -1,8 +1,9 @@ +import { err, ok, type Result } from "../domain/result.js"; import { + admitRecords, isHttpUrl, numberValue, recordValue, - recordsValue, cdpStringValue, type UnknownRecord, } from "./CdpCaptureValues.js"; @@ -136,20 +137,44 @@ export const cdpFrameIdentity = ( }; }; -export const firstCallFrame = ( - value: UnknownRecord | undefined, -): UnknownRecord | undefined => recordsValue(value?.callFrames)[0]; +export interface MalformedCdpCallFrame { + readonly _tag: "MalformedCdpCallFrame"; +} + +const malformedCallFrame: MalformedCdpCallFrame = { + _tag: "MalformedCdpCallFrame", +}; + +/** + * The call site is the frame at position zero. An omitted stack has no call + * site; a malformed stack or top frame stays unknown rather than promoting a + * caller to the call site. + */ +export const topCallFrame = ( + value: unknown, +): Result => { + if (value === undefined) return ok(undefined); + const stack = recordValue(value); + if (stack === undefined) return err(malformedCallFrame); + const frames = admitRecords(stack.callFrames); + if (frames === undefined) return err(malformedCallFrame); + if (frames.length === 0) return ok(undefined); + const frame = recordValue(frames[0]); + return frame === undefined ? err(malformedCallFrame) : ok(frame); +}; export const initiatorLocation = ( initiator: UnknownRecord | undefined, -): UnknownRecord | undefined => { - let stack = recordValue(initiator?.stack); +): Result => { + let stack = initiator?.stack; for (let depth = 0; depth < 32 && stack !== undefined; depth += 1) { - const frame = firstCallFrame(stack); - if (frame !== undefined) return frame; - stack = recordValue(stack.parent); + const frame = topCallFrame(stack); + if (!frame.ok || frame.value !== undefined) return frame; + stack = recordValue(stack)?.parent; } - return cdpStringValue(initiator?.url) === undefined ? undefined : initiator; + return ok( + cdpStringValue(initiator?.url) === undefined ? undefined : initiator, + ); }; export const exclusionReasonForUrl = ( diff --git a/src/browser/CdpWebMcpDiscovery.ts b/src/browser/CdpWebMcpDiscovery.ts index 51bfafc71a..713b207e14 100644 --- a/src/browser/CdpWebMcpDiscovery.ts +++ b/src/browser/CdpWebMcpDiscovery.ts @@ -20,7 +20,6 @@ import { delayWithCancellation, numberValue, recordValue, - recordsValue, cdpStringValue, type UnknownRecord, } from "./CdpCaptureValues.js"; @@ -30,6 +29,7 @@ import { walkFrameTrees, } from "./CdpCaptureDocuments.js"; import { optionalCdpCommand } from "./CdpOptionalCommand.js"; +import { topCallFrame } from "./CdpCaptureEventHelpers.js"; interface DiscoveryContext { readonly connection: CdpConnection; @@ -468,6 +468,9 @@ const normalizeTool = ( if (!declaration.success) completeness.exclude("webmcp_tools", "invalid_protocol_value"); const schema = declaration.success ? declaration.data : null; + const registrationFrame = topCallFrame(value.stackTrace); + if (!registrationFrame.ok) + completeness.exclude("webmcp_tools", "invalid_protocol_value"); return { tool_key: toolKey(frame.url, frameId, name), name, @@ -490,7 +493,7 @@ const normalizeTool = ( autosubmit: annotation("autosubmit"), }, registration_source: registrationSource( - recordValue(value.stackTrace), + registrationFrame.ok ? registrationFrame.value : undefined, new Set(input.allowed_origins), ), trust: "page-declared-untrusted", @@ -498,10 +501,9 @@ const normalizeTool = ( }; const registrationSource = ( - stack: UnknownRecord | undefined, + frame: UnknownRecord | undefined, allowedOrigins: ReadonlySet, ): WebMcpDiscovery["tools"]["items"][number]["registration_source"] => { - const frame = recordsValue(stack?.callFrames)[0]; const url = allowedSanitizedUrl(frame?.url, allowedOrigins); return frame === undefined || url === undefined ? null diff --git a/src/domain/browserObservationSchemas.ts b/src/domain/browserObservationSchemas.ts index 5be597f175..6c643ec8b0 100644 --- a/src/domain/browserObservationSchemas.ts +++ b/src/domain/browserObservationSchemas.ts @@ -167,12 +167,17 @@ const browserConsoleEventSchema = z.object({ .describe( "CDP Runtime.Timestamp in milliseconds since Unix epoch; null when the producer value is absent or invalid.", ), - argument_types: z.array(z.string()), + argument_types: z + .array(z.string()) + .nullable() + .describe( + "CDP argument types by argument position; null when the producer argument inventory is absent or not an array.", + ), url: z.string().nullable(), line: z.number().int().min(0).nullable(), column: z.number().int().min(0).nullable(), text_capture: z.object({ - status: z.enum(["not_approved", "included"]), + status: z.enum(["not_approved", "included", "unavailable"]), values: z.array( z.object({ argument_index: z.number().int().min(0), diff --git a/tests/boundary/browser/cdpConsoleArgumentInventory.test.ts b/tests/boundary/browser/cdpConsoleArgumentInventory.test.ts new file mode 100644 index 0000000000..234bf716c5 --- /dev/null +++ b/tests/boundary/browser/cdpConsoleArgumentInventory.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from "vitest"; + +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js"; +import { webPageInspectionSchema } from "../../../src/domain/browserObservationSchemas.js"; +import { discoverWebMcpToolsInputSchema } from "../../../src/domain/webMcpDiscovery.js"; +import type { JsonValue } from "../../../src/domain/jsonValue.js"; +import { + startFakeCdpBrowser, + type FakeOptions, +} from "../../fixtures/fakeCdpBrowser.js"; +import { trackBrowser } from "./cdpBrowserProvider.support.js"; + +type CdpCommand = Parameters>[0]; + +const sessionEvent = ( + command: CdpCommand, + method: string, + params: Record, +) => ({ + ...(command.sessionId === undefined ? {} : { sessionId: command.sessionId }), + method, + params, +}); + +const inspect = async ( + commandEvents: NonNullable, +) => { + const browser = await startFakeCdpBrowser({ commandEvents }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 1, + include_console_text: true, + }), + ); + if (!result.ok) throw result.error; + expect(() => webPageInspectionSchema.parse(result.value)).not.toThrow(); + return { browser, inspection: result.value }; +}; + +const inspectConsole = async ( + fields: Record, + stackTrace?: (origin: string) => unknown, +) => + inspect((command, origin) => + command.method === "Runtime.enable" + ? [ + sessionEvent(command, "Runtime.consoleAPICalled", { + type: "log", + timestamp: 1, + ...fields, + stackTrace: stackTrace?.(origin) ?? { + callFrames: [ + { url: `${origin}/app.js`, lineNumber: 0, columnNumber: 0 }, + ], + }, + }), + ] + : undefined, + ); + +const invalidConsoleValue = { + section: "console_events", + reason: "invalid_protocol_value", + count: 1, +}; + +describe("CdpBrowserProvider: console argument inventory", () => { + it.each([ + ["omitted", {}], + ["null", { args: null }], + ["non-array", { args: "invalid" }], + ])( + "keeps an %s argument inventory unknown rather than empty", + async (_label, fields) => { + const { inspection } = await inspectConsole(fields); + + expect(inspection.console.events).toHaveLength(1); + expect(inspection.console.events[0]?.argument_types).toBeNull(); + expect(inspection.console.events[0]?.text_capture).toEqual({ + status: "unavailable", + values: [], + retained_bytes: 0, + }); + expect(inspection.completeness.excluded).toContainEqual( + invalidConsoleValue, + ); + }, + ); + + it.each([ + ["a non-object element", null], + ["an element without a type", { value: "first" }], + ])( + "retains original argument positions around %s", + async (_label, malformed) => { + const { inspection } = await inspectConsole({ + args: [malformed, { type: "string", value: "second" }], + }); + + expect(inspection.console.events[0]?.argument_types).toEqual([ + "unknown", + "string", + ]); + expect(inspection.console.events[0]?.text_capture).toEqual({ + status: "included", + values: [{ argument_index: 1, type: "string", text: "second" }], + retained_bytes: Buffer.byteLength("second"), + }); + expect(inspection.completeness.excluded).toContainEqual( + invalidConsoleValue, + ); + }, + ); + + it.each([ + ["an empty", [], []], + ["a non-primitive", [{ type: "object", subtype: "array" }], ["object"]], + ])( + "reports %s argument inventory as observed", + async (_label, args, argumentTypes) => { + const { inspection } = await inspectConsole({ args }); + + expect(inspection.console.events[0]?.argument_types).toEqual( + argumentTypes, + ); + expect(inspection.console.events[0]?.text_capture).toEqual({ + status: "included", + values: [], + retained_bytes: 0, + }); + expect(inspection.completeness.excluded).not.toContainEqual( + expect.objectContaining({ + section: "console_events", + reason: "invalid_protocol_value", + }), + ); + }, + ); + + it.each([ + [ + "a malformed top frame", + (origin: string) => ({ + callFrames: [ + null, + { url: `${origin}/app.js`, lineNumber: 4, columnNumber: 2 }, + ], + }), + ], + ["a non-array frame inventory", () => ({ callFrames: "invalid" })], + ["a non-object stack", () => "invalid"], + ])( + "does not attribute a console call through %s", + async (_label, stackTrace) => { + const { inspection } = await inspectConsole({ args: [] }, stackTrace); + + expect(inspection.console.events).toEqual([]); + expect(inspection.completeness.excluded).toContainEqual( + invalidConsoleValue, + ); + }, + ); + + it("does not attribute a request initiator to a frame below a malformed top frame", async () => { + const { browser, inspection } = await inspect((command, origin) => + command.method === "Network.enable" + ? [ + sessionEvent(command, "Network.requestWillBeSent", { + requestId: "initiated-request", + type: "Fetch", + request: { url: `${origin}/data.json`, method: "GET" }, + initiator: { + type: "script", + stack: { + callFrames: [ + null, + { url: `${origin}/app.js`, lineNumber: 3, columnNumber: 5 }, + ], + }, + }, + timestamp: 1, + }), + ] + : undefined, + ); + + const request = inspection.network.requests.find( + (item) => item.request_id === "initiated-request", + ); + expect(request?.url).toBe(`${browser.allowedOrigin}/data.json`); + expect(request?.initiator).toEqual({ + type: "script", + url: null, + line: null, + column: null, + }); + expect(inspection.completeness.excluded).toContainEqual({ + section: "network_initiators", + reason: "invalid_protocol_value", + count: 1, + }); + }); +}); + +describe("CdpBrowserProvider: WebMCP registration stacks", () => { + const discover = async ( + stackTrace: (origin: string) => JsonValue | undefined, + ) => { + const browser = await startFakeCdpBrowser({ + webMcpTools: true, + webMcpStackTrace: stackTrace, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().discoverWebMcpTools( + discoverWebMcpToolsInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + }), + ); + if (!result.ok) throw result.error; + return { discovery: result.value }; + }; + const invalidToolValue = expect.objectContaining({ + section: "webmcp_tools", + reason: "invalid_protocol_value", + }); + + it.each<[string, (origin: string) => JsonValue]>([ + [ + "a malformed top frame", + (origin) => ({ + callFrames: [ + null, + { url: `${origin}/app.js`, lineNumber: 12, columnNumber: 4 }, + ], + }), + ], + ["a non-array frame inventory", () => ({ callFrames: "invalid" })], + ["a non-object stack", () => "invalid"], + ])( + "keeps the tool but reports %s as incomplete", + async (_label, stackTrace) => { + const { discovery } = await discover(stackTrace); + + expect(discovery.tools.items).toEqual([ + expect.objectContaining({ + name: "search_orders", + registration_source: null, + }), + ]); + expect(discovery.completeness.excluded).toContainEqual(invalidToolValue); + }, + ); + + it.each<[string, JsonValue | undefined]>([ + ["an omitted", undefined], + ["an empty", { callFrames: [] }], + ])("reports %s registration stack as having no source", async (_l, stack) => { + const { discovery } = await discover(() => stack); + + expect(discovery.tools.items).toEqual([ + expect.objectContaining({ + name: "search_orders", + registration_source: null, + }), + ]); + expect(discovery.completeness.excluded).not.toContainEqual( + invalidToolValue, + ); + }); +}); diff --git a/tests/fixtures/fakeCdpBrowserEvents.ts b/tests/fixtures/fakeCdpBrowserEvents.ts index 20d4ef0733..06c1d500c1 100644 --- a/tests/fixtures/fakeCdpBrowserEvents.ts +++ b/tests/fixtures/fakeCdpBrowserEvents.ts @@ -78,15 +78,18 @@ const emitMainWebMcpTools = ( ? options.webMcpConsequential : true, }, - stackTrace: { - callFrames: [ - { - url: `http://127.0.0.1:${String(port)}/app.js?token=tool-source-secret`, - lineNumber: 12, - columnNumber: 4, - }, - ], - }, + stackTrace: + options.webMcpStackTrace !== undefined + ? options.webMcpStackTrace(`http://127.0.0.1:${String(port)}`) + : { + callFrames: [ + { + url: `http://127.0.0.1:${String(port)}/app.js?token=tool-source-secret`, + lineNumber: 12, + columnNumber: 4, + }, + ], + }, }, { name: "private_tool", diff --git a/tests/fixtures/fakeCdpBrowserTypes.ts b/tests/fixtures/fakeCdpBrowserTypes.ts index 08472f451c..13593b0826 100644 --- a/tests/fixtures/fakeCdpBrowserTypes.ts +++ b/tests/fixtures/fakeCdpBrowserTypes.ts @@ -102,6 +102,8 @@ export interface FakeOptions { readonly invalidResponseBodyBase64?: boolean; readonly webMcpTools?: boolean; readonly webMcpConsequential?: JsonValue; + /** Replaces the primary tool's registration stack; returning `undefined` omits it. */ + readonly webMcpStackTrace?: (origin: string) => JsonValue | undefined; readonly webMcpSameUrlRegistrations?: "retain" | "remove-second"; readonly webMcpFrameCount?: number; readonly webMcpSchemaPropertyCount?: number;