webmcpx 0.1.0 → 0.2.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 +60 -3
- package/agent/browser.js +215 -0
- package/agent/mcp.js +233 -0
- package/agent/page.js +304 -0
- package/agent/session.js +137 -0
- package/agent/standard.js +57 -0
- package/bin/webmcpx.js +158 -0
- package/package.json +56 -8
- package/src/auto.js +10 -2
- package/src/generated.js +8 -0
- package/src/index.d.ts +40 -0
- package/src/index.js +3 -139
- package/src/loader.js +133 -0
- package/src/react.d.ts +4 -0
- package/src/react.js +17 -0
- package/src/svelte.d.ts +4 -0
- package/src/svelte.js +8 -0
- package/src/tool.js +271 -0
- package/src/vue.d.ts +4 -0
- package/src/vue.js +11 -0
package/agent/page.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// One tab in the agent's browser. Before any page script runs, it installs the W3C WebMCP polyfill
|
|
2
|
+
// (https://github.com/w3c-oss/webmcp-polyfill), so a site's own registerTool() calls work in any
|
|
3
|
+
// Chromium, with no flag. Nothing else is injected, and the page's CSP stays on. Tools are listed and
|
|
4
|
+
// called only through the spec's consumer methods, document.modelContext.getTools() and executeTool(),
|
|
5
|
+
// on the very descriptors getTools() returned (they keep the owning frame).
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { READ_PAGE } from "./standard.js";
|
|
9
|
+
|
|
10
|
+
const POLYFILL = readFileSync(fileURLToPath(new URL("polyfill.js", import.meta.resolve("@w3c/webmcp-polyfill"))), "utf8");
|
|
11
|
+
const CHANGED = "__webmcpxToolsChanged";
|
|
12
|
+
// Runs in every new document, before the page's scripts. An iframe's initial about:blank is special:
|
|
13
|
+
// when it navigates to a same-origin page, Chrome keeps its Window and does not run this again, and a
|
|
14
|
+
// polyfill installed in the blank document would talk to frames through state tied to that discarded
|
|
15
|
+
// document (no discovery across frames). So there it installs on first use, from the real document.
|
|
16
|
+
const BOOT = `(() => {
|
|
17
|
+
const install = () => {
|
|
18
|
+
${POLYFILL}
|
|
19
|
+
};
|
|
20
|
+
if (window !== window.top && document.URL === "about:blank") {
|
|
21
|
+
const proto = Document.prototype;
|
|
22
|
+
Object.defineProperty(proto, "modelContext", {
|
|
23
|
+
configurable: true,
|
|
24
|
+
enumerable: true,
|
|
25
|
+
get() {
|
|
26
|
+
delete proto.modelContext;
|
|
27
|
+
install();
|
|
28
|
+
return this.modelContext;
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
install();
|
|
34
|
+
const mc = document.modelContext;
|
|
35
|
+
if (window === window.top && typeof mc?.addEventListener === "function" && typeof ${CHANGED} === "function") {
|
|
36
|
+
mc.addEventListener("toolchange", () => ${CHANGED}(""));
|
|
37
|
+
}
|
|
38
|
+
})();`;
|
|
39
|
+
|
|
40
|
+
const GET_TOOLS = `(async () => {
|
|
41
|
+
const mc = document.modelContext;
|
|
42
|
+
return typeof mc?.getTools === "function" ? await mc.getTools() : null;
|
|
43
|
+
})()`;
|
|
44
|
+
|
|
45
|
+
// Called on the array getTools() returned: plain summaries, by index.
|
|
46
|
+
function summarize() {
|
|
47
|
+
const parse = (s) => {
|
|
48
|
+
try {
|
|
49
|
+
return typeof s === "string" ? JSON.parse(s) : s;
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
// The frame that owns each tool (the spec's RegisteredTool.window), as a number that stays the same
|
|
55
|
+
// while the page is open: same-named tools in two frames are two tools.
|
|
56
|
+
// Frame ids are scoped to this document: after a navigation, a frame is never mistaken for an older
|
|
57
|
+
// one. The page itself is 0, so a site's own tools keep their names from visit to visit.
|
|
58
|
+
const owners = (globalThis.__webmcpxOwners ??= { ids: new WeakMap(), next: 1, doc: Math.random().toString(36).slice(2) });
|
|
59
|
+
const owner = (w) => {
|
|
60
|
+
if (!w || w === window) return "0";
|
|
61
|
+
try {
|
|
62
|
+
if (!owners.ids.has(w)) owners.ids.set(w, `${owners.doc}.${owners.next++}`);
|
|
63
|
+
return owners.ids.get(w);
|
|
64
|
+
} catch {
|
|
65
|
+
return "?";
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
return this.map((t, index) => ({
|
|
69
|
+
index,
|
|
70
|
+
name: t.name,
|
|
71
|
+
description: t.description ?? "",
|
|
72
|
+
inputSchema: parse(t.inputSchema),
|
|
73
|
+
annotations: { ...(t.annotations ?? {}) },
|
|
74
|
+
origin: t.origin,
|
|
75
|
+
owner: owner(t.window),
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Called on the same array: runs one descriptor, cancellable through `cancel` below. executeTool() returns
|
|
80
|
+
// the result JSON-serialized; a tool that returned a string (like the webmcpx envelope) comes back as a
|
|
81
|
+
// JSON string literal: unwrap it once.
|
|
82
|
+
async function execute(index, input, call) {
|
|
83
|
+
const controller = new AbortController();
|
|
84
|
+
(this.__webmcpxCalls ??= new Map()).set(call, controller);
|
|
85
|
+
let out;
|
|
86
|
+
try {
|
|
87
|
+
out = await document.modelContext.executeTool(this[index], input, { signal: controller.signal });
|
|
88
|
+
} finally {
|
|
89
|
+
this.__webmcpxCalls.delete(call);
|
|
90
|
+
}
|
|
91
|
+
let value = out;
|
|
92
|
+
try {
|
|
93
|
+
value = typeof out === "string" ? JSON.parse(out) : out;
|
|
94
|
+
} catch {
|
|
95
|
+
// not JSON: keep the text
|
|
96
|
+
}
|
|
97
|
+
return typeof value === "string" ? value : JSON.stringify(value ?? null);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function cancel(call) {
|
|
101
|
+
this.__webmcpxCalls?.get(call)?.abort();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const GROUP = "webmcpx-tools";
|
|
105
|
+
const LOAD_TIMEOUT = 30_000;
|
|
106
|
+
const CALL_TIMEOUT = 60_000;
|
|
107
|
+
/** How long a cancellation may take before the tab counts as frozen. */
|
|
108
|
+
const CANCEL_WAIT = 2_000;
|
|
109
|
+
const SETTLE = { min: 400, max: 3_000, every: 200 };
|
|
110
|
+
// Unref'd: a pending timeout must never keep the CLI alive after its work is done.
|
|
111
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref());
|
|
112
|
+
const timeout = (ms, what) =>
|
|
113
|
+
new Promise((_, reject) => {
|
|
114
|
+
setTimeout(() => reject(new Error(`${what} took longer than ${ms / 1000} s`)), ms).unref();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/** The tool's list is gone (the page navigated since it was read): the call never started. */
|
|
118
|
+
export const stale = (e) => /Could not find object|Cannot find context/i.test(e?.message);
|
|
119
|
+
/** The page navigated while the tool ran: it may have done its work. */
|
|
120
|
+
const interrupted = (e) => /context was destroyed|Inspected target navigated|Execution context/i.test(e?.message);
|
|
121
|
+
|
|
122
|
+
export class Page {
|
|
123
|
+
#cdp;
|
|
124
|
+
#session;
|
|
125
|
+
#target;
|
|
126
|
+
#unsubscribe;
|
|
127
|
+
#onChange;
|
|
128
|
+
#calls = 0;
|
|
129
|
+
/** Set when the page stopped answering: the session must not use this tab again. */
|
|
130
|
+
broken = false;
|
|
131
|
+
#callTimeout = CALL_TIMEOUT;
|
|
132
|
+
#loads = new Set();
|
|
133
|
+
#handles = [];
|
|
134
|
+
#debounce;
|
|
135
|
+
|
|
136
|
+
/** Opens a new tab. `onChange()` fires when the page's tools may have changed on their own. */
|
|
137
|
+
/** `callTimeout` (ms) bounds one tool call; tests shorten it. */
|
|
138
|
+
static async create(cdp, { onChange = () => {}, callTimeout = CALL_TIMEOUT } = {}) {
|
|
139
|
+
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
|
|
140
|
+
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
|
|
141
|
+
const page = new Page(cdp, sessionId, targetId, onChange);
|
|
142
|
+
page.#callTimeout = callTimeout;
|
|
143
|
+
await page.#init();
|
|
144
|
+
return page;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
constructor(cdp, session, target, onChange) {
|
|
148
|
+
this.#cdp = cdp;
|
|
149
|
+
this.#session = session;
|
|
150
|
+
this.#target = target;
|
|
151
|
+
this.#onChange = onChange;
|
|
152
|
+
this.#unsubscribe = cdp.on((msg) => msg.sessionId === session && this.#event(msg));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Closes this tab (in a browser we attached to, it is the only thing we opened there). */
|
|
156
|
+
async close() {
|
|
157
|
+
clearTimeout(this.#debounce);
|
|
158
|
+
this.#unsubscribe();
|
|
159
|
+
await this.#cdp.send("Target.closeTarget", { targetId: this.#target }).catch(() => {});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#send(method, params) {
|
|
163
|
+
return this.#cdp.send(method, params, this.#session);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async #init() {
|
|
167
|
+
await Promise.all([this.#send("Page.enable"), this.#send("Runtime.enable")]);
|
|
168
|
+
await this.#send("Runtime.addBinding", { name: CHANGED });
|
|
169
|
+
await this.#send("Page.addScriptToEvaluateOnNewDocument", { source: BOOT });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#event({ method, params }) {
|
|
173
|
+
if (method === "Page.javascriptDialogOpening") {
|
|
174
|
+
// An alert() would block every later call: dismiss it, like a user closing it.
|
|
175
|
+
this.#send("Page.handleJavaScriptDialog", { accept: false }).catch(() => {});
|
|
176
|
+
} else if (method === "Page.loadEventFired") {
|
|
177
|
+
for (const resolve of this.#loads) resolve();
|
|
178
|
+
this.#loads.clear();
|
|
179
|
+
this.#changed(); // a new document: a new tool list
|
|
180
|
+
} else if (method === "Runtime.bindingCalled" && params.name === CHANGED) {
|
|
181
|
+
this.#changed();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#changed() {
|
|
186
|
+
clearTimeout(this.#debounce);
|
|
187
|
+
this.#debounce = setTimeout(() => this.#onChange(), 150);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async #evaluate(expression, ms = CALL_TIMEOUT) {
|
|
191
|
+
const run = this.#send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
|
|
192
|
+
const { result, exceptionDetails } = await Promise.race([run, timeout(ms, "the page")]);
|
|
193
|
+
if (exceptionDetails) throw new Error(describeException(exceptionDetails));
|
|
194
|
+
return result.value;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Navigates and waits for the tool list to settle.
|
|
199
|
+
* @returns {Promise<{url: string, title: string, tools: PageTool[] | null}>} `tools` is null without WebMCP
|
|
200
|
+
* (outside a secure context).
|
|
201
|
+
*/
|
|
202
|
+
async open(url) {
|
|
203
|
+
// The old document's tool lists die with it.
|
|
204
|
+
await this.#send("Runtime.releaseObjectGroup", { objectGroup: GROUP }).catch(() => {});
|
|
205
|
+
this.#handles = [];
|
|
206
|
+
const loaded = new Promise((resolve) => this.#loads.add(resolve));
|
|
207
|
+
const nav = await this.#send("Page.navigate", { url });
|
|
208
|
+
if (nav.errorText) throw new Error(`could not open ${url}: ${nav.errorText}`);
|
|
209
|
+
if (nav.loaderId) await Promise.race([loaded, wait(LOAD_TIMEOUT)]);
|
|
210
|
+
const tools = await this.#settle();
|
|
211
|
+
const { url: at, title } = await this.#evaluate("({ url: location.href, title: document.title })");
|
|
212
|
+
return { url: at, title, tools };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Polls until the tool list has been stable for a moment (sites often register after load). */
|
|
216
|
+
async #settle() {
|
|
217
|
+
const start = Date.now();
|
|
218
|
+
const names = (list) => JSON.stringify(list?.map((t) => t.name));
|
|
219
|
+
let last = await this.tools();
|
|
220
|
+
while (Date.now() - start < SETTLE.max) {
|
|
221
|
+
await wait(SETTLE.every);
|
|
222
|
+
const now = await this.tools();
|
|
223
|
+
const same = names(now) === names(last);
|
|
224
|
+
last = now;
|
|
225
|
+
if (same && Date.now() - start >= SETTLE.min) break;
|
|
226
|
+
}
|
|
227
|
+
return last;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Reads the page's tools. Each keeps the `index` of its descriptor in this list, which `call` runs:
|
|
232
|
+
* same-named tools in different frames stay distinct. Null without `document.modelContext`.
|
|
233
|
+
* @returns {Promise<PageTool[] | null>}
|
|
234
|
+
*/
|
|
235
|
+
async tools() {
|
|
236
|
+
const run = this.#send("Runtime.evaluate", { expression: GET_TOOLS, awaitPromise: true, objectGroup: GROUP });
|
|
237
|
+
const { result, exceptionDetails } = await Promise.race([run, timeout(CALL_TIMEOUT, "the page")]);
|
|
238
|
+
if (exceptionDetails) throw new Error(describeException(exceptionDetails));
|
|
239
|
+
if (!result.objectId) return null;
|
|
240
|
+
const { result: list } = await this.#send("Runtime.callFunctionOn", {
|
|
241
|
+
objectId: result.objectId,
|
|
242
|
+
functionDeclaration: summarize.toString(),
|
|
243
|
+
returnByValue: true,
|
|
244
|
+
});
|
|
245
|
+
// Each tool keeps the very list it came from: a call runs on that descriptor, so it reaches the
|
|
246
|
+
// owning frame or fails if that owner unregistered it; it never shifts to another tool.
|
|
247
|
+
this.#handles.push(result.objectId);
|
|
248
|
+
if (this.#handles.length > 50) {
|
|
249
|
+
this.#send("Runtime.releaseObject", { objectId: this.#handles.shift() }).catch(() => {});
|
|
250
|
+
}
|
|
251
|
+
return list.value.map((tool) => ({ ...tool, list: result.objectId }));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Runs a tool on the list it came from; resolves with its result text. A tool past the time limit is
|
|
256
|
+
* cancelled (its AbortSignal fires); a page that navigates mid-call reports that the tool may have run.
|
|
257
|
+
*/
|
|
258
|
+
async call(tool, input) {
|
|
259
|
+
const call = ++this.#calls;
|
|
260
|
+
const run = this.#send("Runtime.callFunctionOn", {
|
|
261
|
+
objectId: tool.list,
|
|
262
|
+
functionDeclaration: execute.toString(),
|
|
263
|
+
arguments: [{ value: tool.index }, { value: input ?? {} }, { value: call }],
|
|
264
|
+
awaitPromise: true,
|
|
265
|
+
returnByValue: true,
|
|
266
|
+
userGesture: true,
|
|
267
|
+
});
|
|
268
|
+
let reply;
|
|
269
|
+
try {
|
|
270
|
+
reply = await Promise.race([run, timeout(this.#callTimeout, "the tool")]);
|
|
271
|
+
} catch (e) {
|
|
272
|
+
if (stale(e)) throw e;
|
|
273
|
+
if (interrupted(e)) {
|
|
274
|
+
throw new Error(`the page navigated while "${tool.name}" ran: it may have done its work, so check before calling it again`);
|
|
275
|
+
}
|
|
276
|
+
if (!/took longer than/.test(e?.message)) throw e;
|
|
277
|
+
// Ask the tool to stop. A page whose script is stuck can't even take that: then the tab is broken,
|
|
278
|
+
// and the session replaces it (see Session.call).
|
|
279
|
+
const cancelled = this.#send("Runtime.callFunctionOn", {
|
|
280
|
+
objectId: tool.list,
|
|
281
|
+
functionDeclaration: cancel.toString(),
|
|
282
|
+
arguments: [{ value: call }],
|
|
283
|
+
});
|
|
284
|
+
const answered = await Promise.race([cancelled.then(() => true, () => true), wait(CANCEL_WAIT).then(() => false)]);
|
|
285
|
+
if (!answered) {
|
|
286
|
+
this.broken = true;
|
|
287
|
+
throw new Error(`"${tool.name}" took longer than ${this.#callTimeout / 1000} s and froze the page; it may have partly run`);
|
|
288
|
+
}
|
|
289
|
+
throw new Error(`"${tool.name}" took longer than ${this.#callTimeout / 1000} s and was cancelled; it may have partly run`);
|
|
290
|
+
}
|
|
291
|
+
const { result, exceptionDetails } = reply;
|
|
292
|
+
if (exceptionDetails) throw new Error(describeException(exceptionDetails));
|
|
293
|
+
return result.value;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The loader's `readPage(what)`, evaluated in the page (for the standard tools). */
|
|
297
|
+
read(what) {
|
|
298
|
+
return this.#evaluate(`${READ_PAGE}(${JSON.stringify(what)})`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const describeException = (d) => d.exception?.description?.split("\n")[0] ?? d.text;
|
|
303
|
+
|
|
304
|
+
/** @typedef {{index: number, list: string, name: string, description: string, inputSchema?: object, annotations: Record<string, boolean>, origin?: string, owner?: string}} PageTool */
|
package/agent/session.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// What the CLI and the MCP server share: one browser (started on first use), one tab, and the tool list
|
|
2
|
+
// the agent sees: the page's own tools plus the standard ones the page does not already have.
|
|
3
|
+
import { attach, launch } from "./browser.js";
|
|
4
|
+
import { Page, stale } from "./page.js";
|
|
5
|
+
import { runStandard, standardTools } from "./standard.js";
|
|
6
|
+
|
|
7
|
+
export class Session {
|
|
8
|
+
#options;
|
|
9
|
+
#browser;
|
|
10
|
+
#page;
|
|
11
|
+
#ready;
|
|
12
|
+
#closed = false;
|
|
13
|
+
#queue = Promise.resolve();
|
|
14
|
+
|
|
15
|
+
/** @param {{headed?: boolean, browser?: string, cdp?: string, onChange?: () => void, callTimeout?: number}} options */
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
this.#options = options;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#start() {
|
|
21
|
+
if (this.#closed) return Promise.reject(new Error("the session is closed"));
|
|
22
|
+
this.#ready ??= (async () => {
|
|
23
|
+
const { cdp, headed, browser, onChange, callTimeout } = this.#options;
|
|
24
|
+
const opened = cdp ? await attach(cdp) : await launch({ headed, executable: browser });
|
|
25
|
+
// Closed while the browser was starting: shut it instead of leaving it running.
|
|
26
|
+
if (this.#closed) {
|
|
27
|
+
opened.close();
|
|
28
|
+
throw new Error("the session is closed");
|
|
29
|
+
}
|
|
30
|
+
this.#browser = opened;
|
|
31
|
+
// A browser that quits or crashes is started again on the next page.
|
|
32
|
+
opened.cdp.onClose(() => {
|
|
33
|
+
if (this.#browser === opened) [this.#browser, this.#ready, this.#page] = [];
|
|
34
|
+
});
|
|
35
|
+
return Page.create(opened.cdp, { onChange, callTimeout });
|
|
36
|
+
})().catch((e) => {
|
|
37
|
+
this.#ready = undefined;
|
|
38
|
+
throw e;
|
|
39
|
+
});
|
|
40
|
+
return this.#ready;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Runs page work one at a time: there is one tab, so a navigation must not interleave with a call. */
|
|
44
|
+
#serial(work) {
|
|
45
|
+
const run = this.#queue.then(work);
|
|
46
|
+
this.#queue = run.catch(() => {});
|
|
47
|
+
return run;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @returns {Promise<{url: string, title: string, tools: Tool[] | null}>} */
|
|
51
|
+
goto(url) {
|
|
52
|
+
return this.#serial(async () => {
|
|
53
|
+
this.#page = await this.#start();
|
|
54
|
+
const page = await this.#page.open(normalize(url));
|
|
55
|
+
return { ...page, tools: page.tools && withStandard(page.tools) };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The current tool list (empty before a page is open, or without WebMCP). */
|
|
60
|
+
tools() {
|
|
61
|
+
return this.#serial(async () => (this.#page ? withStandard((await this.#page.tools()) ?? []) : []));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Calls a tool from a list the page gave; one from a page that has since navigated is refused. */
|
|
65
|
+
call(tool, input) {
|
|
66
|
+
return this.#serial(async () => {
|
|
67
|
+
if (!this.#page) throw new Error("no page is open yet: call open_page first");
|
|
68
|
+
const page = this.#page;
|
|
69
|
+
if (tool.standard) return runStandard(tool.name, input, (what) => page.read(what));
|
|
70
|
+
try {
|
|
71
|
+
return await page.call(tool, input);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
// A frozen page can't be used again: drop the browser (or, attached, our tab); the next page
|
|
74
|
+
// starts fresh instead of waiting on it forever.
|
|
75
|
+
if (page.broken) await this.#reset();
|
|
76
|
+
// The tool's list is gone only when the page navigated: never guess which new tool is "the same".
|
|
77
|
+
if (stale(e)) throw new Error(`the page changed since "${tool.name}" was listed: open it again or use the new tools`);
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async #reset() {
|
|
84
|
+
const [browser, ready] = [this.#browser, this.#ready];
|
|
85
|
+
[this.#browser, this.#ready, this.#page] = [];
|
|
86
|
+
const page = await ready?.catch(() => undefined);
|
|
87
|
+
await Promise.race([page?.close(), new Promise((resolve) => setTimeout(resolve, 2_000).unref())]);
|
|
88
|
+
browser?.close();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Closes our tab and the browser (or only our tab's connection, in a browser we attached to). */
|
|
92
|
+
async close() {
|
|
93
|
+
this.#closed = true;
|
|
94
|
+
const page = await this.#ready?.catch(() => undefined);
|
|
95
|
+
await page?.close();
|
|
96
|
+
this.#browser?.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The page's tools, then each standard tool the page does not already register. */
|
|
101
|
+
function withStandard(tools) {
|
|
102
|
+
const names = new Set(tools.map((t) => t.name));
|
|
103
|
+
return [...tools, ...standardTools().filter((t) => !names.has(t.name))];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `example.com` → `https://example.com`; `localhost:3000` → `http://localhost:3000`. */
|
|
107
|
+
export function normalize(url) {
|
|
108
|
+
const raw = String(url ?? "").trim();
|
|
109
|
+
if (!raw) throw new Error("give a URL, e.g. https://example.com or http://localhost:3000");
|
|
110
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || raw.startsWith("about:") || raw.startsWith("file:")) return raw;
|
|
111
|
+
const local = /^(localhost|127\.|\[::1\]|0\.0\.0\.0)/.test(raw);
|
|
112
|
+
return `${local ? "http" : "https"}://${raw}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Whether a tool result reports failure: a webmcpx envelope (`"ok": false`) or an MCP result (`"isError": true`). */
|
|
116
|
+
export function failed(text) {
|
|
117
|
+
try {
|
|
118
|
+
const value = JSON.parse(text);
|
|
119
|
+
return value?.ok === false || value?.isError === true;
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A result some sites already shape like MCP (`{content: [...]}`, e.g. Shopify), or undefined. */
|
|
126
|
+
export function mcpResult(text) {
|
|
127
|
+
try {
|
|
128
|
+
const value = JSON.parse(text);
|
|
129
|
+
if (!Array.isArray(value?.content)) return undefined;
|
|
130
|
+
const { content, structuredContent, isError } = value;
|
|
131
|
+
return { content, ...(structuredContent && { structuredContent }), ...(isError === true && { isError }) };
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @typedef {import("./page.js").PageTool & {standard?: boolean}} Tool */
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The webmcpx standard tools, run by the agent bridge itself: the page is only read (through the
|
|
2
|
+
// loader's own `readPage`, evaluated by DevTools, which the page's CSP does not apply to) and the text
|
|
3
|
+
// engine runs here in Node. Nothing is injected into the page besides the W3C polyfill.
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { CODES, TOOLS } from "../src/generated.js";
|
|
6
|
+
import { readPage } from "../src/loader.js";
|
|
7
|
+
|
|
8
|
+
export const STANDARD = ["get_page_context", "search_page", "fetch_as_markdown"];
|
|
9
|
+
|
|
10
|
+
/** Source of a page expression: `${READ_PAGE}("blocks")`. */
|
|
11
|
+
export const READ_PAGE = `(${readPage})`;
|
|
12
|
+
|
|
13
|
+
let engine;
|
|
14
|
+
const load = () =>
|
|
15
|
+
(engine ??= import("../wasm/webmcpx_wasm.js")
|
|
16
|
+
.then(async (m) => {
|
|
17
|
+
await m.default({ module_or_path: readFileSync(new URL("../wasm/webmcpx_wasm_bg.wasm", import.meta.url)) });
|
|
18
|
+
return m;
|
|
19
|
+
})
|
|
20
|
+
.catch((e) => {
|
|
21
|
+
engine = undefined;
|
|
22
|
+
throw e;
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const envelope = (body) => JSON.stringify(body);
|
|
26
|
+
const err = (code, message) => envelope({ ok: false, error: { code, message, retryable: CODES[code] } });
|
|
27
|
+
|
|
28
|
+
/** The standard tools as page-tool descriptors (`standard: true`), for listing next to the site's. */
|
|
29
|
+
export const standardTools = () =>
|
|
30
|
+
STANDARD.map((name) => {
|
|
31
|
+
const { description, inputSchema, annotations } = TOOLS[name];
|
|
32
|
+
return { name, description, inputSchema, annotations: { ...annotations }, standard: true };
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Runs a standard tool. `read(what)` returns `readPage(what)` evaluated in the page.
|
|
37
|
+
* Like the in-page tools, it never rejects: any input and any failure yield an envelope.
|
|
38
|
+
*/
|
|
39
|
+
export async function runStandard(name, input, read) {
|
|
40
|
+
const args = input && typeof input === "object" && !Array.isArray(input) ? input : {};
|
|
41
|
+
try {
|
|
42
|
+
if (name === "get_page_context") return envelope({ ok: true, data: await read("context") });
|
|
43
|
+
if (name === "search_page" && typeof args.query !== "string") return err("INVALID_INPUT", "`query` must be a string");
|
|
44
|
+
const page = await read("blocks");
|
|
45
|
+
let m;
|
|
46
|
+
try {
|
|
47
|
+
m = await load();
|
|
48
|
+
} catch (e) {
|
|
49
|
+
return err("UNAVAILABLE", `the text engine could not load: ${e?.message ?? e}`);
|
|
50
|
+
}
|
|
51
|
+
if (name === "search_page") return m.search(page, args.query, args.limit ?? 0);
|
|
52
|
+
if (name === "fetch_as_markdown") return m.markdown(page, String(args.cursor ?? ""), args.max_chars ?? 0);
|
|
53
|
+
return err("NOT_FOUND", `no standard tool named ${name}`);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
return err("UNAVAILABLE", String(e?.message ?? e));
|
|
56
|
+
}
|
|
57
|
+
}
|
package/bin/webmcpx.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// npx webmcpx: WebMCP for any agent, in any Chromium, today.
|
|
3
|
+
// webmcpx mcp MCP server: your agent calls the tools of any page it opens
|
|
4
|
+
// webmcpx tools <url> list a page's WebMCP tools (great on localhost)
|
|
5
|
+
// webmcpx call <url> <tool> [json] call one and print the result
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { describe, named, serve } from "../agent/mcp.js";
|
|
8
|
+
import { Session, failed, mcpResult } from "../agent/session.js";
|
|
9
|
+
|
|
10
|
+
const tty = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
11
|
+
const paint = (code) => (s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
12
|
+
const bold = paint(1);
|
|
13
|
+
const dim = paint(2);
|
|
14
|
+
const green = paint(32);
|
|
15
|
+
const yellow = paint(33);
|
|
16
|
+
|
|
17
|
+
const HELP = `${bold("webmcpx")}: WebMCP for any agent, in any Chromium, today.
|
|
18
|
+
|
|
19
|
+
${bold("Give your agent the tools of every page it opens")}
|
|
20
|
+
Claude Code claude mcp add webmcpx -- npx -y webmcpx mcp
|
|
21
|
+
Codex codex mcp add webmcpx -- npx -y webmcpx mcp
|
|
22
|
+
Cursor, VS Code, Windsurf (mcp.json):
|
|
23
|
+
{ "mcpServers": { "webmcpx": { "command": "npx", "args": ["-y", "webmcpx", "mcp"] } } }
|
|
24
|
+
|
|
25
|
+
${bold("Check your own site's tools")} ${dim("(no Chrome flags)")}
|
|
26
|
+
npx webmcpx tools localhost:3000
|
|
27
|
+
npx webmcpx call localhost:3000 search_page '{"query": "pricing"}'
|
|
28
|
+
|
|
29
|
+
${bold("Commands")}
|
|
30
|
+
mcp MCP server over stdio
|
|
31
|
+
tools <url> list the page's WebMCP tools
|
|
32
|
+
call <url> <tool> [json] call one tool and print its result
|
|
33
|
+
|
|
34
|
+
${bold("Options")}
|
|
35
|
+
--headed show the browser window
|
|
36
|
+
--browser <path> Chrome, Chromium, Edge or Brave to use (or WEBMCPX_CHROME)
|
|
37
|
+
--cdp <url> use a browser you started with --remote-debugging-port
|
|
38
|
+
|
|
39
|
+
Every page gets search_page, fetch_as_markdown and get_page_context, plus the site's own tools.
|
|
40
|
+
The linter is the Rust CLI: cargo install webmcpx. Docs: https://webmcpx.dev`;
|
|
41
|
+
|
|
42
|
+
const usage = (message) => {
|
|
43
|
+
console.error(`${message}\n\nRun "npx webmcpx --help" for usage.`);
|
|
44
|
+
process.exit(2);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function parse(argv) {
|
|
48
|
+
try {
|
|
49
|
+
return parseArgs({
|
|
50
|
+
args: argv,
|
|
51
|
+
allowPositionals: true,
|
|
52
|
+
options: {
|
|
53
|
+
headed: { type: "boolean" },
|
|
54
|
+
browser: { type: "string" },
|
|
55
|
+
cdp: { type: "string" },
|
|
56
|
+
help: { type: "boolean", short: "h" },
|
|
57
|
+
version: { type: "boolean", short: "v" },
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return usage(e.message);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const firstLine = (s, max = 72) => {
|
|
66
|
+
const line = String(s ?? "").split("\n")[0];
|
|
67
|
+
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function printTools(page) {
|
|
71
|
+
if (page.tools === null) return console.log(describe(page));
|
|
72
|
+
const own = page.tools.filter((t) => !t.standard);
|
|
73
|
+
const standard = page.tools.filter((t) => t.standard);
|
|
74
|
+
const width = Math.max(0, ...page.tools.map((t) => t.name.length)) + 2;
|
|
75
|
+
const row = (t) => {
|
|
76
|
+
const a = t.annotations ?? {};
|
|
77
|
+
const tag = a.readOnlyHint ? dim("read-only ") : a.consequentialHint ? yellow("acts ") : " ";
|
|
78
|
+
return ` ${bold(t.name.padEnd(width))}${tag}${dim(firstLine(t.description))}`;
|
|
79
|
+
};
|
|
80
|
+
console.log(`${bold(page.title || page.url)} ${dim(page.url)}\n`);
|
|
81
|
+
if (own.length) {
|
|
82
|
+
console.log(green(`✓ ${own.length} WebMCP tool${own.length === 1 ? "" : "s"} registered by the site`));
|
|
83
|
+
for (const t of own) console.log(row(t));
|
|
84
|
+
} else {
|
|
85
|
+
console.log(yellow("○ The site registers no WebMCP tools of its own.") + dim(" Add them: https://webmcpx.dev/#fix"));
|
|
86
|
+
}
|
|
87
|
+
if (standard.length) {
|
|
88
|
+
console.log(`\n${dim("+ standard tools webmcpx adds to every page")}`);
|
|
89
|
+
for (const t of standard) console.log(row(t));
|
|
90
|
+
}
|
|
91
|
+
console.log(dim(`\nTry one: npx webmcpx call ${page.url} search_page '{"query": "..."}'`));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// An MCP client stops its server with a signal: exit through process.exit, so the browser we started
|
|
95
|
+
// is killed by its exit hook instead of being orphaned.
|
|
96
|
+
for (const [signal, code] of [["SIGINT", 130], ["SIGTERM", 143], ["SIGHUP", 129]]) {
|
|
97
|
+
process.once(signal, () => process.exit(code));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function main() {
|
|
101
|
+
const { values, positionals } = parse(process.argv.slice(2));
|
|
102
|
+
const [command, ...rest] = positionals;
|
|
103
|
+
if (values.version) {
|
|
104
|
+
const { readFileSync } = await import("node:fs");
|
|
105
|
+
return console.log(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
|
|
106
|
+
}
|
|
107
|
+
if (values.help || !command || command === "help") return console.log(HELP);
|
|
108
|
+
const options = { headed: values.headed, browser: values.browser, cdp: values.cdp };
|
|
109
|
+
|
|
110
|
+
if (command === "mcp") return serve(options);
|
|
111
|
+
if (command === "lint") usage("The linter is the Rust CLI: cargo install webmcpx, then webmcpx lint <dir>.");
|
|
112
|
+
if (command !== "tools" && command !== "call") usage(`Unknown command "${command}".`);
|
|
113
|
+
|
|
114
|
+
const [url, name, json] = rest;
|
|
115
|
+
if (!url) usage(`Give a URL: npx webmcpx ${command} localhost:3000`);
|
|
116
|
+
if (command === "call" && !name) usage("Give a tool name: npx webmcpx call <url> <tool> [json]");
|
|
117
|
+
let input = {};
|
|
118
|
+
if (json) {
|
|
119
|
+
try {
|
|
120
|
+
input = JSON.parse(json);
|
|
121
|
+
} catch {
|
|
122
|
+
usage(`The input is not JSON: ${json}`);
|
|
123
|
+
}
|
|
124
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) usage(`The input must be a JSON object, like '{"query": "pricing"}'.`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const session = new Session(options);
|
|
128
|
+
try {
|
|
129
|
+
const page = await session.goto(url);
|
|
130
|
+
if (command === "tools") return printTools(page);
|
|
131
|
+
const entries = named(page.tools ?? []);
|
|
132
|
+
const tool = (entries.find((e) => e.tool.name === name) ?? entries.find((e) => e.name === name))?.tool;
|
|
133
|
+
if (!tool) {
|
|
134
|
+
console.error(`webmcpx: ${page.url} has no tool named "${name}". Run: npx webmcpx tools ${url}`);
|
|
135
|
+
process.exitCode = 2;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const out = await session.call(tool, input);
|
|
139
|
+
const mcp = mcpResult(out);
|
|
140
|
+
let pretty = out;
|
|
141
|
+
try {
|
|
142
|
+
pretty = mcp
|
|
143
|
+
? mcp.content.map((c) => (c.type === "text" ? c.text : JSON.stringify(c))).join("\n")
|
|
144
|
+
: JSON.stringify(JSON.parse(out), null, 2);
|
|
145
|
+
} catch {
|
|
146
|
+
// not JSON: print as is
|
|
147
|
+
}
|
|
148
|
+
console.log(pretty);
|
|
149
|
+
if (failed(out)) process.exitCode = 1;
|
|
150
|
+
} finally {
|
|
151
|
+
await session.close();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
main().catch((e) => {
|
|
156
|
+
console.error(`webmcpx: ${e.message}`);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
});
|