fix(browser): preserve WebMCP optional fields and replay coverage gaps

This commit is contained in:
morluto
2026-10-24 03:02:06 +08:00
parent 209c42c346
commit b29aa6043f
4 changed files with 172 additions and 10 deletions
+6 -2
View File
@@ -25,7 +25,7 @@ authority are validated independently before selection and connection.
- `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 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`.
- `discover_webmcp_tools` / `rea discover-webmcp-tools` uses the experimental CDP WebMCP domain to return observed in-scope registrations and their 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. An omitted optional `inputSchema` is a valid parameterless registration, represented by null schema and digest. 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.
@@ -322,7 +322,11 @@ read as registration state only after domain availability is confirmed.
Discovery is attach-only: the command needs a reachable loopback CDP endpoint
and a target ID from `list_browser_targets`, and it never navigates, reloads,
or evaluates the page. Observe the page in the state you want to measure;
registered tools reflect the document as loaded.
registered tools reflect what the protocol delivered. If an in-scope subframe
already exists when discovery attaches, `webmcp_tools` remains attach-limited:
Chromium replay may omit its older registrations. Later live registrations
retain their frame ownership but do not establish recovery of those missing
descriptors. REA does not reload, evaluate, or invoke tools to fill that gap.
Accessibility names/descriptions, console primitive text, JSON body shapes, WebSocket shapes, script content, and storage key names are selected independently:
+17 -6
View File
@@ -77,6 +77,16 @@ export const discoverWebMcp = async (
resolvedTransientFrames: new Set<string>(),
};
ingestInitialFrameTree(frameTree, frameScope);
if (
[...frameUrls.values()].some(
(frame) => frame.parentFrameId !== null && frame.origin !== null,
)
) {
completeness.attachLimited("webmcp_tools");
limitations.push(
"WebMCP.enable replay does not establish coverage of pre-existing subframe registrations. Later live registrations are retained, but cannot recover descriptors omitted before attachment.",
);
}
const removeListener = context.connection.onEvent((event) => {
if (event.sessionId !== context.sessionId) return;
if (ingestFrameScopeEvent(event, frameScope)) return;
@@ -494,10 +504,12 @@ const normalizeTool = (
(annotations !== undefined &&
Object.hasOwn(annotations, "consequential") &&
typeof annotations.consequential !== "boolean");
const declaration = jsonValueSchema.safeParse(value.inputSchema);
if (!declaration.success)
const declaration = Object.hasOwn(value, "inputSchema")
? jsonValueSchema.safeParse(value.inputSchema)
: undefined;
if (declaration?.success === false)
completeness.exclude("webmcp_tools", "invalid_protocol_value");
const schema = declaration.success ? declaration.data : null;
const schema = declaration?.success === true ? declaration.data : null;
const registrationFrame = topCallFrame(value.stackTrace);
if (!registrationFrame.ok)
completeness.exclude("webmcp_tools", "invalid_protocol_value");
@@ -513,9 +525,8 @@ const normalizeTool = (
? "imperative"
: "declarative",
input_schema: schema,
input_schema_sha256: declaration.success
? digestCanonicalValue(schema)
: null,
input_schema_sha256:
declaration?.success === true ? digestCanonicalValue(schema) : null,
annotations: {
read_only: annotation("readOnly"),
untrusted_content: annotation("untrustedContent"),
@@ -0,0 +1,147 @@
import { expect, it } from "vitest";
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
import { discoverWebMcpToolsInputSchema } from "../../../src/domain/webMcpDiscovery.js";
import { digestCanonicalValue } from "../../../src/domain/canonicalDigest.js";
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
import { trackBrowser } from "./cdpBrowserProvider.support.js";
it.each([false, true])(
"accepts optional WebMCP input schema (declared=%s)",
async (declared) => {
const browser = await startFakeCdpBrowser({
commandEvents: ({ method, sessionId }) =>
method === "WebMCP.enable"
? [
{
method: "WebMCP.toolsAdded",
sessionId,
params: {
tools: [
{
name: "parameterless",
frameId: "frame-main",
...(declared ? { inputSchema: {} } : {}),
},
],
},
},
]
: undefined,
});
trackBrowser(browser);
const result = await new CdpBrowserProvider().discoverWebMcpTools(
discoverWebMcpToolsInputSchema.parse({
cdp_endpoint: browser.endpoint,
target_id: "allowed-page",
observation_ms: 0,
}),
);
if (!result.ok) throw result.error;
expect(result.value.tools.items[0]).toMatchObject({
name: "parameterless",
input_schema: declared ? {} : null,
input_schema_sha256: declared ? digestCanonicalValue({}) : null,
});
expect(result.value.completeness.unavailable_sections).not.toContain(
"webmcp_tools",
);
expect(result.value.completeness.excluded).not.toContainEqual(
expect.objectContaining({
section: "webmcp_tools",
reason: "invalid_protocol_value",
}),
);
},
);
it("retains a valid parameterless registration while rejecting malformed annotations", async () => {
const browser = await startFakeCdpBrowser({
commandEvents: ({ method, sessionId }) =>
method === "WebMCP.enable"
? [
{
method: "WebMCP.toolsAdded",
sessionId,
params: {
tools: [
{
name: "parameterless",
frameId: "frame-main",
annotations: { readOnly: "yes" },
},
],
},
},
]
: undefined,
});
trackBrowser(browser);
const result = await new CdpBrowserProvider().discoverWebMcpTools(
discoverWebMcpToolsInputSchema.parse({
cdp_endpoint: browser.endpoint,
target_id: "allowed-page",
observation_ms: 0,
}),
);
if (!result.ok) throw result.error;
expect(result.value.tools.items).toHaveLength(1);
expect(result.value.completeness.unavailable_sections).toContain(
"webmcp_tools",
);
});
it("retains replay and live subframe tools without claiming replay covered older subframe registrations", async () => {
const browser = await startFakeCdpBrowser({
commandResult: ({ method }, origin) =>
method === "Page.getFrameTree"
? {
frameTree: {
frame: { id: "frame-main", url: origin + "/app" },
childFrames: [
{
frame: {
id: "child",
parentId: "frame-main",
url: origin + "/child",
},
},
],
},
}
: undefined,
commandEvents: ({ method, sessionId }) =>
method === "WebMCP.enable"
? [
{
method: "WebMCP.toolsAdded",
sessionId,
params: { tools: [{ name: "replayed", frameId: "frame-main" }] },
},
{
method: "WebMCP.toolsAdded",
sessionId,
params: { tools: [{ name: "live-child", frameId: "child" }] },
},
]
: undefined,
});
trackBrowser(browser);
const result = await new CdpBrowserProvider().discoverWebMcpTools(
discoverWebMcpToolsInputSchema.parse({
cdp_endpoint: browser.endpoint,
target_id: "allowed-page",
observation_ms: 0,
}),
);
if (!result.ok) throw result.error;
expect(result.value.tools.items.map(({ name }) => name).sort()).toEqual([
"live-child",
"replayed",
]);
expect(result.value.completeness.attach_limited_sections).toContain(
"webmcp_tools",
);
expect(result.value.limitations).toContain(
expect.stringContaining("pre-existing subframe registrations"),
);
});
@@ -113,7 +113,7 @@ it.each(
result.value.completeness.attach_limited_sections.includes(
"webmcp_tools",
),
).toBe(!recover);
).toBe(!recover || phase === "event");
if (recover)
expect(result.value.tools.items[0]?.frame_url).toBe(
browser.allowedOrigin + "/child#current",
@@ -130,7 +130,7 @@ it.each(
}),
);
expect(compared.dimensions.webmcp.status).toBe(
recover ? "unchanged" : "unknown",
recover && phase === "initial" ? "unchanged" : "unknown",
);
},
);