@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.
- package/README.md +89 -230
- package/dist/chunks/{browser-settings-menu-REIU7C3Q.js → browser-settings-menu-SR7KLZT6.js} +16 -42
- package/dist/chunks/browser-settings-menu-SR7KLZT6.js.map +7 -0
- package/dist/chunks/{chunk-GTTYS5GT.js → chunk-4KMN4SFF.js} +2 -2
- package/dist/chunks/chunk-4KMN4SFF.js.map +7 -0
- package/dist/chunks/{chunk-SRT3UUIP.js → chunk-F5VG35UI.js} +23 -74
- package/dist/chunks/chunk-F5VG35UI.js.map +7 -0
- package/dist/chunks/{chunk-YJVYJ2L2.js → chunk-MZCGM6D3.js} +43 -48
- package/dist/chunks/chunk-MZCGM6D3.js.map +7 -0
- package/dist/chunks/{menu-H5HMVGHI.js → menu-YR4VRT7D.js} +10 -23
- package/dist/chunks/menu-YR4VRT7D.js.map +7 -0
- package/dist/chunks/{tools-6P7EUX37.js → tools-6R7Z6HWJ.js} +17 -59
- package/dist/chunks/tools-6R7Z6HWJ.js.map +7 -0
- package/dist/index.ts +23 -48
- package/dist/index.ts.map +2 -2
- package/docs/browser-setup.md +147 -0
- package/package.json +54 -55
- package/src/browser-manager.ts +872 -942
- package/src/browser-settings-menu.ts +315 -350
- package/src/cdp-client.ts +381 -398
- package/src/chrome-devtools.ts +205 -232
- package/src/display.ts +35 -0
- package/src/lazy-tools.ts +158 -181
- package/src/menu.ts +409 -446
- package/src/render.ts +74 -84
- package/src/runtime.ts +137 -140
- package/src/screenshot.ts +170 -186
- package/src/settings.ts +472 -523
- package/src/tool-names.ts +8 -14
- package/src/tool-selector.ts +245 -289
- package/src/tools.ts +218 -226
- package/src/webmcp/discovery.ts +184 -188
- package/src/webmcp/policy.ts +296 -329
- package/src/webmcp/protocol.ts +324 -337
- package/src/webmcp/tools.ts +136 -147
- package/dist/chunks/browser-settings-menu-REIU7C3Q.js.map +0 -7
- package/dist/chunks/chunk-GTTYS5GT.js.map +0 -7
- package/dist/chunks/chunk-SRT3UUIP.js.map +0 -7
- package/dist/chunks/chunk-YJVYJ2L2.js.map +0 -7
- package/dist/chunks/menu-H5HMVGHI.js.map +0 -7
- package/dist/chunks/tools-6P7EUX37.js.map +0 -7
package/src/webmcp/policy.ts
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
177
|
-
|
|
162
|
+
page: { id: string; title: string; url: string },
|
|
163
|
+
tools: readonly WebMcpToolDescriptor[],
|
|
178
164
|
) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
202
|
+
tool: WebMcpToolDescriptor,
|
|
203
|
+
input: Record<string, unknown>,
|
|
204
|
+
managedProfile: boolean,
|
|
219
205
|
) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
235
|
+
return sanitizeWebMcpDisplay(error instanceof Error ? error.message : String(error), 8_192);
|
|
253
236
|
}
|
|
254
237
|
|
|
255
238
|
function canonicalAnnotations(value: WebMcpProtocolAnnotation | undefined) {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
375
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
409
376
|
}
|