@narumitw/pi-chrome-devtools 0.52.3 → 0.53.0
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-N5O65I7S.js} +81 -7
- package/dist/chunks/browser-settings-menu-N5O65I7S.js.map +7 -0
- package/dist/chunks/chunk-B7B63PWB.js +465 -0
- package/dist/chunks/chunk-B7B63PWB.js.map +7 -0
- package/dist/chunks/{chunk-RH6KESL3.js → chunk-GVBXT3FB.js} +369 -514
- package/dist/chunks/chunk-GVBXT3FB.js.map +7 -0
- package/dist/chunks/chunk-RTECHAEZ.js +373 -0
- package/dist/chunks/chunk-RTECHAEZ.js.map +7 -0
- package/dist/chunks/{menu-PLD3S3EO.js → menu-UBHW6UP2.js} +72 -39
- package/dist/chunks/menu-UBHW6UP2.js.map +7 -0
- package/dist/chunks/tools-V7OR5KJ3.js +945 -0
- package/dist/chunks/tools-V7OR5KJ3.js.map +7 -0
- package/dist/index.ts +158 -195
- package/dist/index.ts.map +4 -4
- package/package.json +4 -3
- package/src/browser-manager.ts +282 -109
- package/src/browser-settings-menu.ts +98 -4
- package/src/cdp-client.ts +329 -83
- package/src/chrome-devtools.ts +51 -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 +67 -30
- package/src/tools.ts +86 -10
- package/src/webmcp/discovery.ts +235 -0
- package/src/webmcp/policy.ts +402 -0
- package/src/webmcp/protocol.ts +414 -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,235 @@
|
|
|
1
|
+
import { getPage } from "../cdp-client.js";
|
|
2
|
+
import { type DevToolsPage, webMcpOperationIsCurrent } from "../runtime.js";
|
|
3
|
+
import {
|
|
4
|
+
MAX_WEBMCP_TOOLS,
|
|
5
|
+
normalizeWebMcpOutput,
|
|
6
|
+
normalizeWebMcpTool,
|
|
7
|
+
requireMatchingWebMcpTool,
|
|
8
|
+
type WebMcpToolDescriptor,
|
|
9
|
+
type WebMcpToolIdentity,
|
|
10
|
+
webMcpErrorMessage,
|
|
11
|
+
} from "./policy.js";
|
|
12
|
+
import {
|
|
13
|
+
cancelWebMcpInvocation,
|
|
14
|
+
connectWebMcpPage,
|
|
15
|
+
disableWebMcp,
|
|
16
|
+
enableWebMcp,
|
|
17
|
+
enableWebMcpIdentityTracking,
|
|
18
|
+
invokeWebMcpTool,
|
|
19
|
+
readWebMcpFrames,
|
|
20
|
+
requireWebMcpDomain,
|
|
21
|
+
type WebMcpFrame,
|
|
22
|
+
type WebMcpProtocolTool,
|
|
23
|
+
watchWebMcpIdentity,
|
|
24
|
+
} from "./protocol.js";
|
|
25
|
+
|
|
26
|
+
export interface WebMcpOperationIdentity {
|
|
27
|
+
owner: object;
|
|
28
|
+
sessionGeneration: number;
|
|
29
|
+
signal: AbortSignal;
|
|
30
|
+
webMcpGeneration: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface WebMcpInvocationResult {
|
|
34
|
+
invocationId: string;
|
|
35
|
+
output: unknown;
|
|
36
|
+
tool: WebMcpToolDescriptor;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function discoverWebMcpTools(page: DevToolsPage, operation: WebMcpOperationIdentity) {
|
|
40
|
+
assertOperationCurrent(operation);
|
|
41
|
+
await requireWebMcpDomain(operation.signal, operation.owner);
|
|
42
|
+
assertOperationCurrent(operation);
|
|
43
|
+
const client = await connectWebMcpPage(page, operation.signal);
|
|
44
|
+
try {
|
|
45
|
+
const tools = await readInventory(client, page, operation);
|
|
46
|
+
await requireStablePage(client, page, tools, operation);
|
|
47
|
+
return tools;
|
|
48
|
+
} finally {
|
|
49
|
+
await disableWebMcp(client);
|
|
50
|
+
client.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function invokeDiscoveredWebMcpTool(
|
|
55
|
+
page: DevToolsPage,
|
|
56
|
+
expected: WebMcpToolIdentity,
|
|
57
|
+
input: Record<string, unknown>,
|
|
58
|
+
operation: WebMcpOperationIdentity,
|
|
59
|
+
): Promise<WebMcpInvocationResult> {
|
|
60
|
+
assertOperationCurrent(operation);
|
|
61
|
+
await requireWebMcpDomain(operation.signal, operation.owner);
|
|
62
|
+
assertOperationCurrent(operation);
|
|
63
|
+
const client = await connectWebMcpPage(page, operation.signal);
|
|
64
|
+
try {
|
|
65
|
+
await enableWebMcpIdentityTracking(client, operation.signal);
|
|
66
|
+
assertOperationCurrent(operation);
|
|
67
|
+
const tools = await readInventory(client, page, operation);
|
|
68
|
+
const tool = requireMatchingWebMcpTool(tools, expected);
|
|
69
|
+
const identityWatch = watchWebMcpIdentity(
|
|
70
|
+
client,
|
|
71
|
+
{ documentId: tool.documentId, frameId: tool.frameId, toolName: tool.name },
|
|
72
|
+
operation.signal,
|
|
73
|
+
);
|
|
74
|
+
const guardedOperation = { ...operation, signal: identityWatch.signal };
|
|
75
|
+
try {
|
|
76
|
+
await requireStablePage(client, page, [tool], guardedOperation);
|
|
77
|
+
assertOperationCurrent(guardedOperation);
|
|
78
|
+
const response = await invokeWebMcpTool(
|
|
79
|
+
client,
|
|
80
|
+
{ frameId: tool.frameId, toolName: tool.name, input },
|
|
81
|
+
guardedOperation.signal,
|
|
82
|
+
(invocationId) => cancelWebMcpInvocation(page, invocationId),
|
|
83
|
+
);
|
|
84
|
+
assertOperationCurrent(guardedOperation);
|
|
85
|
+
if (response.status === "Canceled") {
|
|
86
|
+
throw new DOMException("Chrome canceled the WebMCP tool invocation.", "AbortError");
|
|
87
|
+
}
|
|
88
|
+
if (response.status === "Error") {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`WebMCP tool failed: ${webMcpErrorMessage(response.errorText ?? remoteExceptionText(response.exception))}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
invocationId: response.invocationId,
|
|
95
|
+
output: normalizeWebMcpOutput(response.output ?? null),
|
|
96
|
+
tool,
|
|
97
|
+
};
|
|
98
|
+
} finally {
|
|
99
|
+
await identityWatch.dispose();
|
|
100
|
+
}
|
|
101
|
+
} finally {
|
|
102
|
+
await disableWebMcp(client);
|
|
103
|
+
client.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function readInventory(
|
|
108
|
+
client: Awaited<ReturnType<typeof connectWebMcpPage>>,
|
|
109
|
+
page: DevToolsPage,
|
|
110
|
+
operation: WebMcpOperationIdentity,
|
|
111
|
+
) {
|
|
112
|
+
const framesBefore = await readWebMcpFrames(client, operation.signal);
|
|
113
|
+
assertOperationCurrent(operation);
|
|
114
|
+
const protocolTools = await enableWebMcp(client, operation.signal);
|
|
115
|
+
assertOperationCurrent(operation);
|
|
116
|
+
if (protocolTools.length > MAX_WEBMCP_TOOLS) {
|
|
117
|
+
throw new Error(`WebMCP page exposes more than ${MAX_WEBMCP_TOOLS} tools.`);
|
|
118
|
+
}
|
|
119
|
+
const framesAfter = await readWebMcpFrames(client, operation.signal);
|
|
120
|
+
assertOperationCurrent(operation);
|
|
121
|
+
requireStableInventoryDocuments(protocolTools, framesBefore, framesAfter);
|
|
122
|
+
return normalizeInventory(protocolTools, framesAfter, page, operation);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function requireStableInventoryDocuments(
|
|
126
|
+
tools: readonly WebMcpProtocolTool[],
|
|
127
|
+
before: readonly WebMcpFrame[],
|
|
128
|
+
after: readonly WebMcpFrame[],
|
|
129
|
+
) {
|
|
130
|
+
const beforeLoaders = new Map(before.map((frame) => [frame.id, frame.loaderId]));
|
|
131
|
+
const afterLoaders = new Map(after.map((frame) => [frame.id, frame.loaderId]));
|
|
132
|
+
for (const frameId of new Set(tools.map((tool) => tool.frameId))) {
|
|
133
|
+
const beforeLoader = beforeLoaders.get(frameId);
|
|
134
|
+
const afterLoader = afterLoaders.get(frameId);
|
|
135
|
+
if (!beforeLoader || !afterLoader || beforeLoader !== afterLoader) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
"A WebMCP frame document changed while Chrome was publishing its tool inventory.",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function normalizeInventory(
|
|
144
|
+
protocolTools: readonly WebMcpProtocolTool[],
|
|
145
|
+
frames: readonly WebMcpFrame[],
|
|
146
|
+
page: DevToolsPage,
|
|
147
|
+
operation: WebMcpOperationIdentity,
|
|
148
|
+
) {
|
|
149
|
+
const frameDocuments = new Map(
|
|
150
|
+
frames.map((frame) => [frame.id, { documentId: frame.loaderId, origin: frameOrigin(frame) }]),
|
|
151
|
+
);
|
|
152
|
+
const tools = protocolTools.map((tool) => {
|
|
153
|
+
const frame = frameDocuments.get(tool.frameId);
|
|
154
|
+
if (!frame) {
|
|
155
|
+
throw new Error(`WebMCP tool ${webMcpErrorMessage(tool.name)} belongs to an unknown frame.`);
|
|
156
|
+
}
|
|
157
|
+
return normalizeWebMcpTool(tool, {
|
|
158
|
+
documentId: frame.documentId,
|
|
159
|
+
frameOrigin: frame.origin,
|
|
160
|
+
pageId: page.id,
|
|
161
|
+
pageUrl: page.url,
|
|
162
|
+
sessionGeneration: generationToken(operation),
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
const identities = new Set<string>();
|
|
166
|
+
for (const tool of tools) {
|
|
167
|
+
const key = `${tool.frameId}\u0000${tool.name}`;
|
|
168
|
+
if (identities.has(key)) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`WebMCP tool identity is duplicated: ${webMcpErrorMessage(tool.name)} in frame ${webMcpErrorMessage(tool.frameId)}.`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
identities.add(key);
|
|
174
|
+
}
|
|
175
|
+
return tools;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function requireStablePage(
|
|
179
|
+
client: Awaited<ReturnType<typeof connectWebMcpPage>>,
|
|
180
|
+
page: DevToolsPage,
|
|
181
|
+
tools: readonly WebMcpToolDescriptor[],
|
|
182
|
+
operation: WebMcpOperationIdentity,
|
|
183
|
+
) {
|
|
184
|
+
const current = await getPage(page.id, {
|
|
185
|
+
sessionOwner: operation.owner,
|
|
186
|
+
signal: operation.signal,
|
|
187
|
+
});
|
|
188
|
+
assertOperationCurrent(operation);
|
|
189
|
+
if (
|
|
190
|
+
current.url !== page.url ||
|
|
191
|
+
current.webSocketDebuggerUrl !== page.webSocketDebuggerUrl ||
|
|
192
|
+
current.type !== page.type
|
|
193
|
+
) {
|
|
194
|
+
throw new Error("The Chrome page navigated or was replaced during the WebMCP operation.");
|
|
195
|
+
}
|
|
196
|
+
const finalFrames = await readWebMcpFrames(client, operation.signal);
|
|
197
|
+
assertOperationCurrent(operation);
|
|
198
|
+
const finalDocuments = new Map(finalFrames.map((frame) => [frame.id, frame.loaderId]));
|
|
199
|
+
for (const tool of tools) {
|
|
200
|
+
if (finalDocuments.get(tool.frameId) !== tool.documentId) {
|
|
201
|
+
throw new Error("A WebMCP frame document changed during the final page identity check.");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function assertOperationCurrent(operation: WebMcpOperationIdentity) {
|
|
207
|
+
operation.signal.throwIfAborted();
|
|
208
|
+
if (!webMcpOperationIsCurrent(operation)) {
|
|
209
|
+
throw new DOMException("The WebMCP operation became stale.", "AbortError");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function generationToken(operation: WebMcpOperationIdentity) {
|
|
214
|
+
return `${operation.sessionGeneration}:${operation.webMcpGeneration}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function frameOrigin(frame: WebMcpFrame) {
|
|
218
|
+
if (frame.securityOrigin && frame.securityOrigin !== "://") return frame.securityOrigin;
|
|
219
|
+
try {
|
|
220
|
+
return new URL(frame.url).origin;
|
|
221
|
+
} catch {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Chrome returned an invalid WebMCP frame URL: ${webMcpErrorMessage(frame.url)}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function remoteExceptionText(value: unknown) {
|
|
229
|
+
if (typeof value === "object" && value !== null) {
|
|
230
|
+
const exception = value as { description?: unknown; value?: unknown };
|
|
231
|
+
if (typeof exception.description === "string") return exception.description;
|
|
232
|
+
if (typeof exception.value === "string") return exception.value;
|
|
233
|
+
}
|
|
234
|
+
return "The page reported an unknown tool error.";
|
|
235
|
+
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
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";
|
|
9
|
+
import type { TSchema } from "typebox";
|
|
10
|
+
import { Check } from "typebox/value";
|
|
11
|
+
import type { WebMcpProtocolAnnotation, WebMcpProtocolTool } from "./protocol.js";
|
|
12
|
+
|
|
13
|
+
export const MAX_WEBMCP_TOOLS = 100;
|
|
14
|
+
const MAX_SCHEMA_BYTES = 64 * 1024;
|
|
15
|
+
const MAX_INPUT_BYTES = 64 * 1024;
|
|
16
|
+
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
17
|
+
const MAX_JSON_DEPTH = 32;
|
|
18
|
+
const MAX_JSON_NODES = 10_000;
|
|
19
|
+
const MAX_OUTPUT_NODES = 100_000;
|
|
20
|
+
const MAX_SCHEMA_VALIDATION_BRANCHES = 128;
|
|
21
|
+
|
|
22
|
+
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;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface WebMcpToolIdentity {
|
|
38
|
+
documentId: string;
|
|
39
|
+
frameId: string;
|
|
40
|
+
frameOrigin: string;
|
|
41
|
+
name: string;
|
|
42
|
+
pageId: string;
|
|
43
|
+
schemaDigest: string;
|
|
44
|
+
sessionGeneration: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function normalizeWebMcpTool(
|
|
48
|
+
tool: WebMcpProtocolTool,
|
|
49
|
+
context: {
|
|
50
|
+
documentId: string;
|
|
51
|
+
frameOrigin: string;
|
|
52
|
+
pageId: string;
|
|
53
|
+
pageUrl: string;
|
|
54
|
+
sessionGeneration: string;
|
|
55
|
+
},
|
|
56
|
+
): 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
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
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
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
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;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
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
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
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;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
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
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function boundedWebMcpDiscovery(
|
|
176
|
+
page: { id: string; title: string; url: string },
|
|
177
|
+
tools: readonly WebMcpToolDescriptor[],
|
|
178
|
+
) {
|
|
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 };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
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 };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function webMcpConfirmationMessage(
|
|
216
|
+
tool: WebMcpToolDescriptor,
|
|
217
|
+
input: Record<string, unknown>,
|
|
218
|
+
managedProfile: boolean,
|
|
219
|
+
) {
|
|
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");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
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))}…`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function webMcpErrorMessage(error: unknown) {
|
|
252
|
+
return sanitizeWebMcpDisplay(error instanceof Error ? error.message : String(error), 8_192);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
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;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
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
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function rejectExpensiveValidationSchema(schema: unknown) {
|
|
308
|
+
let branches = 0;
|
|
309
|
+
const visit = (candidate: unknown): void => {
|
|
310
|
+
if (Array.isArray(candidate)) {
|
|
311
|
+
for (const child of candidate) visit(child);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (!isRecord(candidate)) return;
|
|
315
|
+
for (const keyword of ["allOf", "anyOf", "oneOf"] as const) {
|
|
316
|
+
const alternatives = candidate[keyword];
|
|
317
|
+
if (!Array.isArray(alternatives)) continue;
|
|
318
|
+
branches += alternatives.length;
|
|
319
|
+
if (branches > MAX_SCHEMA_VALIDATION_BRANCHES) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`WebMCP input schema exceeds the validation branch limit of ${MAX_SCHEMA_VALIDATION_BRANCHES}.`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
for (const child of Object.values(candidate)) visit(child);
|
|
326
|
+
};
|
|
327
|
+
visit(schema);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
interface JsonLimits {
|
|
331
|
+
label: string;
|
|
332
|
+
maxBytes: number;
|
|
333
|
+
maxDepth: number;
|
|
334
|
+
maxNodes: number;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function canonicalJsonObject(value: Record<string, unknown>, limits: JsonLimits) {
|
|
338
|
+
const canonical = canonicalJsonValue(value, limits);
|
|
339
|
+
if (!isRecord(canonical)) throw new Error(`${limits.label} must be a JSON object.`);
|
|
340
|
+
return canonical;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function canonicalJsonValue(value: unknown, limits: JsonLimits) {
|
|
344
|
+
let nodes = 0;
|
|
345
|
+
const visit = (candidate: unknown, depth: number): unknown => {
|
|
346
|
+
nodes += 1;
|
|
347
|
+
if (nodes > limits.maxNodes) {
|
|
348
|
+
throw new Error(`${limits.label} exceeds the ${limits.maxNodes} value limit.`);
|
|
349
|
+
}
|
|
350
|
+
if (depth > limits.maxDepth) {
|
|
351
|
+
throw new Error(`${limits.label} exceeds the depth limit of ${limits.maxDepth}.`);
|
|
352
|
+
}
|
|
353
|
+
if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean") {
|
|
354
|
+
return candidate;
|
|
355
|
+
}
|
|
356
|
+
if (typeof candidate === "number") {
|
|
357
|
+
if (!Number.isFinite(candidate))
|
|
358
|
+
throw new Error(`${limits.label} contains a non-finite number.`);
|
|
359
|
+
return candidate;
|
|
360
|
+
}
|
|
361
|
+
if (Array.isArray(candidate)) return candidate.map((entry) => visit(entry, depth + 1));
|
|
362
|
+
if (isRecord(candidate)) {
|
|
363
|
+
return Object.fromEntries(
|
|
364
|
+
Object.keys(candidate)
|
|
365
|
+
.sort()
|
|
366
|
+
.map((key) => [key, visit(candidate[key], depth + 1)]),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
throw new Error(`${limits.label} contains a value that is not JSON-compatible.`);
|
|
370
|
+
};
|
|
371
|
+
const canonical = visit(value, 0);
|
|
372
|
+
const serialized = canonicalStringify(canonical);
|
|
373
|
+
const bytes = Buffer.byteLength(serialized, "utf8");
|
|
374
|
+
if (bytes > limits.maxBytes) {
|
|
375
|
+
throw new Error(
|
|
376
|
+
`${limits.label} exceeds ${formatSize(limits.maxBytes)} (${formatSize(bytes)}).`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
return canonical;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function canonicalStringify(value: unknown) {
|
|
383
|
+
const serialized = JSON.stringify(value);
|
|
384
|
+
if (serialized === undefined) throw new Error("Value is not JSON-compatible.");
|
|
385
|
+
return serialized;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function boundedString(
|
|
389
|
+
value: string,
|
|
390
|
+
label: string,
|
|
391
|
+
maxBytes: number,
|
|
392
|
+
options: { nonEmpty?: boolean } = {},
|
|
393
|
+
) {
|
|
394
|
+
if (options.nonEmpty && value.length === 0) throw new Error(`WebMCP ${label} must not be empty.`);
|
|
395
|
+
const bytes = Buffer.byteLength(value, "utf8");
|
|
396
|
+
if (bytes > maxBytes) throw new Error(`WebMCP ${label} exceeds ${formatSize(maxBytes)}.`);
|
|
397
|
+
return value;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
401
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
402
|
+
}
|