@narumitw/pi-chrome-devtools 0.53.1 → 0.53.3

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.
Files changed (41) hide show
  1. package/README.md +89 -230
  2. package/dist/chunks/{browser-settings-menu-REIU7C3Q.js → browser-settings-menu-SR7KLZT6.js} +16 -42
  3. package/dist/chunks/browser-settings-menu-SR7KLZT6.js.map +7 -0
  4. package/dist/chunks/{chunk-GTTYS5GT.js → chunk-4KMN4SFF.js} +2 -2
  5. package/dist/chunks/chunk-4KMN4SFF.js.map +7 -0
  6. package/dist/chunks/{chunk-SRT3UUIP.js → chunk-F5VG35UI.js} +23 -74
  7. package/dist/chunks/chunk-F5VG35UI.js.map +7 -0
  8. package/dist/chunks/{chunk-YJVYJ2L2.js → chunk-MZCGM6D3.js} +43 -48
  9. package/dist/chunks/chunk-MZCGM6D3.js.map +7 -0
  10. package/dist/chunks/{menu-H5HMVGHI.js → menu-YR4VRT7D.js} +10 -23
  11. package/dist/chunks/menu-YR4VRT7D.js.map +7 -0
  12. package/dist/chunks/{tools-6P7EUX37.js → tools-6R7Z6HWJ.js} +17 -59
  13. package/dist/chunks/tools-6R7Z6HWJ.js.map +7 -0
  14. package/dist/index.ts +23 -48
  15. package/dist/index.ts.map +2 -2
  16. package/docs/browser-setup.md +147 -0
  17. package/package.json +54 -55
  18. package/src/browser-manager.ts +872 -942
  19. package/src/browser-settings-menu.ts +315 -350
  20. package/src/cdp-client.ts +381 -398
  21. package/src/chrome-devtools.ts +205 -232
  22. package/src/display.ts +35 -0
  23. package/src/lazy-tools.ts +158 -181
  24. package/src/menu.ts +409 -446
  25. package/src/render.ts +74 -84
  26. package/src/runtime.ts +137 -140
  27. package/src/screenshot.ts +170 -186
  28. package/src/settings.ts +472 -523
  29. package/src/tool-names.ts +8 -14
  30. package/src/tool-selector.ts +245 -289
  31. package/src/tools.ts +218 -226
  32. package/src/webmcp/discovery.ts +184 -188
  33. package/src/webmcp/policy.ts +296 -329
  34. package/src/webmcp/protocol.ts +324 -337
  35. package/src/webmcp/tools.ts +136 -147
  36. package/dist/chunks/browser-settings-menu-REIU7C3Q.js.map +0 -7
  37. package/dist/chunks/chunk-GTTYS5GT.js.map +0 -7
  38. package/dist/chunks/chunk-SRT3UUIP.js.map +0 -7
  39. package/dist/chunks/chunk-YJVYJ2L2.js.map +0 -7
  40. package/dist/chunks/menu-H5HMVGHI.js.map +0 -7
  41. package/dist/chunks/tools-6P7EUX37.js.map +0 -7
@@ -1,11 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { stripVTControlCharacters } from "node:util";
3
- import {
4
- DEFAULT_MAX_BYTES,
5
- DEFAULT_MAX_LINES,
6
- formatSize,
7
- truncateHead,
8
- } from "@earendil-works/pi-coding-agent";
3
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "@earendil-works/pi-coding-agent";
9
4
  import type { TSchema } from "typebox";
10
5
  import { Check } from "typebox/value";
11
6
  import type { WebMcpProtocolAnnotation, WebMcpProtocolTool } from "./protocol.js";
@@ -20,390 +15,362 @@ const MAX_OUTPUT_NODES = 100_000;
20
15
  const MAX_SCHEMA_VALIDATION_BRANCHES = 128;
21
16
 
22
17
  export interface WebMcpToolDescriptor {
23
- annotations: WebMcpProtocolAnnotation;
24
- description: string;
25
- documentId: string;
26
- frameId: string;
27
- frameOrigin: string;
28
- inputSchema: Record<string, unknown>;
29
- name: string;
30
- pageId: string;
31
- pageUrl: string;
32
- schemaDigest: string;
33
- sessionGeneration: string;
34
- title?: string;
18
+ annotations: WebMcpProtocolAnnotation;
19
+ description: string;
20
+ documentId: string;
21
+ frameId: string;
22
+ frameOrigin: string;
23
+ inputSchema: Record<string, unknown>;
24
+ name: string;
25
+ pageId: string;
26
+ pageUrl: string;
27
+ schemaDigest: string;
28
+ sessionGeneration: string;
29
+ title?: string;
35
30
  }
36
31
 
37
32
  export interface WebMcpToolIdentity {
38
- documentId: string;
39
- frameId: string;
40
- frameOrigin: string;
41
- name: string;
42
- pageId: string;
43
- schemaDigest: string;
44
- sessionGeneration: string;
33
+ documentId: string;
34
+ frameId: string;
35
+ frameOrigin: string;
36
+ name: string;
37
+ pageId: string;
38
+ schemaDigest: string;
39
+ sessionGeneration: string;
45
40
  }
46
41
 
47
42
  export function normalizeWebMcpTool(
48
- tool: WebMcpProtocolTool,
49
- context: {
50
- documentId: string;
51
- frameOrigin: string;
52
- pageId: string;
53
- pageUrl: string;
54
- sessionGeneration: string;
55
- },
43
+ tool: WebMcpProtocolTool,
44
+ context: {
45
+ documentId: string;
46
+ frameOrigin: string;
47
+ pageId: string;
48
+ pageUrl: string;
49
+ sessionGeneration: string;
50
+ },
56
51
  ): WebMcpToolDescriptor {
57
- const name = boundedString(tool.name, "tool name", 512, { nonEmpty: true });
58
- const description = boundedString(tool.description, "tool description", 16 * 1024);
59
- const title =
60
- tool.title === undefined ? undefined : boundedString(tool.title, "tool title", 1_024);
61
- const documentId = boundedString(context.documentId, "document loader ID", 512, {
62
- nonEmpty: true,
63
- });
64
- const frameId = boundedString(tool.frameId, "frame ID", 512, { nonEmpty: true });
65
- const frameOrigin = boundedString(context.frameOrigin, "frame origin", 2_048, {
66
- nonEmpty: true,
67
- });
68
- const pageId = boundedString(context.pageId, "page ID", 512, { nonEmpty: true });
69
- const pageUrl = boundedString(context.pageUrl, "page URL", 8_192);
70
- const inputSchema = tool.inputSchema ?? { type: "object", additionalProperties: true };
71
- rejectRegexBearingSchema(inputSchema);
72
- const canonicalSchema = canonicalJsonObject(inputSchema, {
73
- label: "WebMCP input schema",
74
- maxBytes: MAX_SCHEMA_BYTES,
75
- maxDepth: MAX_JSON_DEPTH,
76
- maxNodes: MAX_JSON_NODES,
77
- });
78
- rejectExpensiveValidationSchema(canonicalSchema);
79
- const annotations = canonicalAnnotations(tool.annotations);
80
- const schemaDigest = createHash("sha256")
81
- .update(canonicalStringify({ inputSchema: canonicalSchema, annotations }))
82
- .digest("hex");
83
- return {
84
- annotations,
85
- description,
86
- documentId,
87
- frameId,
88
- frameOrigin,
89
- inputSchema: canonicalSchema,
90
- name,
91
- pageId,
92
- pageUrl,
93
- schemaDigest,
94
- sessionGeneration: context.sessionGeneration,
95
- ...(title === undefined ? {} : { title }),
96
- };
52
+ const name = boundedString(tool.name, "tool name", 512, { nonEmpty: true });
53
+ const description = boundedString(tool.description, "tool description", 16 * 1024);
54
+ const title = tool.title === undefined ? undefined : boundedString(tool.title, "tool title", 1_024);
55
+ const documentId = boundedString(context.documentId, "document loader ID", 512, {
56
+ nonEmpty: true,
57
+ });
58
+ const frameId = boundedString(tool.frameId, "frame ID", 512, { nonEmpty: true });
59
+ const frameOrigin = boundedString(context.frameOrigin, "frame origin", 2_048, {
60
+ nonEmpty: true,
61
+ });
62
+ const pageId = boundedString(context.pageId, "page ID", 512, { nonEmpty: true });
63
+ const pageUrl = boundedString(context.pageUrl, "page URL", 8_192);
64
+ const inputSchema = tool.inputSchema ?? { type: "object", additionalProperties: true };
65
+ rejectRegexBearingSchema(inputSchema);
66
+ const canonicalSchema = canonicalJsonObject(inputSchema, {
67
+ label: "WebMCP input schema",
68
+ maxBytes: MAX_SCHEMA_BYTES,
69
+ maxDepth: MAX_JSON_DEPTH,
70
+ maxNodes: MAX_JSON_NODES,
71
+ });
72
+ rejectExpensiveValidationSchema(canonicalSchema);
73
+ const annotations = canonicalAnnotations(tool.annotations);
74
+ const schemaDigest = createHash("sha256")
75
+ .update(canonicalStringify({ inputSchema: canonicalSchema, annotations }))
76
+ .digest("hex");
77
+ return {
78
+ annotations,
79
+ description,
80
+ documentId,
81
+ frameId,
82
+ frameOrigin,
83
+ inputSchema: canonicalSchema,
84
+ name,
85
+ pageId,
86
+ pageUrl,
87
+ schemaDigest,
88
+ sessionGeneration: context.sessionGeneration,
89
+ ...(title === undefined ? {} : { title }),
90
+ };
97
91
  }
98
92
 
99
93
  export function webMcpIdentity(tool: WebMcpToolDescriptor): WebMcpToolIdentity {
100
- return {
101
- sessionGeneration: tool.sessionGeneration,
102
- pageId: tool.pageId,
103
- documentId: tool.documentId,
104
- frameId: tool.frameId,
105
- frameOrigin: tool.frameOrigin,
106
- name: tool.name,
107
- schemaDigest: tool.schemaDigest,
108
- };
94
+ return {
95
+ sessionGeneration: tool.sessionGeneration,
96
+ pageId: tool.pageId,
97
+ documentId: tool.documentId,
98
+ frameId: tool.frameId,
99
+ frameOrigin: tool.frameOrigin,
100
+ name: tool.name,
101
+ schemaDigest: tool.schemaDigest,
102
+ };
109
103
  }
110
104
 
111
- export function requireMatchingWebMcpTool(
112
- tools: readonly WebMcpToolDescriptor[],
113
- expected: WebMcpToolIdentity,
114
- ) {
115
- const candidates = tools.filter(
116
- (tool) => tool.frameId === expected.frameId && tool.name === expected.name,
117
- );
118
- if (candidates.length === 0) throw new Error("The selected WebMCP tool is no longer available.");
119
- if (candidates.length > 1) {
120
- throw new Error("The selected WebMCP tool identity is ambiguous in its frame.");
121
- }
122
- const current = candidates[0];
123
- if (!current) throw new Error("The selected WebMCP tool is no longer available.");
124
- const mismatches = (
125
- [
126
- ["session generation", current.sessionGeneration, expected.sessionGeneration],
127
- ["page", current.pageId, expected.pageId],
128
- ["document", current.documentId, expected.documentId],
129
- ["frame", current.frameId, expected.frameId],
130
- ["frame origin", current.frameOrigin, expected.frameOrigin],
131
- ["schema or annotations", current.schemaDigest, expected.schemaDigest],
132
- ] as const
133
- ).flatMap(([label, actual, wanted]) => (actual === wanted ? [] : [label]));
134
- if (mismatches.length > 0) {
135
- throw new Error(`The selected WebMCP tool became stale: ${mismatches.join(", ")} changed.`);
136
- }
137
- return current;
105
+ export function requireMatchingWebMcpTool(tools: readonly WebMcpToolDescriptor[], expected: WebMcpToolIdentity) {
106
+ const candidates = tools.filter((tool) => tool.frameId === expected.frameId && tool.name === expected.name);
107
+ if (candidates.length === 0) throw new Error("The selected WebMCP tool is no longer available.");
108
+ if (candidates.length > 1) {
109
+ throw new Error("The selected WebMCP tool identity is ambiguous in its frame.");
110
+ }
111
+ const current = candidates[0];
112
+ if (!current) throw new Error("The selected WebMCP tool is no longer available.");
113
+ const mismatches = (
114
+ [
115
+ ["session generation", current.sessionGeneration, expected.sessionGeneration],
116
+ ["page", current.pageId, expected.pageId],
117
+ ["document", current.documentId, expected.documentId],
118
+ ["frame", current.frameId, expected.frameId],
119
+ ["frame origin", current.frameOrigin, expected.frameOrigin],
120
+ ["schema or annotations", current.schemaDigest, expected.schemaDigest],
121
+ ] as const
122
+ ).flatMap(([label, actual, wanted]) => (actual === wanted ? [] : [label]));
123
+ if (mismatches.length > 0) {
124
+ throw new Error(`The selected WebMCP tool became stale: ${mismatches.join(", ")} changed.`);
125
+ }
126
+ return current;
138
127
  }
139
128
 
140
129
  export function normalizeWebMcpInput(input: Record<string, unknown>) {
141
- return canonicalJsonObject(input, {
142
- label: "WebMCP tool input",
143
- maxBytes: MAX_INPUT_BYTES,
144
- maxDepth: MAX_JSON_DEPTH,
145
- maxNodes: MAX_JSON_NODES,
146
- });
130
+ return canonicalJsonObject(input, {
131
+ label: "WebMCP tool input",
132
+ maxBytes: MAX_INPUT_BYTES,
133
+ maxDepth: MAX_JSON_DEPTH,
134
+ maxNodes: MAX_JSON_NODES,
135
+ });
147
136
  }
148
137
 
149
- export function validateWebMcpInput(
150
- schema: Record<string, unknown>,
151
- input: Record<string, unknown>,
152
- ) {
153
- const normalized = normalizeWebMcpInput(input);
154
- let valid: boolean;
155
- try {
156
- valid = Check(schema as TSchema, normalized);
157
- } catch (error) {
158
- throw new Error(
159
- `The page provided a WebMCP input schema that cannot be validated: ${error instanceof Error ? error.message : String(error)}`,
160
- );
161
- }
162
- if (!valid) throw new Error("WebMCP tool input does not match the current page-provided schema.");
163
- return normalized;
138
+ export function validateWebMcpInput(schema: Record<string, unknown>, input: Record<string, unknown>) {
139
+ const normalized = normalizeWebMcpInput(input);
140
+ let valid: boolean;
141
+ try {
142
+ valid = Check(schema as TSchema, normalized);
143
+ } catch (error) {
144
+ throw new Error(
145
+ `The page provided a WebMCP input schema that cannot be validated: ${error instanceof Error ? error.message : String(error)}`,
146
+ );
147
+ }
148
+ if (!valid) throw new Error("WebMCP tool input does not match the current page-provided schema.");
149
+ return normalized;
164
150
  }
165
151
 
166
152
  export function normalizeWebMcpOutput(output: unknown) {
167
- return canonicalJsonValue(output, {
168
- label: "WebMCP tool output",
169
- maxBytes: MAX_OUTPUT_BYTES,
170
- maxDepth: MAX_JSON_DEPTH * 2,
171
- maxNodes: MAX_OUTPUT_NODES,
172
- });
153
+ return canonicalJsonValue(output, {
154
+ label: "WebMCP tool output",
155
+ maxBytes: MAX_OUTPUT_BYTES,
156
+ maxDepth: MAX_JSON_DEPTH * 2,
157
+ maxNodes: MAX_OUTPUT_NODES,
158
+ });
173
159
  }
174
160
 
175
161
  export function boundedWebMcpDiscovery(
176
- page: { id: string; title: string; url: string },
177
- tools: readonly WebMcpToolDescriptor[],
162
+ page: { id: string; title: string; url: string },
163
+ tools: readonly WebMcpToolDescriptor[],
178
164
  ) {
179
- const included: WebMcpToolDescriptor[] = [];
180
- for (const tool of tools) {
181
- const candidate = [...included, tool];
182
- const publication = boundedWebMcpJson({
183
- page,
184
- tools: candidate,
185
- totalToolCount: tools.length,
186
- truncated: candidate.length < tools.length,
187
- });
188
- if (publication.truncated) break;
189
- included.push(tool);
190
- }
191
- const truncated = included.length < tools.length;
192
- const publication = boundedWebMcpJson({
193
- page,
194
- tools: included,
195
- totalToolCount: tools.length,
196
- truncated,
197
- });
198
- if (publication.truncated) {
199
- throw new Error("WebMCP discovery metadata cannot fit within Pi's output limit.");
200
- }
201
- return { ...publication, included, truncated };
165
+ const included: WebMcpToolDescriptor[] = [];
166
+ for (const tool of tools) {
167
+ const candidate = [...included, tool];
168
+ const publication = boundedWebMcpJson({
169
+ page,
170
+ tools: candidate,
171
+ totalToolCount: tools.length,
172
+ truncated: candidate.length < tools.length,
173
+ });
174
+ if (publication.truncated) break;
175
+ included.push(tool);
176
+ }
177
+ const truncated = included.length < tools.length;
178
+ const publication = boundedWebMcpJson({
179
+ page,
180
+ tools: included,
181
+ totalToolCount: tools.length,
182
+ truncated,
183
+ });
184
+ if (publication.truncated) {
185
+ throw new Error("WebMCP discovery metadata cannot fit within Pi's output limit.");
186
+ }
187
+ return { ...publication, included, truncated };
202
188
  }
203
189
 
204
190
  export function boundedWebMcpJson(value: unknown) {
205
- const serialized = sanitizeWebMcpDisplay(JSON.stringify(value, null, 2), Number.MAX_SAFE_INTEGER);
206
- const result = truncateHead(serialized, {
207
- maxBytes: Math.max(1, DEFAULT_MAX_BYTES - 256),
208
- maxLines: Math.max(1, DEFAULT_MAX_LINES - 2),
209
- });
210
- if (!result.truncated) return { text: result.content, truncated: false };
211
- const notice = `\n[WebMCP output truncated: ${result.outputLines}/${result.totalLines} lines, ${formatSize(result.outputBytes)}/${formatSize(result.totalBytes)}.]`;
212
- return { text: `${result.content}${notice}`, truncated: true };
191
+ const serialized = sanitizeWebMcpDisplay(JSON.stringify(value, null, 2), Number.MAX_SAFE_INTEGER);
192
+ const result = truncateHead(serialized, {
193
+ maxBytes: Math.max(1, DEFAULT_MAX_BYTES - 256),
194
+ maxLines: Math.max(1, DEFAULT_MAX_LINES - 2),
195
+ });
196
+ if (!result.truncated) return { text: result.content, truncated: false };
197
+ const notice = `\n[WebMCP output truncated: ${result.outputLines}/${result.totalLines} lines, ${formatSize(result.outputBytes)}/${formatSize(result.totalBytes)}.]`;
198
+ return { text: `${result.content}${notice}`, truncated: true };
213
199
  }
214
200
 
215
201
  export function webMcpConfirmationMessage(
216
- tool: WebMcpToolDescriptor,
217
- input: Record<string, unknown>,
218
- managedProfile: boolean,
202
+ tool: WebMcpToolDescriptor,
203
+ input: Record<string, unknown>,
204
+ managedProfile: boolean,
219
205
  ) {
220
- const inputSummary = sanitizeWebMcpDisplay(JSON.stringify(input, null, 2), 2_000);
221
- return [
222
- managedProfile
223
- ? "This call uses the isolated browser profile managed by Pi."
224
- : "Warning: this call uses an attached browser profile that may contain everyday authenticated sessions and sensitive state.",
225
- `Page: ${sanitizeWebMcpDisplay(tool.pageUrl, 4_096)}`,
226
- `Frame origin: ${sanitizeWebMcpDisplay(tool.frameOrigin, 2_048)}`,
227
- `Tool: ${sanitizeWebMcpDisplay(tool.name, 512)}`,
228
- "Page annotations are untrusted and do not bypass this confirmation.",
229
- "Input:",
230
- inputSummary,
231
- ].join("\n");
206
+ const inputSummary = sanitizeWebMcpDisplay(JSON.stringify(input, null, 2), 2_000);
207
+ return [
208
+ managedProfile
209
+ ? "This call uses the isolated browser profile managed by Pi."
210
+ : "Warning: this call uses an attached browser profile that may contain everyday authenticated sessions and sensitive state.",
211
+ `Page: ${sanitizeWebMcpDisplay(tool.pageUrl, 4_096)}`,
212
+ `Frame origin: ${sanitizeWebMcpDisplay(tool.frameOrigin, 2_048)}`,
213
+ `Tool: ${sanitizeWebMcpDisplay(tool.name, 512)}`,
214
+ "Page annotations are untrusted and do not bypass this confirmation.",
215
+ "Input:",
216
+ inputSummary,
217
+ ].join("\n");
232
218
  }
233
219
 
234
220
  export function sanitizeWebMcpDisplay(value: string, maxCharacters = 50_000) {
235
- const withoutBidi = stripVTControlCharacters(value).replace(
236
- /[\u202a-\u202e\u2066-\u2069]/gu,
237
- "�",
238
- );
239
- const sanitized = Array.from(withoutBidi, (character) => {
240
- const codePoint = character.codePointAt(0) ?? 0;
241
- const unsafeControl =
242
- (codePoint >= 0 && codePoint <= 8) ||
243
- (codePoint >= 11 && codePoint <= 31) ||
244
- (codePoint >= 127 && codePoint <= 159);
245
- return unsafeControl ? "�" : character;
246
- }).join("");
247
- if (sanitized.length <= maxCharacters) return sanitized;
248
- return `${sanitized.slice(0, Math.max(0, maxCharacters - 1))}…`;
221
+ const withoutBidi = stripVTControlCharacters(value).replace(/[\u202a-\u202e\u2066-\u2069]/gu, "�");
222
+ const sanitized = Array.from(withoutBidi, (character) => {
223
+ const codePoint = character.codePointAt(0) ?? 0;
224
+ const unsafeControl =
225
+ (codePoint >= 0 && codePoint <= 8) ||
226
+ (codePoint >= 11 && codePoint <= 31) ||
227
+ (codePoint >= 127 && codePoint <= 159);
228
+ return unsafeControl ? "�" : character;
229
+ }).join("");
230
+ if (sanitized.length <= maxCharacters) return sanitized;
231
+ return `${sanitized.slice(0, Math.max(0, maxCharacters - 1))}…`;
249
232
  }
250
233
 
251
234
  export function webMcpErrorMessage(error: unknown) {
252
- return sanitizeWebMcpDisplay(error instanceof Error ? error.message : String(error), 8_192);
235
+ return sanitizeWebMcpDisplay(error instanceof Error ? error.message : String(error), 8_192);
253
236
  }
254
237
 
255
238
  function canonicalAnnotations(value: WebMcpProtocolAnnotation | undefined) {
256
- if (!value) return {};
257
- return Object.fromEntries(
258
- Object.entries(value)
259
- .filter((entry): entry is [string, boolean] => typeof entry[1] === "boolean")
260
- .sort(([left], [right]) => left.localeCompare(right)),
261
- ) as WebMcpProtocolAnnotation;
239
+ if (!value) return {};
240
+ return Object.fromEntries(
241
+ Object.entries(value)
242
+ .filter((entry): entry is [string, boolean] => typeof entry[1] === "boolean")
243
+ .sort(([left], [right]) => left.localeCompare(right)),
244
+ ) as WebMcpProtocolAnnotation;
262
245
  }
263
246
 
264
247
  function rejectRegexBearingSchema(schema: unknown): void {
265
- if (Array.isArray(schema)) {
266
- for (const child of schema) rejectRegexBearingSchema(child);
267
- return;
268
- }
269
- if (!isRecord(schema)) return;
270
- if (schema.pattern !== undefined || schema.patternProperties !== undefined) {
271
- throw new Error(
272
- "WebMCP input schemas with pattern or patternProperties are unsupported because page-controlled regular expressions cannot be evaluated safely.",
273
- );
274
- }
275
- for (const keyword of [
276
- "additionalItems",
277
- "additionalProperties",
278
- "contains",
279
- "else",
280
- "if",
281
- "items",
282
- "not",
283
- "propertyNames",
284
- "then",
285
- "unevaluatedItems",
286
- "unevaluatedProperties",
287
- ] as const) {
288
- rejectRegexBearingSchema(schema[keyword]);
289
- }
290
- for (const keyword of ["allOf", "anyOf", "oneOf", "prefixItems"] as const) {
291
- if (Array.isArray(schema[keyword])) {
292
- for (const child of schema[keyword]) rejectRegexBearingSchema(child);
293
- }
294
- }
295
- for (const keyword of [
296
- "$defs",
297
- "definitions",
298
- "dependencies",
299
- "dependentSchemas",
300
- "properties",
301
- ] as const) {
302
- if (!isRecord(schema[keyword])) continue;
303
- for (const child of Object.values(schema[keyword])) rejectRegexBearingSchema(child);
304
- }
248
+ if (Array.isArray(schema)) {
249
+ for (const child of schema) rejectRegexBearingSchema(child);
250
+ return;
251
+ }
252
+ if (!isRecord(schema)) return;
253
+ if (schema.pattern !== undefined || schema.patternProperties !== undefined) {
254
+ throw new Error(
255
+ "WebMCP input schemas with pattern or patternProperties are unsupported because page-controlled regular expressions cannot be evaluated safely.",
256
+ );
257
+ }
258
+ for (const keyword of [
259
+ "additionalItems",
260
+ "additionalProperties",
261
+ "contains",
262
+ "else",
263
+ "if",
264
+ "items",
265
+ "not",
266
+ "propertyNames",
267
+ "then",
268
+ "unevaluatedItems",
269
+ "unevaluatedProperties",
270
+ ] as const) {
271
+ rejectRegexBearingSchema(schema[keyword]);
272
+ }
273
+ for (const keyword of ["allOf", "anyOf", "oneOf", "prefixItems"] as const) {
274
+ if (Array.isArray(schema[keyword])) {
275
+ for (const child of schema[keyword]) rejectRegexBearingSchema(child);
276
+ }
277
+ }
278
+ for (const keyword of ["$defs", "definitions", "dependencies", "dependentSchemas", "properties"] as const) {
279
+ if (!isRecord(schema[keyword])) continue;
280
+ for (const child of Object.values(schema[keyword])) rejectRegexBearingSchema(child);
281
+ }
305
282
  }
306
283
 
307
284
  function rejectExpensiveValidationSchema(schema: unknown) {
308
- let branches = 0;
309
- const countBranches = (count: number) => {
310
- branches += count;
311
- if (branches > MAX_SCHEMA_VALIDATION_BRANCHES) {
312
- throw new Error(
313
- `WebMCP input schema exceeds the validation branch limit of ${MAX_SCHEMA_VALIDATION_BRANCHES}.`,
314
- );
315
- }
316
- };
317
- const visit = (candidate: unknown): void => {
318
- if (Array.isArray(candidate)) {
319
- for (const child of candidate) visit(child);
320
- return;
321
- }
322
- if (!isRecord(candidate)) return;
323
- for (const keyword of ["allOf", "anyOf", "oneOf"] as const) {
324
- const alternatives = candidate[keyword];
325
- if (Array.isArray(alternatives)) countBranches(alternatives.length);
326
- }
327
- for (const keyword of ["dependencies", "dependentSchemas"] as const) {
328
- const dependencies = candidate[keyword];
329
- if (!isRecord(dependencies)) continue;
330
- countBranches(Object.values(dependencies).filter(isRecord).length);
331
- }
332
- for (const child of Object.values(candidate)) visit(child);
333
- };
334
- visit(schema);
285
+ let branches = 0;
286
+ const countBranches = (count: number) => {
287
+ branches += count;
288
+ if (branches > MAX_SCHEMA_VALIDATION_BRANCHES) {
289
+ throw new Error(`WebMCP input schema exceeds the validation branch limit of ${MAX_SCHEMA_VALIDATION_BRANCHES}.`);
290
+ }
291
+ };
292
+ const visit = (candidate: unknown): void => {
293
+ if (Array.isArray(candidate)) {
294
+ for (const child of candidate) visit(child);
295
+ return;
296
+ }
297
+ if (!isRecord(candidate)) return;
298
+ for (const keyword of ["allOf", "anyOf", "oneOf"] as const) {
299
+ const alternatives = candidate[keyword];
300
+ if (Array.isArray(alternatives)) countBranches(alternatives.length);
301
+ }
302
+ for (const keyword of ["dependencies", "dependentSchemas"] as const) {
303
+ const dependencies = candidate[keyword];
304
+ if (!isRecord(dependencies)) continue;
305
+ countBranches(Object.values(dependencies).filter(isRecord).length);
306
+ }
307
+ for (const child of Object.values(candidate)) visit(child);
308
+ };
309
+ visit(schema);
335
310
  }
336
311
 
337
312
  interface JsonLimits {
338
- label: string;
339
- maxBytes: number;
340
- maxDepth: number;
341
- maxNodes: number;
313
+ label: string;
314
+ maxBytes: number;
315
+ maxDepth: number;
316
+ maxNodes: number;
342
317
  }
343
318
 
344
319
  function canonicalJsonObject(value: Record<string, unknown>, limits: JsonLimits) {
345
- const canonical = canonicalJsonValue(value, limits);
346
- if (!isRecord(canonical)) throw new Error(`${limits.label} must be a JSON object.`);
347
- return canonical;
320
+ const canonical = canonicalJsonValue(value, limits);
321
+ if (!isRecord(canonical)) throw new Error(`${limits.label} must be a JSON object.`);
322
+ return canonical;
348
323
  }
349
324
 
350
325
  function canonicalJsonValue(value: unknown, limits: JsonLimits) {
351
- let nodes = 0;
352
- const visit = (candidate: unknown, depth: number): unknown => {
353
- nodes += 1;
354
- if (nodes > limits.maxNodes) {
355
- throw new Error(`${limits.label} exceeds the ${limits.maxNodes} value limit.`);
356
- }
357
- if (depth > limits.maxDepth) {
358
- throw new Error(`${limits.label} exceeds the depth limit of ${limits.maxDepth}.`);
359
- }
360
- if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean") {
361
- return candidate;
362
- }
363
- if (typeof candidate === "number") {
364
- if (!Number.isFinite(candidate))
365
- throw new Error(`${limits.label} contains a non-finite number.`);
366
- return candidate;
367
- }
368
- if (Array.isArray(candidate)) return candidate.map((entry) => visit(entry, depth + 1));
369
- if (isRecord(candidate)) {
370
- return Object.fromEntries(
371
- Object.keys(candidate)
372
- .sort()
373
- .map((key) => [key, visit(candidate[key], depth + 1)]),
374
- );
375
- }
376
- throw new Error(`${limits.label} contains a value that is not JSON-compatible.`);
377
- };
378
- const canonical = visit(value, 0);
379
- const serialized = canonicalStringify(canonical);
380
- const bytes = Buffer.byteLength(serialized, "utf8");
381
- if (bytes > limits.maxBytes) {
382
- throw new Error(
383
- `${limits.label} exceeds ${formatSize(limits.maxBytes)} (${formatSize(bytes)}).`,
384
- );
385
- }
386
- return canonical;
326
+ let nodes = 0;
327
+ const visit = (candidate: unknown, depth: number): unknown => {
328
+ nodes += 1;
329
+ if (nodes > limits.maxNodes) {
330
+ throw new Error(`${limits.label} exceeds the ${limits.maxNodes} value limit.`);
331
+ }
332
+ if (depth > limits.maxDepth) {
333
+ throw new Error(`${limits.label} exceeds the depth limit of ${limits.maxDepth}.`);
334
+ }
335
+ if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean") {
336
+ return candidate;
337
+ }
338
+ if (typeof candidate === "number") {
339
+ if (!Number.isFinite(candidate)) throw new Error(`${limits.label} contains a non-finite number.`);
340
+ return candidate;
341
+ }
342
+ if (Array.isArray(candidate)) return candidate.map((entry) => visit(entry, depth + 1));
343
+ if (isRecord(candidate)) {
344
+ return Object.fromEntries(
345
+ Object.keys(candidate)
346
+ .sort()
347
+ .map((key) => [key, visit(candidate[key], depth + 1)]),
348
+ );
349
+ }
350
+ throw new Error(`${limits.label} contains a value that is not JSON-compatible.`);
351
+ };
352
+ const canonical = visit(value, 0);
353
+ const serialized = canonicalStringify(canonical);
354
+ const bytes = Buffer.byteLength(serialized, "utf8");
355
+ if (bytes > limits.maxBytes) {
356
+ throw new Error(`${limits.label} exceeds ${formatSize(limits.maxBytes)} (${formatSize(bytes)}).`);
357
+ }
358
+ return canonical;
387
359
  }
388
360
 
389
361
  function canonicalStringify(value: unknown) {
390
- const serialized = JSON.stringify(value);
391
- if (serialized === undefined) throw new Error("Value is not JSON-compatible.");
392
- return serialized;
362
+ const serialized = JSON.stringify(value);
363
+ if (serialized === undefined) throw new Error("Value is not JSON-compatible.");
364
+ return serialized;
393
365
  }
394
366
 
395
- function boundedString(
396
- value: string,
397
- label: string,
398
- maxBytes: number,
399
- options: { nonEmpty?: boolean } = {},
400
- ) {
401
- if (options.nonEmpty && value.length === 0) throw new Error(`WebMCP ${label} must not be empty.`);
402
- const bytes = Buffer.byteLength(value, "utf8");
403
- if (bytes > maxBytes) throw new Error(`WebMCP ${label} exceeds ${formatSize(maxBytes)}.`);
404
- return value;
367
+ function boundedString(value: string, label: string, maxBytes: number, options: { nonEmpty?: boolean } = {}) {
368
+ if (options.nonEmpty && value.length === 0) throw new Error(`WebMCP ${label} must not be empty.`);
369
+ const bytes = Buffer.byteLength(value, "utf8");
370
+ if (bytes > maxBytes) throw new Error(`WebMCP ${label} exceeds ${formatSize(maxBytes)}.`);
371
+ return value;
405
372
  }
406
373
 
407
374
  function isRecord(value: unknown): value is Record<string, unknown> {
408
- return typeof value === "object" && value !== null && !Array.isArray(value);
375
+ return typeof value === "object" && value !== null && !Array.isArray(value);
409
376
  }