@narumitw/pi-chrome-devtools 0.52.3 → 0.53.1
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/README.md +62 -9
- package/dist/chunks/{browser-settings-menu-VWCNG4HB.js → browser-settings-menu-REIU7C3Q.js} +83 -7
- package/dist/chunks/browser-settings-menu-REIU7C3Q.js.map +7 -0
- package/dist/chunks/chunk-GTTYS5GT.js +384 -0
- package/dist/chunks/chunk-GTTYS5GT.js.map +7 -0
- package/dist/chunks/{chunk-RH6KESL3.js → chunk-SRT3UUIP.js} +473 -574
- package/dist/chunks/chunk-SRT3UUIP.js.map +7 -0
- package/dist/chunks/chunk-YJVYJ2L2.js +469 -0
- package/dist/chunks/chunk-YJVYJ2L2.js.map +7 -0
- package/dist/chunks/{menu-PLD3S3EO.js → menu-H5HMVGHI.js} +72 -39
- package/dist/chunks/menu-H5HMVGHI.js.map +7 -0
- package/dist/chunks/tools-6P7EUX37.js +964 -0
- package/dist/chunks/tools-6P7EUX37.js.map +7 -0
- package/dist/index.ts +166 -199
- package/dist/index.ts.map +4 -4
- package/package.json +4 -3
- package/src/browser-manager.ts +408 -169
- package/src/browser-settings-menu.ts +100 -5
- package/src/cdp-client.ts +347 -90
- package/src/chrome-devtools.ts +58 -11
- package/src/lazy-tools.ts +37 -4
- package/src/menu.ts +80 -39
- package/src/render.ts +12 -1
- package/src/runtime.ts +92 -0
- package/src/settings.ts +47 -10
- package/src/tool-names.ts +16 -1
- package/src/tool-selector.ts +85 -44
- package/src/tools.ts +91 -15
- package/src/webmcp/discovery.ts +235 -0
- package/src/webmcp/policy.ts +409 -0
- package/src/webmcp/protocol.ts +426 -0
- package/src/webmcp/tools.ts +190 -0
- package/dist/chunks/browser-settings-menu-VWCNG4HB.js.map +0 -7
- package/dist/chunks/chunk-RH6KESL3.js.map +0 -7
- package/dist/chunks/menu-PLD3S3EO.js.map +0 -7
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
// @generated by scripts/build-runtime.mjs; do not edit.
|
|
2
|
+
// @ts-nocheck -- the generated entry uses a .ts extension for Pi's Jiti loader.
|
|
3
|
+
import {
|
|
4
|
+
CdpClient,
|
|
5
|
+
getPage,
|
|
6
|
+
resolvePage,
|
|
7
|
+
textResult
|
|
8
|
+
} from "./chunk-GTTYS5GT.js";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_TIMEOUT_MS,
|
|
11
|
+
beginWebMcpOperation,
|
|
12
|
+
currentWebMcpGeneration,
|
|
13
|
+
fetchDevToolsJson,
|
|
14
|
+
managedBrowserForOwner,
|
|
15
|
+
webMcpEnabled,
|
|
16
|
+
webMcpOperationIsCurrent,
|
|
17
|
+
webMcpSessionSignal
|
|
18
|
+
} from "./chunk-SRT3UUIP.js";
|
|
19
|
+
|
|
20
|
+
// src/webmcp/policy.ts
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { stripVTControlCharacters } from "node:util";
|
|
23
|
+
import {
|
|
24
|
+
DEFAULT_MAX_BYTES,
|
|
25
|
+
DEFAULT_MAX_LINES,
|
|
26
|
+
formatSize,
|
|
27
|
+
truncateHead
|
|
28
|
+
} from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import { Check } from "typebox/value";
|
|
30
|
+
var MAX_WEBMCP_TOOLS = 100;
|
|
31
|
+
var MAX_SCHEMA_BYTES = 64 * 1024;
|
|
32
|
+
var MAX_INPUT_BYTES = 64 * 1024;
|
|
33
|
+
var MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
34
|
+
var MAX_JSON_DEPTH = 32;
|
|
35
|
+
var MAX_JSON_NODES = 1e4;
|
|
36
|
+
var MAX_OUTPUT_NODES = 1e5;
|
|
37
|
+
var MAX_SCHEMA_VALIDATION_BRANCHES = 128;
|
|
38
|
+
function normalizeWebMcpTool(tool, context) {
|
|
39
|
+
const name = boundedString(tool.name, "tool name", 512, { nonEmpty: true });
|
|
40
|
+
const description = boundedString(tool.description, "tool description", 16 * 1024);
|
|
41
|
+
const title = tool.title === void 0 ? void 0 : boundedString(tool.title, "tool title", 1024);
|
|
42
|
+
const documentId = boundedString(context.documentId, "document loader ID", 512, {
|
|
43
|
+
nonEmpty: true
|
|
44
|
+
});
|
|
45
|
+
const frameId = boundedString(tool.frameId, "frame ID", 512, { nonEmpty: true });
|
|
46
|
+
const frameOrigin2 = boundedString(context.frameOrigin, "frame origin", 2048, {
|
|
47
|
+
nonEmpty: true
|
|
48
|
+
});
|
|
49
|
+
const pageId = boundedString(context.pageId, "page ID", 512, { nonEmpty: true });
|
|
50
|
+
const pageUrl = boundedString(context.pageUrl, "page URL", 8192);
|
|
51
|
+
const inputSchema = tool.inputSchema ?? { type: "object", additionalProperties: true };
|
|
52
|
+
rejectRegexBearingSchema(inputSchema);
|
|
53
|
+
const canonicalSchema = canonicalJsonObject(inputSchema, {
|
|
54
|
+
label: "WebMCP input schema",
|
|
55
|
+
maxBytes: MAX_SCHEMA_BYTES,
|
|
56
|
+
maxDepth: MAX_JSON_DEPTH,
|
|
57
|
+
maxNodes: MAX_JSON_NODES
|
|
58
|
+
});
|
|
59
|
+
rejectExpensiveValidationSchema(canonicalSchema);
|
|
60
|
+
const annotations = canonicalAnnotations(tool.annotations);
|
|
61
|
+
const schemaDigest = createHash("sha256").update(canonicalStringify({ inputSchema: canonicalSchema, annotations })).digest("hex");
|
|
62
|
+
return {
|
|
63
|
+
annotations,
|
|
64
|
+
description,
|
|
65
|
+
documentId,
|
|
66
|
+
frameId,
|
|
67
|
+
frameOrigin: frameOrigin2,
|
|
68
|
+
inputSchema: canonicalSchema,
|
|
69
|
+
name,
|
|
70
|
+
pageId,
|
|
71
|
+
pageUrl,
|
|
72
|
+
schemaDigest,
|
|
73
|
+
sessionGeneration: context.sessionGeneration,
|
|
74
|
+
...title === void 0 ? {} : { title }
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function webMcpIdentity(tool) {
|
|
78
|
+
return {
|
|
79
|
+
sessionGeneration: tool.sessionGeneration,
|
|
80
|
+
pageId: tool.pageId,
|
|
81
|
+
documentId: tool.documentId,
|
|
82
|
+
frameId: tool.frameId,
|
|
83
|
+
frameOrigin: tool.frameOrigin,
|
|
84
|
+
name: tool.name,
|
|
85
|
+
schemaDigest: tool.schemaDigest
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function requireMatchingWebMcpTool(tools, expected) {
|
|
89
|
+
const candidates = tools.filter(
|
|
90
|
+
(tool) => tool.frameId === expected.frameId && tool.name === expected.name
|
|
91
|
+
);
|
|
92
|
+
if (candidates.length === 0) throw new Error("The selected WebMCP tool is no longer available.");
|
|
93
|
+
if (candidates.length > 1) {
|
|
94
|
+
throw new Error("The selected WebMCP tool identity is ambiguous in its frame.");
|
|
95
|
+
}
|
|
96
|
+
const current = candidates[0];
|
|
97
|
+
if (!current) throw new Error("The selected WebMCP tool is no longer available.");
|
|
98
|
+
const mismatches = [
|
|
99
|
+
["session generation", current.sessionGeneration, expected.sessionGeneration],
|
|
100
|
+
["page", current.pageId, expected.pageId],
|
|
101
|
+
["document", current.documentId, expected.documentId],
|
|
102
|
+
["frame", current.frameId, expected.frameId],
|
|
103
|
+
["frame origin", current.frameOrigin, expected.frameOrigin],
|
|
104
|
+
["schema or annotations", current.schemaDigest, expected.schemaDigest]
|
|
105
|
+
].flatMap(([label, actual, wanted]) => actual === wanted ? [] : [label]);
|
|
106
|
+
if (mismatches.length > 0) {
|
|
107
|
+
throw new Error(`The selected WebMCP tool became stale: ${mismatches.join(", ")} changed.`);
|
|
108
|
+
}
|
|
109
|
+
return current;
|
|
110
|
+
}
|
|
111
|
+
function normalizeWebMcpInput(input) {
|
|
112
|
+
return canonicalJsonObject(input, {
|
|
113
|
+
label: "WebMCP tool input",
|
|
114
|
+
maxBytes: MAX_INPUT_BYTES,
|
|
115
|
+
maxDepth: MAX_JSON_DEPTH,
|
|
116
|
+
maxNodes: MAX_JSON_NODES
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function validateWebMcpInput(schema, input) {
|
|
120
|
+
const normalized = normalizeWebMcpInput(input);
|
|
121
|
+
let valid;
|
|
122
|
+
try {
|
|
123
|
+
valid = Check(schema, normalized);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`The page provided a WebMCP input schema that cannot be validated: ${error instanceof Error ? error.message : String(error)}`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (!valid) throw new Error("WebMCP tool input does not match the current page-provided schema.");
|
|
130
|
+
return normalized;
|
|
131
|
+
}
|
|
132
|
+
function normalizeWebMcpOutput(output) {
|
|
133
|
+
return canonicalJsonValue(output, {
|
|
134
|
+
label: "WebMCP tool output",
|
|
135
|
+
maxBytes: MAX_OUTPUT_BYTES,
|
|
136
|
+
maxDepth: MAX_JSON_DEPTH * 2,
|
|
137
|
+
maxNodes: MAX_OUTPUT_NODES
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function boundedWebMcpDiscovery(page, tools) {
|
|
141
|
+
const included = [];
|
|
142
|
+
for (const tool of tools) {
|
|
143
|
+
const candidate = [...included, tool];
|
|
144
|
+
const publication2 = boundedWebMcpJson({
|
|
145
|
+
page,
|
|
146
|
+
tools: candidate,
|
|
147
|
+
totalToolCount: tools.length,
|
|
148
|
+
truncated: candidate.length < tools.length
|
|
149
|
+
});
|
|
150
|
+
if (publication2.truncated) break;
|
|
151
|
+
included.push(tool);
|
|
152
|
+
}
|
|
153
|
+
const truncated = included.length < tools.length;
|
|
154
|
+
const publication = boundedWebMcpJson({
|
|
155
|
+
page,
|
|
156
|
+
tools: included,
|
|
157
|
+
totalToolCount: tools.length,
|
|
158
|
+
truncated
|
|
159
|
+
});
|
|
160
|
+
if (publication.truncated) {
|
|
161
|
+
throw new Error("WebMCP discovery metadata cannot fit within Pi's output limit.");
|
|
162
|
+
}
|
|
163
|
+
return { ...publication, included, truncated };
|
|
164
|
+
}
|
|
165
|
+
function boundedWebMcpJson(value) {
|
|
166
|
+
const serialized = sanitizeWebMcpDisplay(JSON.stringify(value, null, 2), Number.MAX_SAFE_INTEGER);
|
|
167
|
+
const result = truncateHead(serialized, {
|
|
168
|
+
maxBytes: Math.max(1, DEFAULT_MAX_BYTES - 256),
|
|
169
|
+
maxLines: Math.max(1, DEFAULT_MAX_LINES - 2)
|
|
170
|
+
});
|
|
171
|
+
if (!result.truncated) return { text: result.content, truncated: false };
|
|
172
|
+
const notice = `
|
|
173
|
+
[WebMCP output truncated: ${result.outputLines}/${result.totalLines} lines, ${formatSize(result.outputBytes)}/${formatSize(result.totalBytes)}.]`;
|
|
174
|
+
return { text: `${result.content}${notice}`, truncated: true };
|
|
175
|
+
}
|
|
176
|
+
function webMcpConfirmationMessage(tool, input, managedProfile) {
|
|
177
|
+
const inputSummary = sanitizeWebMcpDisplay(JSON.stringify(input, null, 2), 2e3);
|
|
178
|
+
return [
|
|
179
|
+
managedProfile ? "This call uses the isolated browser profile managed by Pi." : "Warning: this call uses an attached browser profile that may contain everyday authenticated sessions and sensitive state.",
|
|
180
|
+
`Page: ${sanitizeWebMcpDisplay(tool.pageUrl, 4096)}`,
|
|
181
|
+
`Frame origin: ${sanitizeWebMcpDisplay(tool.frameOrigin, 2048)}`,
|
|
182
|
+
`Tool: ${sanitizeWebMcpDisplay(tool.name, 512)}`,
|
|
183
|
+
"Page annotations are untrusted and do not bypass this confirmation.",
|
|
184
|
+
"Input:",
|
|
185
|
+
inputSummary
|
|
186
|
+
].join("\n");
|
|
187
|
+
}
|
|
188
|
+
function sanitizeWebMcpDisplay(value, maxCharacters = 5e4) {
|
|
189
|
+
const withoutBidi = stripVTControlCharacters(value).replace(
|
|
190
|
+
/[\u202a-\u202e\u2066-\u2069]/gu,
|
|
191
|
+
"\uFFFD"
|
|
192
|
+
);
|
|
193
|
+
const sanitized = Array.from(withoutBidi, (character) => {
|
|
194
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
195
|
+
const unsafeControl = codePoint >= 0 && codePoint <= 8 || codePoint >= 11 && codePoint <= 31 || codePoint >= 127 && codePoint <= 159;
|
|
196
|
+
return unsafeControl ? "\uFFFD" : character;
|
|
197
|
+
}).join("");
|
|
198
|
+
if (sanitized.length <= maxCharacters) return sanitized;
|
|
199
|
+
return `${sanitized.slice(0, Math.max(0, maxCharacters - 1))}\u2026`;
|
|
200
|
+
}
|
|
201
|
+
function webMcpErrorMessage(error) {
|
|
202
|
+
return sanitizeWebMcpDisplay(error instanceof Error ? error.message : String(error), 8192);
|
|
203
|
+
}
|
|
204
|
+
function canonicalAnnotations(value) {
|
|
205
|
+
if (!value) return {};
|
|
206
|
+
return Object.fromEntries(
|
|
207
|
+
Object.entries(value).filter((entry) => typeof entry[1] === "boolean").sort(([left], [right]) => left.localeCompare(right))
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
function rejectRegexBearingSchema(schema) {
|
|
211
|
+
if (Array.isArray(schema)) {
|
|
212
|
+
for (const child of schema) rejectRegexBearingSchema(child);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (!isRecord(schema)) return;
|
|
216
|
+
if (schema.pattern !== void 0 || schema.patternProperties !== void 0) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
"WebMCP input schemas with pattern or patternProperties are unsupported because page-controlled regular expressions cannot be evaluated safely."
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
for (const keyword of [
|
|
222
|
+
"additionalItems",
|
|
223
|
+
"additionalProperties",
|
|
224
|
+
"contains",
|
|
225
|
+
"else",
|
|
226
|
+
"if",
|
|
227
|
+
"items",
|
|
228
|
+
"not",
|
|
229
|
+
"propertyNames",
|
|
230
|
+
"then",
|
|
231
|
+
"unevaluatedItems",
|
|
232
|
+
"unevaluatedProperties"
|
|
233
|
+
]) {
|
|
234
|
+
rejectRegexBearingSchema(schema[keyword]);
|
|
235
|
+
}
|
|
236
|
+
for (const keyword of ["allOf", "anyOf", "oneOf", "prefixItems"]) {
|
|
237
|
+
if (Array.isArray(schema[keyword])) {
|
|
238
|
+
for (const child of schema[keyword]) rejectRegexBearingSchema(child);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
for (const keyword of [
|
|
242
|
+
"$defs",
|
|
243
|
+
"definitions",
|
|
244
|
+
"dependencies",
|
|
245
|
+
"dependentSchemas",
|
|
246
|
+
"properties"
|
|
247
|
+
]) {
|
|
248
|
+
if (!isRecord(schema[keyword])) continue;
|
|
249
|
+
for (const child of Object.values(schema[keyword])) rejectRegexBearingSchema(child);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function rejectExpensiveValidationSchema(schema) {
|
|
253
|
+
let branches = 0;
|
|
254
|
+
const countBranches = (count) => {
|
|
255
|
+
branches += count;
|
|
256
|
+
if (branches > MAX_SCHEMA_VALIDATION_BRANCHES) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`WebMCP input schema exceeds the validation branch limit of ${MAX_SCHEMA_VALIDATION_BRANCHES}.`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
const visit = (candidate) => {
|
|
263
|
+
if (Array.isArray(candidate)) {
|
|
264
|
+
for (const child of candidate) visit(child);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (!isRecord(candidate)) return;
|
|
268
|
+
for (const keyword of ["allOf", "anyOf", "oneOf"]) {
|
|
269
|
+
const alternatives = candidate[keyword];
|
|
270
|
+
if (Array.isArray(alternatives)) countBranches(alternatives.length);
|
|
271
|
+
}
|
|
272
|
+
for (const keyword of ["dependencies", "dependentSchemas"]) {
|
|
273
|
+
const dependencies = candidate[keyword];
|
|
274
|
+
if (!isRecord(dependencies)) continue;
|
|
275
|
+
countBranches(Object.values(dependencies).filter(isRecord).length);
|
|
276
|
+
}
|
|
277
|
+
for (const child of Object.values(candidate)) visit(child);
|
|
278
|
+
};
|
|
279
|
+
visit(schema);
|
|
280
|
+
}
|
|
281
|
+
function canonicalJsonObject(value, limits) {
|
|
282
|
+
const canonical = canonicalJsonValue(value, limits);
|
|
283
|
+
if (!isRecord(canonical)) throw new Error(`${limits.label} must be a JSON object.`);
|
|
284
|
+
return canonical;
|
|
285
|
+
}
|
|
286
|
+
function canonicalJsonValue(value, limits) {
|
|
287
|
+
let nodes = 0;
|
|
288
|
+
const visit = (candidate, depth) => {
|
|
289
|
+
nodes += 1;
|
|
290
|
+
if (nodes > limits.maxNodes) {
|
|
291
|
+
throw new Error(`${limits.label} exceeds the ${limits.maxNodes} value limit.`);
|
|
292
|
+
}
|
|
293
|
+
if (depth > limits.maxDepth) {
|
|
294
|
+
throw new Error(`${limits.label} exceeds the depth limit of ${limits.maxDepth}.`);
|
|
295
|
+
}
|
|
296
|
+
if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean") {
|
|
297
|
+
return candidate;
|
|
298
|
+
}
|
|
299
|
+
if (typeof candidate === "number") {
|
|
300
|
+
if (!Number.isFinite(candidate))
|
|
301
|
+
throw new Error(`${limits.label} contains a non-finite number.`);
|
|
302
|
+
return candidate;
|
|
303
|
+
}
|
|
304
|
+
if (Array.isArray(candidate)) return candidate.map((entry) => visit(entry, depth + 1));
|
|
305
|
+
if (isRecord(candidate)) {
|
|
306
|
+
return Object.fromEntries(
|
|
307
|
+
Object.keys(candidate).sort().map((key) => [key, visit(candidate[key], depth + 1)])
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
throw new Error(`${limits.label} contains a value that is not JSON-compatible.`);
|
|
311
|
+
};
|
|
312
|
+
const canonical = visit(value, 0);
|
|
313
|
+
const serialized = canonicalStringify(canonical);
|
|
314
|
+
const bytes = Buffer.byteLength(serialized, "utf8");
|
|
315
|
+
if (bytes > limits.maxBytes) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`${limits.label} exceeds ${formatSize(limits.maxBytes)} (${formatSize(bytes)}).`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
return canonical;
|
|
321
|
+
}
|
|
322
|
+
function canonicalStringify(value) {
|
|
323
|
+
const serialized = JSON.stringify(value);
|
|
324
|
+
if (serialized === void 0) throw new Error("Value is not JSON-compatible.");
|
|
325
|
+
return serialized;
|
|
326
|
+
}
|
|
327
|
+
function boundedString(value, label, maxBytes, options = {}) {
|
|
328
|
+
if (options.nonEmpty && value.length === 0) throw new Error(`WebMCP ${label} must not be empty.`);
|
|
329
|
+
const bytes = Buffer.byteLength(value, "utf8");
|
|
330
|
+
if (bytes > maxBytes) throw new Error(`WebMCP ${label} exceeds ${formatSize(maxBytes)}.`);
|
|
331
|
+
return value;
|
|
332
|
+
}
|
|
333
|
+
function isRecord(value) {
|
|
334
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// src/webmcp/protocol.ts
|
|
338
|
+
var REQUIRED_COMMANDS = ["enable", "disable", "invokeTool", "cancelInvocation"];
|
|
339
|
+
var REQUIRED_EVENTS = ["toolsAdded", "toolsRemoved", "toolInvoked", "toolResponded"];
|
|
340
|
+
var IDENTITY_WATCH_TIMEOUT_MS = DEFAULT_TIMEOUT_MS * 4;
|
|
341
|
+
async function requireWebMcpDomain(signal, owner) {
|
|
342
|
+
signal.throwIfAborted();
|
|
343
|
+
const protocol = await fetchDevToolsJson(
|
|
344
|
+
"/json/protocol",
|
|
345
|
+
{ signal },
|
|
346
|
+
owner
|
|
347
|
+
);
|
|
348
|
+
signal.throwIfAborted();
|
|
349
|
+
const domain = findDomain(protocol.domains, "WebMCP");
|
|
350
|
+
if (!domain || !hasNamedEntries(domain.commands, REQUIRED_COMMANDS) || !hasNamedEntries(domain.events, REQUIRED_EVENTS)) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
"This browser does not expose the experimental Chrome DevTools WebMCP domain. Use a compatible Chrome build and enable the website's required Origin Trial or WebMCP testing feature."
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async function connectWebMcpPage(page, signal) {
|
|
357
|
+
if (!page.webSocketDebuggerUrl) throw new Error(`Page has no webSocketDebuggerUrl: ${page.id}`);
|
|
358
|
+
return CdpClient.connect(page.webSocketDebuggerUrl, {
|
|
359
|
+
signal: withDeadline(signal)
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
async function cancelWebMcpInvocation(page, invocationId) {
|
|
363
|
+
const cleanupSignal = AbortSignal.timeout(1e3);
|
|
364
|
+
const client = await connectWebMcpPage(page, cleanupSignal);
|
|
365
|
+
try {
|
|
366
|
+
await client.send("WebMCP.cancelInvocation", { invocationId }, { signal: cleanupSignal });
|
|
367
|
+
} finally {
|
|
368
|
+
client.close();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
async function enableWebMcpIdentityTracking(client, signal) {
|
|
372
|
+
await client.send("Page.enable", {}, { signal, timeoutMs: DEFAULT_TIMEOUT_MS });
|
|
373
|
+
}
|
|
374
|
+
async function enableWebMcp(client, signal) {
|
|
375
|
+
const eventController = new AbortController();
|
|
376
|
+
const eventSignal = AbortSignal.any([signal, eventController.signal]);
|
|
377
|
+
const toolsAdded = client.waitForEvent(
|
|
378
|
+
"WebMCP.toolsAdded",
|
|
379
|
+
(value) => {
|
|
380
|
+
const parsed = parseToolsAdded(value);
|
|
381
|
+
if (!parsed) throw new Error("Chrome sent a malformed WebMCP.toolsAdded event");
|
|
382
|
+
return true;
|
|
383
|
+
},
|
|
384
|
+
{ signal: eventSignal, timeoutMs: DEFAULT_TIMEOUT_MS }
|
|
385
|
+
).then(
|
|
386
|
+
(value) => ({ ok: true, value }),
|
|
387
|
+
(error) => ({ error, ok: false })
|
|
388
|
+
);
|
|
389
|
+
try {
|
|
390
|
+
await client.send("WebMCP.enable", {}, { signal, timeoutMs: DEFAULT_TIMEOUT_MS });
|
|
391
|
+
} catch (error) {
|
|
392
|
+
eventController.abort();
|
|
393
|
+
await toolsAdded;
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
const result = await toolsAdded;
|
|
398
|
+
if (!result.ok) throw result.error;
|
|
399
|
+
const parsed = parseToolsAdded(result.value);
|
|
400
|
+
if (!parsed) throw new Error("Chrome sent a malformed WebMCP.toolsAdded event");
|
|
401
|
+
return parsed.tools;
|
|
402
|
+
} finally {
|
|
403
|
+
eventController.abort();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async function disableWebMcp(client) {
|
|
407
|
+
try {
|
|
408
|
+
await client.send("WebMCP.disable", {}, { timeoutMs: 1e3 });
|
|
409
|
+
} catch {
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
async function readWebMcpFrames(client, signal) {
|
|
413
|
+
const result = await client.send(
|
|
414
|
+
"Page.getFrameTree",
|
|
415
|
+
{},
|
|
416
|
+
{
|
|
417
|
+
signal,
|
|
418
|
+
timeoutMs: DEFAULT_TIMEOUT_MS
|
|
419
|
+
}
|
|
420
|
+
);
|
|
421
|
+
const frames = parseFrameTree(result);
|
|
422
|
+
if (!frames) throw new Error("Chrome sent a malformed Page.getFrameTree response");
|
|
423
|
+
return frames;
|
|
424
|
+
}
|
|
425
|
+
function watchWebMcpIdentity(client, expected, signal) {
|
|
426
|
+
const stopController = new AbortController();
|
|
427
|
+
const staleController = new AbortController();
|
|
428
|
+
const watchSignal = AbortSignal.any([signal, stopController.signal]);
|
|
429
|
+
const options = { signal: watchSignal, timeoutMs: IDENTITY_WATCH_TIMEOUT_MS };
|
|
430
|
+
const settleWatch = (create) => {
|
|
431
|
+
let watch;
|
|
432
|
+
try {
|
|
433
|
+
watch = create();
|
|
434
|
+
} catch (error) {
|
|
435
|
+
watch = Promise.reject(error);
|
|
436
|
+
}
|
|
437
|
+
return watch.then(
|
|
438
|
+
() => {
|
|
439
|
+
if (staleController.signal.aborted) return;
|
|
440
|
+
staleController.abort(
|
|
441
|
+
new DOMException(
|
|
442
|
+
"The selected WebMCP document or tool changed before Chrome completed the invocation boundary.",
|
|
443
|
+
"AbortError"
|
|
444
|
+
)
|
|
445
|
+
);
|
|
446
|
+
},
|
|
447
|
+
(error) => {
|
|
448
|
+
if (stopController.signal.aborted || signal.aborted || staleController.signal.aborted) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
staleController.abort(error);
|
|
452
|
+
}
|
|
453
|
+
);
|
|
454
|
+
};
|
|
455
|
+
const settledWatches = [
|
|
456
|
+
settleWatch(
|
|
457
|
+
() => client.waitForEvent(
|
|
458
|
+
"Page.frameNavigated",
|
|
459
|
+
(value) => frameNavigationChangesIdentity(value, expected.frameId, expected.documentId),
|
|
460
|
+
options
|
|
461
|
+
)
|
|
462
|
+
),
|
|
463
|
+
settleWatch(
|
|
464
|
+
() => client.waitForEvent(
|
|
465
|
+
"WebMCP.toolsAdded",
|
|
466
|
+
(value) => toolChangeMatchesIdentity(value, expected),
|
|
467
|
+
options
|
|
468
|
+
)
|
|
469
|
+
),
|
|
470
|
+
settleWatch(
|
|
471
|
+
() => client.waitForEvent(
|
|
472
|
+
"WebMCP.toolsRemoved",
|
|
473
|
+
(value) => toolChangeMatchesIdentity(value, expected),
|
|
474
|
+
options
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
];
|
|
478
|
+
return {
|
|
479
|
+
signal: AbortSignal.any([signal, staleController.signal]),
|
|
480
|
+
async dispose() {
|
|
481
|
+
stopController.abort();
|
|
482
|
+
await Promise.allSettled(settledWatches);
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
async function invokeWebMcpTool(client, request, signal, fallbackCancel) {
|
|
487
|
+
signal.throwIfAborted();
|
|
488
|
+
let invocationId;
|
|
489
|
+
let cancellation;
|
|
490
|
+
const cancel = () => {
|
|
491
|
+
if (!invocationId || cancellation) return;
|
|
492
|
+
const invocationToCancel = invocationId;
|
|
493
|
+
const recoverCancellation = () => fallbackCancel ? fallbackCancel(invocationToCancel).catch(() => void 0) : Promise.resolve();
|
|
494
|
+
try {
|
|
495
|
+
cancellation = client.send("WebMCP.cancelInvocation", { invocationId }, { timeoutMs: 1e3 }).catch(recoverCancellation);
|
|
496
|
+
} catch {
|
|
497
|
+
cancellation = recoverCancellation();
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
const onAbort = () => cancel();
|
|
501
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
502
|
+
try {
|
|
503
|
+
let response;
|
|
504
|
+
try {
|
|
505
|
+
response = await client.send(
|
|
506
|
+
"WebMCP.invokeTool",
|
|
507
|
+
{ frameId: request.frameId, toolName: request.toolName, input: request.input },
|
|
508
|
+
{ timeoutMs: DEFAULT_TIMEOUT_MS }
|
|
509
|
+
);
|
|
510
|
+
} catch (error) {
|
|
511
|
+
if (signal.aborted) throw signal.reason;
|
|
512
|
+
throw error;
|
|
513
|
+
}
|
|
514
|
+
invocationId = parseInvocationId(response);
|
|
515
|
+
if (!invocationId) throw new Error("Chrome sent a malformed WebMCP.invokeTool response");
|
|
516
|
+
if (signal.aborted) cancel();
|
|
517
|
+
let event;
|
|
518
|
+
try {
|
|
519
|
+
event = await client.waitForEvent(
|
|
520
|
+
"WebMCP.toolResponded",
|
|
521
|
+
(value) => {
|
|
522
|
+
const parsed = parseToolResponded(value);
|
|
523
|
+
if (!parsed) throw new Error("Chrome sent a malformed WebMCP.toolResponded event");
|
|
524
|
+
return parsed.invocationId === invocationId;
|
|
525
|
+
},
|
|
526
|
+
{ timeoutMs: DEFAULT_TIMEOUT_MS }
|
|
527
|
+
);
|
|
528
|
+
} catch (error) {
|
|
529
|
+
cancel();
|
|
530
|
+
await cancellation;
|
|
531
|
+
if (signal.aborted) throw signal.reason;
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
534
|
+
if (signal.aborted) throw signal.reason;
|
|
535
|
+
return event;
|
|
536
|
+
} finally {
|
|
537
|
+
signal.removeEventListener("abort", onAbort);
|
|
538
|
+
await cancellation;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
function findDomain(value, name) {
|
|
542
|
+
if (!Array.isArray(value)) return void 0;
|
|
543
|
+
return value.find(
|
|
544
|
+
(candidate) => isRecord2(candidate) && candidate.domain === name
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
function hasNamedEntries(value, required) {
|
|
548
|
+
if (!Array.isArray(value)) return false;
|
|
549
|
+
const names = new Set(
|
|
550
|
+
value.flatMap(
|
|
551
|
+
(candidate) => isRecord2(candidate) && typeof candidate.name === "string" ? [candidate.name] : []
|
|
552
|
+
)
|
|
553
|
+
);
|
|
554
|
+
return required.every((name) => names.has(name));
|
|
555
|
+
}
|
|
556
|
+
function parseToolsAdded(value) {
|
|
557
|
+
if (!isRecord2(value) || !Array.isArray(value.tools)) return void 0;
|
|
558
|
+
const tools = [];
|
|
559
|
+
for (const candidate of value.tools) {
|
|
560
|
+
const tool = parseTool(candidate);
|
|
561
|
+
if (!tool) return void 0;
|
|
562
|
+
tools.push(tool);
|
|
563
|
+
}
|
|
564
|
+
return { tools };
|
|
565
|
+
}
|
|
566
|
+
function parseTool(value) {
|
|
567
|
+
if (!isRecord2(value) || typeof value.name !== "string" || typeof value.description !== "string" || typeof value.frameId !== "string" || value.inputSchema !== void 0 && !isRecord2(value.inputSchema) || value.title !== void 0 && typeof value.title !== "string" || value.backendNodeId !== void 0 && typeof value.backendNodeId !== "number") {
|
|
568
|
+
return void 0;
|
|
569
|
+
}
|
|
570
|
+
const annotations = parseAnnotations(value.annotations);
|
|
571
|
+
if (value.annotations !== void 0 && !annotations) return void 0;
|
|
572
|
+
return {
|
|
573
|
+
name: value.name,
|
|
574
|
+
description: value.description,
|
|
575
|
+
frameId: value.frameId,
|
|
576
|
+
...value.title === void 0 ? {} : { title: value.title },
|
|
577
|
+
...value.inputSchema === void 0 ? {} : { inputSchema: value.inputSchema },
|
|
578
|
+
...annotations ? { annotations } : {},
|
|
579
|
+
...value.backendNodeId === void 0 ? {} : { backendNodeId: value.backendNodeId }
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
function parseAnnotations(value) {
|
|
583
|
+
if (value === void 0) return void 0;
|
|
584
|
+
if (!isRecord2(value)) return void 0;
|
|
585
|
+
const annotations = {};
|
|
586
|
+
for (const key of ["autosubmit", "consequential", "readOnly", "untrustedContent"]) {
|
|
587
|
+
if (value[key] === void 0) continue;
|
|
588
|
+
if (typeof value[key] !== "boolean") return void 0;
|
|
589
|
+
annotations[key] = value[key];
|
|
590
|
+
}
|
|
591
|
+
return annotations;
|
|
592
|
+
}
|
|
593
|
+
function parseFrameTree(value) {
|
|
594
|
+
if (!isRecord2(value) || !isRecord2(value.frameTree)) return void 0;
|
|
595
|
+
const frames = [];
|
|
596
|
+
const visit = (tree) => {
|
|
597
|
+
if (!isRecord2(tree) || !isRecord2(tree.frame)) return false;
|
|
598
|
+
const frame = tree.frame;
|
|
599
|
+
if (typeof frame.id !== "string" || typeof frame.loaderId !== "string" || typeof frame.url !== "string" || frame.securityOrigin !== void 0 && typeof frame.securityOrigin !== "string") {
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
frames.push({
|
|
603
|
+
id: frame.id,
|
|
604
|
+
loaderId: frame.loaderId,
|
|
605
|
+
url: frame.url,
|
|
606
|
+
...frame.securityOrigin === void 0 ? {} : { securityOrigin: frame.securityOrigin }
|
|
607
|
+
});
|
|
608
|
+
if (tree.childFrames === void 0) return true;
|
|
609
|
+
return Array.isArray(tree.childFrames) && tree.childFrames.every(visit);
|
|
610
|
+
};
|
|
611
|
+
return visit(value.frameTree) ? frames : void 0;
|
|
612
|
+
}
|
|
613
|
+
function frameNavigationChangesIdentity(value, frameId, documentId) {
|
|
614
|
+
if (!isRecord2(value) || !isRecord2(value.frame)) {
|
|
615
|
+
throw new Error("Chrome sent a malformed Page.frameNavigated event");
|
|
616
|
+
}
|
|
617
|
+
if (typeof value.frame.id !== "string" || typeof value.frame.loaderId !== "string") {
|
|
618
|
+
throw new Error("Chrome sent a malformed Page.frameNavigated event");
|
|
619
|
+
}
|
|
620
|
+
return value.frame.id === frameId && value.frame.loaderId !== documentId;
|
|
621
|
+
}
|
|
622
|
+
function toolChangeMatchesIdentity(value, expected) {
|
|
623
|
+
if (!isRecord2(value) || !Array.isArray(value.tools)) {
|
|
624
|
+
throw new Error("Chrome sent a malformed WebMCP tool-change event");
|
|
625
|
+
}
|
|
626
|
+
return value.tools.some((tool) => {
|
|
627
|
+
if (!isRecord2(tool) || typeof tool.frameId !== "string" || typeof tool.name !== "string") {
|
|
628
|
+
throw new Error("Chrome sent a malformed WebMCP tool-change event");
|
|
629
|
+
}
|
|
630
|
+
return tool.frameId === expected.frameId && tool.name === expected.toolName;
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
function parseInvocationId(value) {
|
|
634
|
+
return isRecord2(value) && typeof value.invocationId === "string" ? value.invocationId : void 0;
|
|
635
|
+
}
|
|
636
|
+
function parseToolResponded(value) {
|
|
637
|
+
if (!isRecord2(value) || typeof value.invocationId !== "string" || !["Canceled", "Completed", "Error"].includes(value.status) || value.errorText !== void 0 && typeof value.errorText !== "string") {
|
|
638
|
+
return void 0;
|
|
639
|
+
}
|
|
640
|
+
return {
|
|
641
|
+
invocationId: value.invocationId,
|
|
642
|
+
status: value.status,
|
|
643
|
+
...value.output === void 0 ? {} : { output: value.output },
|
|
644
|
+
...value.errorText === void 0 ? {} : { errorText: value.errorText },
|
|
645
|
+
...value.exception === void 0 ? {} : { exception: value.exception }
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
function withDeadline(signal) {
|
|
649
|
+
return AbortSignal.any([signal, AbortSignal.timeout(DEFAULT_TIMEOUT_MS)]);
|
|
650
|
+
}
|
|
651
|
+
function isRecord2(value) {
|
|
652
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// src/webmcp/discovery.ts
|
|
656
|
+
async function discoverWebMcpTools(page, operation) {
|
|
657
|
+
assertOperationCurrent(operation);
|
|
658
|
+
await requireWebMcpDomain(operation.signal, operation.owner);
|
|
659
|
+
assertOperationCurrent(operation);
|
|
660
|
+
const client = await connectWebMcpPage(page, operation.signal);
|
|
661
|
+
try {
|
|
662
|
+
const tools = await readInventory(client, page, operation);
|
|
663
|
+
await requireStablePage(client, page, tools, operation);
|
|
664
|
+
return tools;
|
|
665
|
+
} finally {
|
|
666
|
+
await disableWebMcp(client);
|
|
667
|
+
client.close();
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
async function invokeDiscoveredWebMcpTool(page, expected, input, operation) {
|
|
671
|
+
assertOperationCurrent(operation);
|
|
672
|
+
await requireWebMcpDomain(operation.signal, operation.owner);
|
|
673
|
+
assertOperationCurrent(operation);
|
|
674
|
+
const client = await connectWebMcpPage(page, operation.signal);
|
|
675
|
+
try {
|
|
676
|
+
await enableWebMcpIdentityTracking(client, operation.signal);
|
|
677
|
+
assertOperationCurrent(operation);
|
|
678
|
+
const tools = await readInventory(client, page, operation);
|
|
679
|
+
const tool = requireMatchingWebMcpTool(tools, expected);
|
|
680
|
+
const identityWatch = watchWebMcpIdentity(
|
|
681
|
+
client,
|
|
682
|
+
{ documentId: tool.documentId, frameId: tool.frameId, toolName: tool.name },
|
|
683
|
+
operation.signal
|
|
684
|
+
);
|
|
685
|
+
const guardedOperation = { ...operation, signal: identityWatch.signal };
|
|
686
|
+
try {
|
|
687
|
+
await requireStablePage(client, page, [tool], guardedOperation);
|
|
688
|
+
assertOperationCurrent(guardedOperation);
|
|
689
|
+
const response = await invokeWebMcpTool(
|
|
690
|
+
client,
|
|
691
|
+
{ frameId: tool.frameId, toolName: tool.name, input },
|
|
692
|
+
guardedOperation.signal,
|
|
693
|
+
(invocationId) => cancelWebMcpInvocation(page, invocationId)
|
|
694
|
+
);
|
|
695
|
+
assertOperationCurrent(guardedOperation);
|
|
696
|
+
if (response.status === "Canceled") {
|
|
697
|
+
throw new DOMException("Chrome canceled the WebMCP tool invocation.", "AbortError");
|
|
698
|
+
}
|
|
699
|
+
if (response.status === "Error") {
|
|
700
|
+
throw new Error(
|
|
701
|
+
`WebMCP tool failed: ${webMcpErrorMessage(response.errorText ?? remoteExceptionText(response.exception))}`
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
return {
|
|
705
|
+
invocationId: response.invocationId,
|
|
706
|
+
output: normalizeWebMcpOutput(response.output ?? null),
|
|
707
|
+
tool
|
|
708
|
+
};
|
|
709
|
+
} finally {
|
|
710
|
+
await identityWatch.dispose();
|
|
711
|
+
}
|
|
712
|
+
} finally {
|
|
713
|
+
await disableWebMcp(client);
|
|
714
|
+
client.close();
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
async function readInventory(client, page, operation) {
|
|
718
|
+
const framesBefore = await readWebMcpFrames(client, operation.signal);
|
|
719
|
+
assertOperationCurrent(operation);
|
|
720
|
+
const protocolTools = await enableWebMcp(client, operation.signal);
|
|
721
|
+
assertOperationCurrent(operation);
|
|
722
|
+
if (protocolTools.length > MAX_WEBMCP_TOOLS) {
|
|
723
|
+
throw new Error(`WebMCP page exposes more than ${MAX_WEBMCP_TOOLS} tools.`);
|
|
724
|
+
}
|
|
725
|
+
const framesAfter = await readWebMcpFrames(client, operation.signal);
|
|
726
|
+
assertOperationCurrent(operation);
|
|
727
|
+
requireStableInventoryDocuments(protocolTools, framesBefore, framesAfter);
|
|
728
|
+
return normalizeInventory(protocolTools, framesAfter, page, operation);
|
|
729
|
+
}
|
|
730
|
+
function requireStableInventoryDocuments(tools, before, after) {
|
|
731
|
+
const beforeLoaders = new Map(before.map((frame) => [frame.id, frame.loaderId]));
|
|
732
|
+
const afterLoaders = new Map(after.map((frame) => [frame.id, frame.loaderId]));
|
|
733
|
+
for (const frameId of new Set(tools.map((tool) => tool.frameId))) {
|
|
734
|
+
const beforeLoader = beforeLoaders.get(frameId);
|
|
735
|
+
const afterLoader = afterLoaders.get(frameId);
|
|
736
|
+
if (!beforeLoader || !afterLoader || beforeLoader !== afterLoader) {
|
|
737
|
+
throw new Error(
|
|
738
|
+
"A WebMCP frame document changed while Chrome was publishing its tool inventory."
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
function normalizeInventory(protocolTools, frames, page, operation) {
|
|
744
|
+
const frameDocuments = new Map(
|
|
745
|
+
frames.map((frame) => [frame.id, { documentId: frame.loaderId, origin: frameOrigin(frame) }])
|
|
746
|
+
);
|
|
747
|
+
const tools = protocolTools.map((tool) => {
|
|
748
|
+
const frame = frameDocuments.get(tool.frameId);
|
|
749
|
+
if (!frame) {
|
|
750
|
+
throw new Error(`WebMCP tool ${webMcpErrorMessage(tool.name)} belongs to an unknown frame.`);
|
|
751
|
+
}
|
|
752
|
+
return normalizeWebMcpTool(tool, {
|
|
753
|
+
documentId: frame.documentId,
|
|
754
|
+
frameOrigin: frame.origin,
|
|
755
|
+
pageId: page.id,
|
|
756
|
+
pageUrl: page.url,
|
|
757
|
+
sessionGeneration: generationToken(operation)
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
const identities = /* @__PURE__ */ new Set();
|
|
761
|
+
for (const tool of tools) {
|
|
762
|
+
const key = `${tool.frameId}\0${tool.name}`;
|
|
763
|
+
if (identities.has(key)) {
|
|
764
|
+
throw new Error(
|
|
765
|
+
`WebMCP tool identity is duplicated: ${webMcpErrorMessage(tool.name)} in frame ${webMcpErrorMessage(tool.frameId)}.`
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
identities.add(key);
|
|
769
|
+
}
|
|
770
|
+
return tools;
|
|
771
|
+
}
|
|
772
|
+
async function requireStablePage(client, page, tools, operation) {
|
|
773
|
+
const current = await getPage(page.id, {
|
|
774
|
+
sessionOwner: operation.owner,
|
|
775
|
+
signal: operation.signal
|
|
776
|
+
});
|
|
777
|
+
assertOperationCurrent(operation);
|
|
778
|
+
if (current.url !== page.url || current.webSocketDebuggerUrl !== page.webSocketDebuggerUrl || current.type !== page.type) {
|
|
779
|
+
throw new Error("The Chrome page navigated or was replaced during the WebMCP operation.");
|
|
780
|
+
}
|
|
781
|
+
const finalFrames = await readWebMcpFrames(client, operation.signal);
|
|
782
|
+
assertOperationCurrent(operation);
|
|
783
|
+
const finalDocuments = new Map(finalFrames.map((frame) => [frame.id, frame.loaderId]));
|
|
784
|
+
for (const tool of tools) {
|
|
785
|
+
if (finalDocuments.get(tool.frameId) !== tool.documentId) {
|
|
786
|
+
throw new Error("A WebMCP frame document changed during the final page identity check.");
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
function assertOperationCurrent(operation) {
|
|
791
|
+
operation.signal.throwIfAborted();
|
|
792
|
+
if (!webMcpOperationIsCurrent(operation)) {
|
|
793
|
+
throw new DOMException("The WebMCP operation became stale.", "AbortError");
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
function generationToken(operation) {
|
|
797
|
+
return `${operation.sessionGeneration}:${operation.webMcpGeneration}`;
|
|
798
|
+
}
|
|
799
|
+
function frameOrigin(frame) {
|
|
800
|
+
if (frame.securityOrigin && frame.securityOrigin !== "://") return frame.securityOrigin;
|
|
801
|
+
try {
|
|
802
|
+
return new URL(frame.url).origin;
|
|
803
|
+
} catch {
|
|
804
|
+
throw new Error(
|
|
805
|
+
`Chrome returned an invalid WebMCP frame URL: ${webMcpErrorMessage(frame.url)}`
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
function remoteExceptionText(value) {
|
|
810
|
+
if (typeof value === "object" && value !== null) {
|
|
811
|
+
const exception = value;
|
|
812
|
+
if (typeof exception.description === "string") return exception.description;
|
|
813
|
+
if (typeof exception.value === "string") return exception.value;
|
|
814
|
+
}
|
|
815
|
+
return "The page reported an unknown tool error.";
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// src/webmcp/tools.ts
|
|
819
|
+
async function executeWebMcpListTool(params, toolSignal, ctx) {
|
|
820
|
+
try {
|
|
821
|
+
requireWebMcpEnabled(ctx.sessionManager);
|
|
822
|
+
const preflightGeneration = currentWebMcpGeneration(ctx.sessionManager);
|
|
823
|
+
const preflightSignal = combinedSignal(ctx.sessionManager, toolSignal);
|
|
824
|
+
preflightSignal.throwIfAborted();
|
|
825
|
+
const page = await resolvePage(params.pageId, {
|
|
826
|
+
sessionOwner: ctx.sessionManager,
|
|
827
|
+
signal: preflightSignal
|
|
828
|
+
});
|
|
829
|
+
preflightSignal.throwIfAborted();
|
|
830
|
+
requireCurrentPreflight(ctx.sessionManager, preflightGeneration);
|
|
831
|
+
const operation = beginWebMcpOperation(ctx.sessionManager, toolSignal);
|
|
832
|
+
try {
|
|
833
|
+
const tools = await discoverWebMcpTools(page, operation);
|
|
834
|
+
assertCurrent(operation);
|
|
835
|
+
const published = boundedWebMcpDiscovery(
|
|
836
|
+
{ id: page.id, title: page.title, url: page.url },
|
|
837
|
+
tools
|
|
838
|
+
);
|
|
839
|
+
return textResult(published.text, {
|
|
840
|
+
page: { id: page.id, url: page.url },
|
|
841
|
+
toolCount: tools.length,
|
|
842
|
+
publishedToolCount: published.included.length,
|
|
843
|
+
truncated: published.truncated,
|
|
844
|
+
identities: published.included.map(webMcpIdentity)
|
|
845
|
+
});
|
|
846
|
+
} finally {
|
|
847
|
+
operation.dispose();
|
|
848
|
+
}
|
|
849
|
+
} catch (error) {
|
|
850
|
+
throw safeToolError(error);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
async function executeWebMcpCallTool(params, toolSignal, ctx) {
|
|
854
|
+
try {
|
|
855
|
+
requireWebMcpEnabled(ctx.sessionManager);
|
|
856
|
+
if (!ctx.hasUI || ctx.mode !== "tui" && ctx.mode !== "rpc") {
|
|
857
|
+
throw new Error(
|
|
858
|
+
"chrome_devtools_webmcp_call_tool requires observable confirmation in TUI or RPC mode and is unavailable in print or JSON mode."
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
const input = normalizeWebMcpInput(params.input);
|
|
862
|
+
const expected = expectedIdentity(params);
|
|
863
|
+
const preflightGeneration = currentWebMcpGeneration(ctx.sessionManager);
|
|
864
|
+
const preflightSignal = combinedSignal(ctx.sessionManager, toolSignal);
|
|
865
|
+
preflightSignal.throwIfAborted();
|
|
866
|
+
const page = await getPage(params.pageId, {
|
|
867
|
+
sessionOwner: ctx.sessionManager,
|
|
868
|
+
signal: preflightSignal
|
|
869
|
+
});
|
|
870
|
+
preflightSignal.throwIfAborted();
|
|
871
|
+
requireCurrentPreflight(ctx.sessionManager, preflightGeneration);
|
|
872
|
+
const operation = beginWebMcpOperation(ctx.sessionManager, toolSignal);
|
|
873
|
+
try {
|
|
874
|
+
const discovered = await discoverWebMcpTools(page, operation);
|
|
875
|
+
const current = requireMatchingWebMcpTool(discovered, expected);
|
|
876
|
+
const validatedInput = validateWebMcpInput(current.inputSchema, input);
|
|
877
|
+
assertCurrent(operation);
|
|
878
|
+
const confirmed = await ctx.ui.confirm(
|
|
879
|
+
`Allow WebMCP tool: ${sanitizeWebMcpDisplay(current.name, 256)}`,
|
|
880
|
+
webMcpConfirmationMessage(current, validatedInput, usesManagedProfile(ctx.sessionManager)),
|
|
881
|
+
{ signal: operation.signal }
|
|
882
|
+
);
|
|
883
|
+
assertCurrent(operation);
|
|
884
|
+
if (!confirmed) {
|
|
885
|
+
throw new DOMException("WebMCP tool call cancelled by the user.", "AbortError");
|
|
886
|
+
}
|
|
887
|
+
const invocation = await invokeDiscoveredWebMcpTool(
|
|
888
|
+
page,
|
|
889
|
+
expected,
|
|
890
|
+
validatedInput,
|
|
891
|
+
operation
|
|
892
|
+
);
|
|
893
|
+
assertCurrent(operation);
|
|
894
|
+
const published = boundedWebMcpJson({
|
|
895
|
+
status: "completed",
|
|
896
|
+
tool: webMcpIdentity(invocation.tool),
|
|
897
|
+
output: invocation.output
|
|
898
|
+
});
|
|
899
|
+
return textResult(published.text, {
|
|
900
|
+
invocationId: invocation.invocationId,
|
|
901
|
+
tool: webMcpIdentity(invocation.tool),
|
|
902
|
+
truncated: published.truncated,
|
|
903
|
+
output: published.text
|
|
904
|
+
});
|
|
905
|
+
} finally {
|
|
906
|
+
operation.dispose();
|
|
907
|
+
}
|
|
908
|
+
} catch (error) {
|
|
909
|
+
throw safeToolError(error);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
function requireWebMcpEnabled(owner) {
|
|
913
|
+
if (!webMcpEnabled(owner)) {
|
|
914
|
+
throw new Error(
|
|
915
|
+
"WebMCP is disabled. Enable the experimental user setting in /chrome-devtools settings, then make the WebMCP gateway tools available."
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
function combinedSignal(owner, toolSignal) {
|
|
920
|
+
const sessionSignal = webMcpSessionSignal(owner);
|
|
921
|
+
return toolSignal ? AbortSignal.any([toolSignal, sessionSignal]) : sessionSignal;
|
|
922
|
+
}
|
|
923
|
+
function requireCurrentPreflight(owner, generation) {
|
|
924
|
+
requireWebMcpEnabled(owner);
|
|
925
|
+
if (generation !== currentWebMcpGeneration(owner)) {
|
|
926
|
+
throw new DOMException(
|
|
927
|
+
"The WebMCP operation became stale during page resolution.",
|
|
928
|
+
"AbortError"
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
function expectedIdentity(params) {
|
|
933
|
+
return {
|
|
934
|
+
sessionGeneration: params.sessionGeneration,
|
|
935
|
+
pageId: params.pageId,
|
|
936
|
+
documentId: params.documentId,
|
|
937
|
+
frameId: params.frameId,
|
|
938
|
+
frameOrigin: params.frameOrigin,
|
|
939
|
+
name: params.toolName,
|
|
940
|
+
schemaDigest: params.schemaDigest
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
function assertCurrent(operation) {
|
|
944
|
+
operation.signal.throwIfAborted();
|
|
945
|
+
if (!webMcpOperationIsCurrent(operation)) {
|
|
946
|
+
throw new DOMException("The WebMCP operation became stale.", "AbortError");
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
function usesManagedProfile(owner) {
|
|
950
|
+
const managedBrowser = managedBrowserForOwner(owner);
|
|
951
|
+
return Boolean(managedBrowser?.ready && !managedBrowser.exited);
|
|
952
|
+
}
|
|
953
|
+
function safeToolError(error) {
|
|
954
|
+
const message = webMcpErrorMessage(error);
|
|
955
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
956
|
+
return new DOMException(message, "AbortError");
|
|
957
|
+
}
|
|
958
|
+
return new Error(message);
|
|
959
|
+
}
|
|
960
|
+
export {
|
|
961
|
+
executeWebMcpCallTool,
|
|
962
|
+
executeWebMcpListTool
|
|
963
|
+
};
|
|
964
|
+
//# sourceMappingURL=tools-6P7EUX37.js.map
|