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 CHANGED
@@ -1,14 +1,17 @@
1
1
  # webmcpx
2
2
 
3
- **Make your site agent-ready with one script tag.** webmcpx registers standard
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
- **See it work in any agent, today.** Give your coding agent the WebMCP tools of every page it opens:
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="search_flights" tooldescription="Search flights by route and date." action="/search">
53
- <input name="from" required toolparamdescription="Departure airport, e.g. GRU">
54
- <input name="to" required toolparamdescription="Arrival airport, e.g. LIS">
55
- <input name="date" type="date" required>
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
- for (const { reject } of this.#pending.values()) reject(new Error("the browser connection closed"));
79
- this.#pending.clear();
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
- this.#ws.send(JSON.stringify({ id, method, params, ...(sessionId && { sessionId }) }));
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
- const kill = () => child.kill();
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, in list order: MCP allows [A-Za-z0-9_-] and 64 characters, WebMCP
48
- * also "." and 128, and two frames can register the same name. Clashes get "_2", "_3"...
49
- * @returns {{name: string, tool: import("./session.js").Tool}[]}
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
- return tools.map((tool) => {
54
- const base = tool.name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64) || "tool";
55
- let name = base;
56
- for (let n = 2; used.has(name); n++) name = `${base.slice(0, 64 - String(n).length - 1)}_${n}`;
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 { name, tool };
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) ?? (name === CALL.name ? entries.find((e) => e.tool.name === wanted) : undefined);
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 the result JSON-serialized; a tool
65
- // that returned a string (like the webmcpx envelope) comes back as a JSON string literal: unwrap it once.
66
- async function execute(index, input) {
67
- const out = await document.modelContext.executeTool(this[index], input);
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
- /** Whether an error means the page navigated or the tool list was replaced since it was read. */
89
- export const stale = (e) =>
90
- /Could not find object|Cannot find context|context was destroyed|Inspected target navigated/i.test(e?.message);
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
- static async create(cdp, { onChange = () => {} } = {}) {
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
- const count = new Map();
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
- /** Runs a tool on the list it came from; resolves with its result text. */
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
- const { result, exceptionDetails } = await Promise.race([run, timeout(CALL_TIMEOUT, "the tool")]);
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, unique: boolean, name: string, description: string, inputSchema?: object, annotations: Record<string, boolean>, origin?: string}} PageTool */
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
- this.#browser = cdp ? await attach(cdp) : await launch({ headed, executable: browser });
23
- return Page.create(this.#browser.cdp, { onChange });
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 the latest list; a page tool whose list went stale is found again by name and origin. */
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
- if (!stale(e)) throw e;
62
- // The document the tool was listed in is gone: run the same tool in the new one only when no
63
- // other tool shares its name and origin there or then; otherwise refuse rather than guess.
64
- const now = ((await page.tools()) ?? []).filter((t) => t.name === tool.name && t.origin === tool.origin);
65
- const again = tool.unique && now.length === 1 ? now[0] : undefined;
66
- if (!again) throw new Error(`the page changed and no longer has the tool "${tool.name}": call open_page again`);
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
- close() {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webmcpx",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Standard WebMCP tools for any website, and an MCP server that gives any agent the WebMCP tools of the pages it opens.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
- // Registers the standard tools, and turns every <form toolname> into a tool (that code loads only
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
- // webmcpx standard tools: registers them on the page. Owns the DOM and registerTool();
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.children) {
53
- if (skipped(child)) continue;
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.textContent)).join(" | ");
57
- const kind = row ? "p" : KINDS[child.tagName] ?? (child.children.length === 0 && "p");
58
- if (kind) out += kind + FIELD + (row || child.textContent) + FIELD + anchor + RECORD;
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 (one result shape, validated input, never a
2
- // rejection), and the WebMCP declarative API (<form toolname>, webmcp's declarative-api-explainer.md)
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
- /** Wraps `run` in the standard contract: parsed and checked input, `{ok, data}` or `{ok: false, error}`. */
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 (!name || el.disabled || SKIPPED.test(type) || !("value" in el) || el.tagName === "BUTTON") continue;
146
+ if (!fillable(el)) continue;
153
147
  let p;
154
- if (type === "checkbox") p = { type: "boolean" };
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
- return { type: "object", properties, ...(required.length && { required }) };
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
- if (el.type === "checkbox") el.checked = Boolean(value);
176
- else if (el.type === "radio") el.checked = el.value === String(value);
177
- else if (el.multiple) for (const o of el.options) o.selected = [].concat(value).includes(o.value);
178
- else el.value = String(value);
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) {