webmcpx 0.2.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 +9 -6
- package/agent/browser.js +51 -7
- package/agent/mcp.js +58 -15
- package/agent/page.js +89 -22
- package/agent/session.js +36 -13
- package/bin/webmcpx.js +7 -1
- package/package.json +1 -1
- package/src/auto.js +1 -2
- package/src/loader.js +13 -12
- package/src/tool.js +27 -25
package/README.md
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
# webmcpx
|
|
2
2
|
|
|
3
|
-
**Make your site agent-ready
|
|
3
|
+
**Make your site agent-ready in two steps.** webmcpx registers standard
|
|
4
4
|
[WebMCP](https://webmachinelearning.github.io/webmcp/) tools on any page, so AI agents in the browser
|
|
5
5
|
call a function instead of reading and clicking through the page.
|
|
6
6
|
|
|
7
|
+
**Step 1, on your site.** Paste this before `</body>`:
|
|
8
|
+
|
|
7
9
|
```html
|
|
8
10
|
<script type="module" src="https://cdn.jsdelivr.net/npm/webmcpx@0.2/src/auto.js"></script>
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
**
|
|
13
|
+
**Step 2, in your agent.** Give your agent the WebMCP tools of every page it opens (the ChatGPT desktop
|
|
14
|
+
app and the Codex app skip this step: they call WebMCP tools natively):
|
|
12
15
|
|
|
13
16
|
```sh
|
|
14
17
|
claude mcp add webmcpx -- npx -y webmcpx mcp # Codex: codex mcp add webmcpx -- npx -y webmcpx mcp
|
|
@@ -49,10 +52,10 @@ the script tag adds where the browser doesn't have it yet. With `toolautosubmit`
|
|
|
49
52
|
`event.agentInvoked` and can answer with `event.respondWith(...)`.
|
|
50
53
|
|
|
51
54
|
```html
|
|
52
|
-
<form toolname="
|
|
53
|
-
<input name="
|
|
54
|
-
<input name="
|
|
55
|
-
<
|
|
55
|
+
<form toolname="add_to_cart" tooldescription="Add a product to the cart by SKU." action="/cart" method="post" toolautosubmit>
|
|
56
|
+
<input name="sku" required toolparamdescription="Product SKU, e.g. MUG-01">
|
|
57
|
+
<input name="qty" type="number" min="1" toolparamdescription="How many (default 1)">
|
|
58
|
+
<button>Add to cart</button>
|
|
56
59
|
</form>
|
|
57
60
|
```
|
|
58
61
|
|
package/agent/browser.js
CHANGED
|
@@ -51,6 +51,8 @@ export class Cdp {
|
|
|
51
51
|
#id = 0;
|
|
52
52
|
#pending = new Map();
|
|
53
53
|
#listeners = new Set();
|
|
54
|
+
#closed = false;
|
|
55
|
+
#onClose = new Set();
|
|
54
56
|
|
|
55
57
|
static async connect(url) {
|
|
56
58
|
const ws = new WebSocket(url);
|
|
@@ -74,17 +76,39 @@ export class Cdp {
|
|
|
74
76
|
if (msg.error) pending?.reject(new Error(msg.error.message));
|
|
75
77
|
else pending?.resolve(msg.result);
|
|
76
78
|
});
|
|
77
|
-
ws.addEventListener("close", () =>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
ws.addEventListener("close", () => this.#shut());
|
|
80
|
+
ws.addEventListener("error", () => this.#shut());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Once closed (the browser quit or crashed), every pending and later call fails at once. */
|
|
84
|
+
#shut() {
|
|
85
|
+
if (this.#closed) return;
|
|
86
|
+
this.#closed = true;
|
|
87
|
+
for (const { reject } of this.#pending.values()) reject(new Error("the browser connection closed"));
|
|
88
|
+
this.#pending.clear();
|
|
89
|
+
for (const listener of this.#onClose) listener();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
get closed() {
|
|
93
|
+
return this.#closed;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Calls `listener` once the connection closes. */
|
|
97
|
+
onClose(listener) {
|
|
98
|
+
this.#onClose.add(listener);
|
|
81
99
|
}
|
|
82
100
|
|
|
83
101
|
send(method, params = {}, sessionId) {
|
|
102
|
+
if (this.#closed) return Promise.reject(new Error("the browser connection closed"));
|
|
84
103
|
const id = ++this.#id;
|
|
85
104
|
return new Promise((resolve, reject) => {
|
|
86
105
|
this.#pending.set(id, { resolve, reject });
|
|
87
|
-
|
|
106
|
+
try {
|
|
107
|
+
this.#ws.send(JSON.stringify({ id, method, params, ...(sessionId && { sessionId }) }));
|
|
108
|
+
} catch (e) {
|
|
109
|
+
this.#pending.delete(id);
|
|
110
|
+
reject(e);
|
|
111
|
+
}
|
|
88
112
|
});
|
|
89
113
|
}
|
|
90
114
|
|
|
@@ -96,6 +120,7 @@ export class Cdp {
|
|
|
96
120
|
|
|
97
121
|
close() {
|
|
98
122
|
this.#ws.close();
|
|
123
|
+
this.#shut();
|
|
99
124
|
}
|
|
100
125
|
}
|
|
101
126
|
|
|
@@ -122,10 +147,24 @@ export async function launch({ headed = false, executable = findBrowser() } = {}
|
|
|
122
147
|
];
|
|
123
148
|
const child = spawn(executable, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
124
149
|
const cleanup = () => rmSync(profile, { recursive: true, force: true });
|
|
125
|
-
|
|
150
|
+
// Also runs on process exit (signals included, see bin/webmcpx.js): never leave a browser or its profile.
|
|
151
|
+
const kill = () => {
|
|
152
|
+
child.kill();
|
|
153
|
+
try {
|
|
154
|
+
cleanup();
|
|
155
|
+
} catch {
|
|
156
|
+
// still in use: removed when the browser exits
|
|
157
|
+
}
|
|
158
|
+
};
|
|
126
159
|
process.once("exit", kill);
|
|
127
160
|
child.once("exit", cleanup);
|
|
128
161
|
|
|
162
|
+
// A browser that fails to start (no DevTools line, no connection) must not be left running.
|
|
163
|
+
const fail = (e) => {
|
|
164
|
+
process.removeListener("exit", kill);
|
|
165
|
+
kill();
|
|
166
|
+
throw e;
|
|
167
|
+
};
|
|
129
168
|
const endpoint = await new Promise((resolve, reject) => {
|
|
130
169
|
let log = "";
|
|
131
170
|
const timer = setTimeout(() => reject(new Error(`${executable} did not start within 30 s`)), 30_000);
|
|
@@ -146,8 +185,13 @@ export async function launch({ headed = false, executable = findBrowser() } = {}
|
|
|
146
185
|
clearTimeout(timer);
|
|
147
186
|
reject(new Error(`${executable} exited (${code}) before it was ready: ${log.trim().slice(-500)}`));
|
|
148
187
|
});
|
|
188
|
+
}).catch(fail);
|
|
189
|
+
const cdp = await Cdp.connect(endpoint).catch(fail);
|
|
190
|
+
// The connection dropped but Chrome may still run: it is ours, so it goes too.
|
|
191
|
+
cdp.onClose(() => {
|
|
192
|
+
process.removeListener("exit", kill);
|
|
193
|
+
kill();
|
|
149
194
|
});
|
|
150
|
-
const cdp = await Cdp.connect(endpoint);
|
|
151
195
|
return {
|
|
152
196
|
cdp,
|
|
153
197
|
close() {
|
package/agent/mcp.js
CHANGED
|
@@ -43,22 +43,58 @@ const CALL = {
|
|
|
43
43
|
annotations: { openWorldHint: true },
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
+
/** Who a tool is across refreshes: standard, or the page's (origin, owning frame and name). */
|
|
47
|
+
const keyOf = (t) => (t.standard ? `standard ${t.name}` : `${t.origin ?? ""} ${t.owner ?? "0"} ${t.name}`);
|
|
48
|
+
|
|
46
49
|
/**
|
|
47
|
-
* Gives each tool a unique MCP name
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
+
* Gives each tool a unique MCP name: MCP allows [A-Za-z0-9_-] and 64 characters, WebMCP also "." and
|
|
51
|
+
* 128, and two frames can register the same name. Clashes get "_2", "_3"...
|
|
52
|
+
* `previous` (key -> names, in list order) carries names over when as many tools share that key as
|
|
53
|
+
* before. When that count changes, same-named tools in different frames can't be told apart, so their old
|
|
54
|
+
* names are retired for the session (`retired`, updated here). `reserved` (name -> key, every name handed out this session; updated here) lets a
|
|
55
|
+
* name come back only to the same key: a name a client already has never runs a different tool.
|
|
56
|
+
* @returns {{name: string, id: string, tool: import("./session.js").Tool}[]}
|
|
50
57
|
*/
|
|
51
|
-
export function named(tools) {
|
|
58
|
+
export function named(tools, previous = new Map(), reserved = new Map(), retired = new Set()) {
|
|
59
|
+
const ids = tools.map(keyOf);
|
|
60
|
+
const count = (id) => ids.filter((x) => x === id).length;
|
|
61
|
+
// Same-named tools whose number changed can't be matched to their old names: retire those names for
|
|
62
|
+
// good. A lone tool that leaves and comes back is the same tool (same origin and name): it keeps its name.
|
|
63
|
+
for (const [id, names] of previous) {
|
|
64
|
+
if (names.length !== count(id) && Math.max(names.length, count(id)) > 1) for (const n of names) retired.add(n);
|
|
65
|
+
}
|
|
52
66
|
const used = new Set([OPEN.name, CALL.name]);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
67
|
+
const free = (name, id) => !used.has(name) && !retired.has(name) && (reserved.get(name) ?? id) === id;
|
|
68
|
+
const rank = new Map();
|
|
69
|
+
const kept = ids.map((id) => {
|
|
70
|
+
const i = rank.get(id) ?? 0;
|
|
71
|
+
rank.set(id, i + 1);
|
|
72
|
+
const name = previous.get(id)?.[i];
|
|
73
|
+
if (!name || !free(name, id)) return undefined;
|
|
57
74
|
used.add(name);
|
|
58
|
-
return
|
|
75
|
+
return name;
|
|
76
|
+
});
|
|
77
|
+
const entries = tools.map((tool, i) => {
|
|
78
|
+
let name = kept[i];
|
|
79
|
+
if (!name) {
|
|
80
|
+
const base = tool.name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64) || "tool";
|
|
81
|
+
name = base;
|
|
82
|
+
for (let n = 2; !free(name, ids[i]); n++) name = `${base.slice(0, 64 - String(n).length - 1)}_${n}`;
|
|
83
|
+
used.add(name);
|
|
84
|
+
}
|
|
85
|
+
return { name, id: ids[i], tool };
|
|
59
86
|
});
|
|
87
|
+
for (const e of entries) reserved.set(e.name, e.id);
|
|
88
|
+
return entries;
|
|
60
89
|
}
|
|
61
90
|
|
|
91
|
+
/** What `named` needs from the last list: each key's names, in order. */
|
|
92
|
+
export const carry = (entries) => {
|
|
93
|
+
const map = new Map();
|
|
94
|
+
for (const e of entries) map.set(e.id, [...(map.get(e.id) ?? []), e.name]);
|
|
95
|
+
return map;
|
|
96
|
+
};
|
|
97
|
+
|
|
62
98
|
/** MCP wants an object schema at the root; WebMCP schemas may leave `type` out. */
|
|
63
99
|
export function objectSchema(schema) {
|
|
64
100
|
if (schema && typeof schema === "object" && !Array.isArray(schema)) {
|
|
@@ -93,11 +129,10 @@ function asMcp({ name, tool }) {
|
|
|
93
129
|
}
|
|
94
130
|
|
|
95
131
|
/** A short, readable summary of an opened page for the model. */
|
|
96
|
-
export function describe({ url, title, tools }) {
|
|
132
|
+
export function describe({ url, title, tools }, entries = tools && named(tools)) {
|
|
97
133
|
if (tools === null) {
|
|
98
134
|
return `Opened ${url}, but it is not a secure context, so it has no WebMCP. Use https:// or localhost.`;
|
|
99
135
|
}
|
|
100
|
-
const entries = named(tools);
|
|
101
136
|
const own = entries.filter((e) => !e.tool.standard);
|
|
102
137
|
const lines = [`Opened "${title}" (${url}).`, ""];
|
|
103
138
|
lines.push(own.length ? `The site's own WebMCP tools (${own.length}):` : "The site registers no WebMCP tools of its own.");
|
|
@@ -113,12 +148,14 @@ export function describe({ url, title, tools }) {
|
|
|
113
148
|
/** Runs the server on stdin/stdout until stdin closes. Logs go to stderr only. */
|
|
114
149
|
export async function serve(options = {}, { input = process.stdin, output = process.stdout } = {}) {
|
|
115
150
|
let entries = [];
|
|
151
|
+
const reserved = new Map();
|
|
152
|
+
const retired = new Set();
|
|
116
153
|
let initialized = false;
|
|
117
154
|
const write = (msg) => output.write(`${JSON.stringify({ jsonrpc: "2.0", ...msg })}\n`);
|
|
118
155
|
// Replaces the tool list and tells the client when what it would see changed.
|
|
119
156
|
const update = (tools) => {
|
|
120
157
|
const before = JSON.stringify(entries.map(asMcp));
|
|
121
|
-
entries = named(tools);
|
|
158
|
+
entries = named(tools, carry(entries), reserved, retired);
|
|
122
159
|
if (initialized && JSON.stringify(entries.map(asMcp)) !== before) write({ method: "notifications/tools/list_changed" });
|
|
123
160
|
};
|
|
124
161
|
const session = new Session({
|
|
@@ -132,11 +169,17 @@ export async function serve(options = {}, { input = process.stdin, output = proc
|
|
|
132
169
|
if (name === OPEN.name) {
|
|
133
170
|
const page = await session.goto(args.url);
|
|
134
171
|
update(page.tools ?? []);
|
|
135
|
-
return text(describe(page));
|
|
172
|
+
return text(describe(page, entries));
|
|
136
173
|
}
|
|
137
174
|
const wanted = name === CALL.name ? String(args.name ?? "") : name;
|
|
175
|
+
// call_page_tool also takes the page's own name, but only when it is unambiguous and is not a name we
|
|
176
|
+
// gave (or retired) for another tool.
|
|
177
|
+
const byPageName = entries.filter((e) => e.tool.name === wanted);
|
|
138
178
|
const entry =
|
|
139
|
-
entries.find((e) => e.name === wanted) ??
|
|
179
|
+
entries.find((e) => e.name === wanted) ??
|
|
180
|
+
(name === CALL.name && byPageName.length === 1 && !retired.has(wanted) && (reserved.get(wanted) ?? byPageName[0].id) === byPageName[0].id
|
|
181
|
+
? byPageName[0]
|
|
182
|
+
: undefined);
|
|
140
183
|
if (!entry) return text(`Unknown tool "${wanted}". Call open_page first, then use the tools it lists.`, true);
|
|
141
184
|
const out = await session.call(entry.tool, name === CALL.name ? args.input : args);
|
|
142
185
|
return mcpResult(out) ?? text(out, failed(out));
|
|
@@ -186,5 +229,5 @@ export async function serve(options = {}, { input = process.stdin, output = proc
|
|
|
186
229
|
(e) => msg.id !== undefined && write({ id: msg.id, error: { code: e.code ?? -32603, message: e.message } }),
|
|
187
230
|
);
|
|
188
231
|
}
|
|
189
|
-
session.close();
|
|
232
|
+
await session.close();
|
|
190
233
|
}
|
package/agent/page.js
CHANGED
|
@@ -51,6 +51,20 @@ function summarize() {
|
|
|
51
51
|
return undefined;
|
|
52
52
|
}
|
|
53
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
|
+
};
|
|
54
68
|
return this.map((t, index) => ({
|
|
55
69
|
index,
|
|
56
70
|
name: t.name,
|
|
@@ -58,13 +72,22 @@ function summarize() {
|
|
|
58
72
|
inputSchema: parse(t.inputSchema),
|
|
59
73
|
annotations: { ...(t.annotations ?? {}) },
|
|
60
74
|
origin: t.origin,
|
|
75
|
+
owner: owner(t.window),
|
|
61
76
|
}));
|
|
62
77
|
}
|
|
63
78
|
|
|
64
|
-
// Called on the same array: runs one descriptor. executeTool() returns
|
|
65
|
-
// that returned a string (like the webmcpx envelope) comes back as a
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
}
|
|
68
91
|
let value = out;
|
|
69
92
|
try {
|
|
70
93
|
value = typeof out === "string" ? JSON.parse(out) : out;
|
|
@@ -74,9 +97,15 @@ async function execute(index, input) {
|
|
|
74
97
|
return typeof value === "string" ? value : JSON.stringify(value ?? null);
|
|
75
98
|
}
|
|
76
99
|
|
|
100
|
+
function cancel(call) {
|
|
101
|
+
this.__webmcpxCalls?.get(call)?.abort();
|
|
102
|
+
}
|
|
103
|
+
|
|
77
104
|
const GROUP = "webmcpx-tools";
|
|
78
105
|
const LOAD_TIMEOUT = 30_000;
|
|
79
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;
|
|
80
109
|
const SETTLE = { min: 400, max: 3_000, every: 200 };
|
|
81
110
|
// Unref'd: a pending timeout must never keep the CLI alive after its work is done.
|
|
82
111
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref());
|
|
@@ -85,32 +114,49 @@ const timeout = (ms, what) =>
|
|
|
85
114
|
setTimeout(() => reject(new Error(`${what} took longer than ${ms / 1000} s`)), ms).unref();
|
|
86
115
|
});
|
|
87
116
|
|
|
88
|
-
/**
|
|
89
|
-
export const stale = (e) =>
|
|
90
|
-
|
|
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);
|
|
91
121
|
|
|
92
122
|
export class Page {
|
|
93
123
|
#cdp;
|
|
94
124
|
#session;
|
|
125
|
+
#target;
|
|
126
|
+
#unsubscribe;
|
|
95
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;
|
|
96
132
|
#loads = new Set();
|
|
97
133
|
#handles = [];
|
|
98
134
|
#debounce;
|
|
99
135
|
|
|
100
136
|
/** Opens a new tab. `onChange()` fires when the page's tools may have changed on their own. */
|
|
101
|
-
|
|
137
|
+
/** `callTimeout` (ms) bounds one tool call; tests shorten it. */
|
|
138
|
+
static async create(cdp, { onChange = () => {}, callTimeout = CALL_TIMEOUT } = {}) {
|
|
102
139
|
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
|
|
103
140
|
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
|
|
104
|
-
const page = new Page(cdp, sessionId, onChange);
|
|
141
|
+
const page = new Page(cdp, sessionId, targetId, onChange);
|
|
142
|
+
page.#callTimeout = callTimeout;
|
|
105
143
|
await page.#init();
|
|
106
144
|
return page;
|
|
107
145
|
}
|
|
108
146
|
|
|
109
|
-
constructor(cdp, session, onChange) {
|
|
147
|
+
constructor(cdp, session, target, onChange) {
|
|
110
148
|
this.#cdp = cdp;
|
|
111
149
|
this.#session = session;
|
|
150
|
+
this.#target = target;
|
|
112
151
|
this.#onChange = onChange;
|
|
113
|
-
cdp.on((msg) => msg.sessionId === session && this.#event(msg));
|
|
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(() => {});
|
|
114
160
|
}
|
|
115
161
|
|
|
116
162
|
#send(method, params) {
|
|
@@ -202,26 +248,47 @@ export class Page {
|
|
|
202
248
|
if (this.#handles.length > 50) {
|
|
203
249
|
this.#send("Runtime.releaseObject", { objectId: this.#handles.shift() }).catch(() => {});
|
|
204
250
|
}
|
|
205
|
-
|
|
206
|
-
for (const t of list.value) count.set(`${t.origin} ${t.name}`, (count.get(`${t.origin} ${t.name}`) ?? 0) + 1);
|
|
207
|
-
return list.value.map((tool) => ({
|
|
208
|
-
...tool,
|
|
209
|
-
list: result.objectId,
|
|
210
|
-
unique: count.get(`${tool.origin} ${tool.name}`) === 1,
|
|
211
|
-
}));
|
|
251
|
+
return list.value.map((tool) => ({ ...tool, list: result.objectId }));
|
|
212
252
|
}
|
|
213
253
|
|
|
214
|
-
/**
|
|
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
|
+
*/
|
|
215
258
|
async call(tool, input) {
|
|
259
|
+
const call = ++this.#calls;
|
|
216
260
|
const run = this.#send("Runtime.callFunctionOn", {
|
|
217
261
|
objectId: tool.list,
|
|
218
262
|
functionDeclaration: execute.toString(),
|
|
219
|
-
arguments: [{ value: tool.index }, { value: input ?? {} }],
|
|
263
|
+
arguments: [{ value: tool.index }, { value: input ?? {} }, { value: call }],
|
|
220
264
|
awaitPromise: true,
|
|
221
265
|
returnByValue: true,
|
|
222
266
|
userGesture: true,
|
|
223
267
|
});
|
|
224
|
-
|
|
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;
|
|
225
292
|
if (exceptionDetails) throw new Error(describeException(exceptionDetails));
|
|
226
293
|
return result.value;
|
|
227
294
|
}
|
|
@@ -234,4 +301,4 @@ export class Page {
|
|
|
234
301
|
|
|
235
302
|
const describeException = (d) => d.exception?.description?.split("\n")[0] ?? d.text;
|
|
236
303
|
|
|
237
|
-
/** @typedef {{index: number, list: string,
|
|
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
CHANGED
|
@@ -9,18 +9,30 @@ export class Session {
|
|
|
9
9
|
#browser;
|
|
10
10
|
#page;
|
|
11
11
|
#ready;
|
|
12
|
+
#closed = false;
|
|
12
13
|
#queue = Promise.resolve();
|
|
13
14
|
|
|
14
|
-
/** @param {{headed?: boolean, browser?: string, cdp?: string, onChange?: () => void}} options */
|
|
15
|
+
/** @param {{headed?: boolean, browser?: string, cdp?: string, onChange?: () => void, callTimeout?: number}} options */
|
|
15
16
|
constructor(options = {}) {
|
|
16
17
|
this.#options = options;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
#start() {
|
|
21
|
+
if (this.#closed) return Promise.reject(new Error("the session is closed"));
|
|
20
22
|
this.#ready ??= (async () => {
|
|
21
|
-
const { cdp, headed, browser, onChange } = this.#options;
|
|
22
|
-
|
|
23
|
-
|
|
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 });
|
|
24
36
|
})().catch((e) => {
|
|
25
37
|
this.#ready = undefined;
|
|
26
38
|
throw e;
|
|
@@ -49,7 +61,7 @@ export class Session {
|
|
|
49
61
|
return this.#serial(async () => (this.#page ? withStandard((await this.#page.tools()) ?? []) : []));
|
|
50
62
|
}
|
|
51
63
|
|
|
52
|
-
/** Calls a tool from
|
|
64
|
+
/** Calls a tool from a list the page gave; one from a page that has since navigated is refused. */
|
|
53
65
|
call(tool, input) {
|
|
54
66
|
return this.#serial(async () => {
|
|
55
67
|
if (!this.#page) throw new Error("no page is open yet: call open_page first");
|
|
@@ -58,18 +70,29 @@ export class Session {
|
|
|
58
70
|
try {
|
|
59
71
|
return await page.call(tool, input);
|
|
60
72
|
} catch (e) {
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return page.call(again, input);
|
|
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;
|
|
68
79
|
}
|
|
69
80
|
});
|
|
70
81
|
}
|
|
71
82
|
|
|
72
|
-
|
|
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();
|
|
73
96
|
this.#browser?.close();
|
|
74
97
|
}
|
|
75
98
|
}
|
package/bin/webmcpx.js
CHANGED
|
@@ -91,6 +91,12 @@ function printTools(page) {
|
|
|
91
91
|
console.log(dim(`\nTry one: npx webmcpx call ${page.url} search_page '{"query": "..."}'`));
|
|
92
92
|
}
|
|
93
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
|
+
|
|
94
100
|
async function main() {
|
|
95
101
|
const { values, positionals } = parse(process.argv.slice(2));
|
|
96
102
|
const [command, ...rest] = positionals;
|
|
@@ -142,7 +148,7 @@ async function main() {
|
|
|
142
148
|
console.log(pretty);
|
|
143
149
|
if (failed(out)) process.exitCode = 1;
|
|
144
150
|
} finally {
|
|
145
|
-
session.close();
|
|
151
|
+
await session.close();
|
|
146
152
|
}
|
|
147
153
|
}
|
|
148
154
|
|
package/package.json
CHANGED
package/src/auto.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
// Drop-in: <script type="module" src="https://cdn.jsdelivr.net/npm/webmcpx@0.2/src/auto.js"></script>
|
|
2
|
-
//
|
|
3
|
-
// on pages that have one).
|
|
2
|
+
// Standard tools, plus <form toolname> as tools (that code loads only where such a form exists).
|
|
4
3
|
import { register } from "./loader.js";
|
|
5
4
|
|
|
6
5
|
register();
|
package/src/loader.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// all text processing runs in a WebAssembly module loaded on the first tool call.
|
|
1
|
+
// The standard tools: DOM and registerTool() here, text work in WebAssembly (loaded on first call).
|
|
3
2
|
import { CODES, TOOLS } from "./generated.js";
|
|
4
3
|
|
|
5
4
|
const DEFAULT_TOOLS = ["get_page_context", "search_page", "fetch_as_markdown"];
|
|
@@ -46,16 +45,22 @@ export function readPage(what, root) {
|
|
|
46
45
|
};
|
|
47
46
|
}
|
|
48
47
|
const skipped = (el) => SKIP.has(el.tagName) || el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true";
|
|
48
|
+
// Text without hidden parts; an element with no block inside is one block ("Free <b>delivery</b>").
|
|
49
|
+
const textOf = (el) =>
|
|
50
|
+
[...el.childNodes].map((n) => (n.nodeType === 3 ? n.data : n.nodeType === 1 && !skipped(n) ? textOf(n) : "")).join("");
|
|
51
|
+
const BLOCKS = Object.keys(KINDS) + ",div,section,article,main,ul,ol,dl,table,tr,form,header,figure,details";
|
|
49
52
|
let out = "";
|
|
50
53
|
let anchor = "";
|
|
54
|
+
const add = (kind, s) => text(s) && (out += kind + FIELD + s + FIELD + anchor + RECORD);
|
|
51
55
|
const visit = (el) => {
|
|
52
|
-
for (const child of el.
|
|
53
|
-
if (
|
|
56
|
+
for (const child of el.childNodes) {
|
|
57
|
+
if (child.nodeType === 3) add("p", child.data);
|
|
58
|
+
if (child.nodeType !== 1 || skipped(child)) continue;
|
|
54
59
|
if (child.id) anchor = `${base}#${child.id}`;
|
|
55
60
|
// A table row is one block ("Monday | Closed"), so search keeps a value next to its label.
|
|
56
|
-
const row = child.tagName === "TR" && [...child.children].filter((c) => !skipped(c)).map((c) => text(c
|
|
57
|
-
const kind = row ? "p" : KINDS[child.tagName] ?? (child.
|
|
58
|
-
if (kind)
|
|
61
|
+
const row = child.tagName === "TR" && [...child.children].filter((c) => !skipped(c)).map((c) => text(textOf(c))).join(" | ");
|
|
62
|
+
const kind = row ? "p" : KINDS[child.tagName] ?? (!child.querySelector(BLOCKS) && "p");
|
|
63
|
+
if (kind) add(kind, row || textOf(child));
|
|
59
64
|
else visit(child);
|
|
60
65
|
}
|
|
61
66
|
};
|
|
@@ -99,11 +104,7 @@ const guard = (run) => async (input, client) => {
|
|
|
99
104
|
}
|
|
100
105
|
};
|
|
101
106
|
|
|
102
|
-
/**
|
|
103
|
-
* Registers webmcpx standard tools on the current document.
|
|
104
|
-
* @param {import("./index").Options} [options]
|
|
105
|
-
* @returns {Promise<import("./index").Registration>}
|
|
106
|
-
*/
|
|
107
|
+
/** Registers the standard tools (types: index.d.ts). */
|
|
107
108
|
export async function register(options = {}) {
|
|
108
109
|
const mc = globalThis.document?.modelContext ?? globalThis.navigator?.modelContext;
|
|
109
110
|
const controller = new AbortController();
|
package/src/tool.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// Your own tools with the standard tools' contract
|
|
2
|
-
//
|
|
3
|
-
// in browsers that expose WebMCP without it.
|
|
1
|
+
// Your own tools, with the standard tools' contract, and <form toolname> (webmcp's
|
|
2
|
+
// declarative-api-explainer.md) where the browser lacks it.
|
|
4
3
|
import { CODES, LIMITS } from "./generated.js";
|
|
5
4
|
|
|
6
5
|
const context = () => globalThis.document?.modelContext ?? globalThis.navigator?.modelContext;
|
|
@@ -70,7 +69,7 @@ function check(s, value, at = "input") {
|
|
|
70
69
|
return "";
|
|
71
70
|
}
|
|
72
71
|
|
|
73
|
-
/**
|
|
72
|
+
/** `run` in the standard contract: checked input, `{ok, data}` or `{ok: false, error}`. */
|
|
74
73
|
const contract = (inputSchema, run) => async (raw, client) => {
|
|
75
74
|
let args;
|
|
76
75
|
try {
|
|
@@ -110,12 +109,7 @@ function register(definition, signal) {
|
|
|
110
109
|
);
|
|
111
110
|
}
|
|
112
111
|
|
|
113
|
-
/**
|
|
114
|
-
* Registers one of your own tools. `run(input)` returns the data (or throws; an error with a webmcpx
|
|
115
|
-
* `code` keeps it). A no-op where WebMCP is missing.
|
|
116
|
-
* @param {import("./index").ToolDefinition} definition
|
|
117
|
-
* @returns {{ready: Promise<boolean>, dispose(): void}}
|
|
118
|
-
*/
|
|
112
|
+
/** Registers one of your own tools (types and docs: index.d.ts). */
|
|
119
113
|
export function tool({ name, description, input, inputSchema = schema(input), run, readOnly, acts, untrusted, signal } = {}) {
|
|
120
114
|
const controller = new AbortController();
|
|
121
115
|
if (signal?.aborted) return { ready: Promise.resolve(false), dispose() {} };
|
|
@@ -139,7 +133,7 @@ export function tool({ name, description, input, inputSchema = schema(input), ru
|
|
|
139
133
|
|
|
140
134
|
const TOOL_ATTRIBUTES = ["toolname", "tooldescription", "toolautosubmit"];
|
|
141
135
|
// What a control contributes to the schema (option values included).
|
|
142
|
-
const CONTROL_ATTRIBUTES = ["name", "type", "required", "disabled", "min", "max", "multiple", "value", "toolparamdescription", "method"];
|
|
136
|
+
const CONTROL_ATTRIBUTES = ["name", "type", "required", "disabled", "readonly", "min", "max", "multiple", "value", "toolparamdescription", "method"];
|
|
143
137
|
const FORMATS = { email: "email", url: "uri", date: "date", "datetime-local": "date-time", time: "time" };
|
|
144
138
|
const SKIPPED = /^(submit|button|reset|image|file|password|hidden)$/;
|
|
145
139
|
|
|
@@ -149,9 +143,12 @@ export function formSchema(form) {
|
|
|
149
143
|
const required = [];
|
|
150
144
|
for (const el of form.elements) {
|
|
151
145
|
const { name, type } = el;
|
|
152
|
-
if (!
|
|
146
|
+
if (!fillable(el)) continue;
|
|
153
147
|
let p;
|
|
154
|
-
|
|
148
|
+
// Several checkboxes with one name: a set of choices.
|
|
149
|
+
if (type === "checkbox" && [...form.elements].filter((e) => e.name === name && fillable(e)).length > 1) {
|
|
150
|
+
(p = properties[name] ?? { type: "array", items: { enum: [] } }).items.enum.push(el.value);
|
|
151
|
+
} else if (type === "checkbox") p = { type: "boolean" };
|
|
155
152
|
else if (type === "radio") (p = properties[name] ?? { type: "string", enum: [] }).enum.push(el.value);
|
|
156
153
|
else if (el.tagName === "SELECT") {
|
|
157
154
|
p = { type: "string", enum: [...el.options].map((o) => o.value) };
|
|
@@ -160,22 +157,31 @@ export function formSchema(form) {
|
|
|
160
157
|
p = { type: "number", ...(el.min && { minimum: +el.min }), ...(el.max && { maximum: +el.max }) };
|
|
161
158
|
} else p = { type: "string", ...(FORMATS[type] && { format: FORMATS[type] }) };
|
|
162
159
|
const description =
|
|
163
|
-
el.getAttribute("toolparamdescription") || el.labels?.[0]?.textContent.trim() || el.getAttribute("placeholder");
|
|
160
|
+
el.getAttribute("toolparamdescription") || (!p.items && (el.labels?.[0]?.textContent.trim() || el.getAttribute("placeholder")));
|
|
164
161
|
if (description) p.description = description;
|
|
165
162
|
properties[name] = p;
|
|
166
163
|
if (el.required && !required.includes(name)) required.push(name);
|
|
167
164
|
}
|
|
168
|
-
|
|
165
|
+
// Only fields it shows: never hidden, password or disabled ones.
|
|
166
|
+
return { type: "object", properties, ...(required.length && { required }), additionalProperties: false };
|
|
169
167
|
}
|
|
170
168
|
|
|
169
|
+
/** A control a user could edit: named, enabled (fieldsets too), not readonly, hidden, password or a file. */
|
|
170
|
+
const fillable = (el) =>
|
|
171
|
+
el.name && !el.matches(":disabled, [readonly]") && !SKIPPED.test(el.type) && "value" in el && el.tagName !== "BUTTON";
|
|
172
|
+
|
|
171
173
|
function fill(form, args) {
|
|
172
174
|
for (const [name, value] of Object.entries(args)) {
|
|
173
175
|
const field = form.elements.namedItem(name);
|
|
174
|
-
for (const el of field instanceof Element ? [field] : [...(field ?? [])]) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
for (const el of (field instanceof Element ? [field] : [...(field ?? [])]).filter(fillable)) {
|
|
177
|
+
// Click / native setter: frameworks (React) ignore plain property writes.
|
|
178
|
+
const on = el.type === "checkbox" ? ([].concat(value).includes(el.value) || value === true) : el.value === String(value);
|
|
179
|
+
if (el.type === "checkbox" || el.type === "radio") {
|
|
180
|
+
if (el.checked !== on) el.click();
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (el.multiple) for (const o of el.options) o.selected = [].concat(value).includes(o.value);
|
|
184
|
+
else Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value").set.call(el, String(value));
|
|
179
185
|
// Frameworks listen for these, not for property writes.
|
|
180
186
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
181
187
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
@@ -212,11 +218,7 @@ function runForm(form) {
|
|
|
212
218
|
};
|
|
213
219
|
}
|
|
214
220
|
|
|
215
|
-
/**
|
|
216
|
-
* Exposes every `<form toolname>` of the page as a tool, and follows them as the page changes. Does
|
|
217
|
-
* nothing where WebMCP is missing or where the browser runs the declarative API itself.
|
|
218
|
-
* @returns {{dispose(): void}}
|
|
219
|
-
*/
|
|
221
|
+
/** Every `<form toolname>` as a tool, following DOM changes; not where the browser does it itself. */
|
|
220
222
|
export function forms() {
|
|
221
223
|
const doc = globalThis.document;
|
|
222
224
|
if (!doc || typeof context()?.registerTool !== "function" || "toolName" in HTMLFormElement.prototype) {
|