webmcpx 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/agent/page.js ADDED
@@ -0,0 +1,237 @@
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
+ return this.map((t, index) => ({
55
+ index,
56
+ name: t.name,
57
+ description: t.description ?? "",
58
+ inputSchema: parse(t.inputSchema),
59
+ annotations: { ...(t.annotations ?? {}) },
60
+ origin: t.origin,
61
+ }));
62
+ }
63
+
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);
68
+ let value = out;
69
+ try {
70
+ value = typeof out === "string" ? JSON.parse(out) : out;
71
+ } catch {
72
+ // not JSON: keep the text
73
+ }
74
+ return typeof value === "string" ? value : JSON.stringify(value ?? null);
75
+ }
76
+
77
+ const GROUP = "webmcpx-tools";
78
+ const LOAD_TIMEOUT = 30_000;
79
+ const CALL_TIMEOUT = 60_000;
80
+ const SETTLE = { min: 400, max: 3_000, every: 200 };
81
+ // Unref'd: a pending timeout must never keep the CLI alive after its work is done.
82
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref());
83
+ const timeout = (ms, what) =>
84
+ new Promise((_, reject) => {
85
+ setTimeout(() => reject(new Error(`${what} took longer than ${ms / 1000} s`)), ms).unref();
86
+ });
87
+
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);
91
+
92
+ export class Page {
93
+ #cdp;
94
+ #session;
95
+ #onChange;
96
+ #loads = new Set();
97
+ #handles = [];
98
+ #debounce;
99
+
100
+ /** Opens a new tab. `onChange()` fires when the page's tools may have changed on their own. */
101
+ static async create(cdp, { onChange = () => {} } = {}) {
102
+ const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
103
+ const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
104
+ const page = new Page(cdp, sessionId, onChange);
105
+ await page.#init();
106
+ return page;
107
+ }
108
+
109
+ constructor(cdp, session, onChange) {
110
+ this.#cdp = cdp;
111
+ this.#session = session;
112
+ this.#onChange = onChange;
113
+ cdp.on((msg) => msg.sessionId === session && this.#event(msg));
114
+ }
115
+
116
+ #send(method, params) {
117
+ return this.#cdp.send(method, params, this.#session);
118
+ }
119
+
120
+ async #init() {
121
+ await Promise.all([this.#send("Page.enable"), this.#send("Runtime.enable")]);
122
+ await this.#send("Runtime.addBinding", { name: CHANGED });
123
+ await this.#send("Page.addScriptToEvaluateOnNewDocument", { source: BOOT });
124
+ }
125
+
126
+ #event({ method, params }) {
127
+ if (method === "Page.javascriptDialogOpening") {
128
+ // An alert() would block every later call: dismiss it, like a user closing it.
129
+ this.#send("Page.handleJavaScriptDialog", { accept: false }).catch(() => {});
130
+ } else if (method === "Page.loadEventFired") {
131
+ for (const resolve of this.#loads) resolve();
132
+ this.#loads.clear();
133
+ this.#changed(); // a new document: a new tool list
134
+ } else if (method === "Runtime.bindingCalled" && params.name === CHANGED) {
135
+ this.#changed();
136
+ }
137
+ }
138
+
139
+ #changed() {
140
+ clearTimeout(this.#debounce);
141
+ this.#debounce = setTimeout(() => this.#onChange(), 150);
142
+ }
143
+
144
+ async #evaluate(expression, ms = CALL_TIMEOUT) {
145
+ const run = this.#send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
146
+ const { result, exceptionDetails } = await Promise.race([run, timeout(ms, "the page")]);
147
+ if (exceptionDetails) throw new Error(describeException(exceptionDetails));
148
+ return result.value;
149
+ }
150
+
151
+ /**
152
+ * Navigates and waits for the tool list to settle.
153
+ * @returns {Promise<{url: string, title: string, tools: PageTool[] | null}>} `tools` is null without WebMCP
154
+ * (outside a secure context).
155
+ */
156
+ async open(url) {
157
+ // The old document's tool lists die with it.
158
+ await this.#send("Runtime.releaseObjectGroup", { objectGroup: GROUP }).catch(() => {});
159
+ this.#handles = [];
160
+ const loaded = new Promise((resolve) => this.#loads.add(resolve));
161
+ const nav = await this.#send("Page.navigate", { url });
162
+ if (nav.errorText) throw new Error(`could not open ${url}: ${nav.errorText}`);
163
+ if (nav.loaderId) await Promise.race([loaded, wait(LOAD_TIMEOUT)]);
164
+ const tools = await this.#settle();
165
+ const { url: at, title } = await this.#evaluate("({ url: location.href, title: document.title })");
166
+ return { url: at, title, tools };
167
+ }
168
+
169
+ /** Polls until the tool list has been stable for a moment (sites often register after load). */
170
+ async #settle() {
171
+ const start = Date.now();
172
+ const names = (list) => JSON.stringify(list?.map((t) => t.name));
173
+ let last = await this.tools();
174
+ while (Date.now() - start < SETTLE.max) {
175
+ await wait(SETTLE.every);
176
+ const now = await this.tools();
177
+ const same = names(now) === names(last);
178
+ last = now;
179
+ if (same && Date.now() - start >= SETTLE.min) break;
180
+ }
181
+ return last;
182
+ }
183
+
184
+ /**
185
+ * Reads the page's tools. Each keeps the `index` of its descriptor in this list, which `call` runs:
186
+ * same-named tools in different frames stay distinct. Null without `document.modelContext`.
187
+ * @returns {Promise<PageTool[] | null>}
188
+ */
189
+ async tools() {
190
+ const run = this.#send("Runtime.evaluate", { expression: GET_TOOLS, awaitPromise: true, objectGroup: GROUP });
191
+ const { result, exceptionDetails } = await Promise.race([run, timeout(CALL_TIMEOUT, "the page")]);
192
+ if (exceptionDetails) throw new Error(describeException(exceptionDetails));
193
+ if (!result.objectId) return null;
194
+ const { result: list } = await this.#send("Runtime.callFunctionOn", {
195
+ objectId: result.objectId,
196
+ functionDeclaration: summarize.toString(),
197
+ returnByValue: true,
198
+ });
199
+ // Each tool keeps the very list it came from: a call runs on that descriptor, so it reaches the
200
+ // owning frame or fails if that owner unregistered it; it never shifts to another tool.
201
+ this.#handles.push(result.objectId);
202
+ if (this.#handles.length > 50) {
203
+ this.#send("Runtime.releaseObject", { objectId: this.#handles.shift() }).catch(() => {});
204
+ }
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
+ }));
212
+ }
213
+
214
+ /** Runs a tool on the list it came from; resolves with its result text. */
215
+ async call(tool, input) {
216
+ const run = this.#send("Runtime.callFunctionOn", {
217
+ objectId: tool.list,
218
+ functionDeclaration: execute.toString(),
219
+ arguments: [{ value: tool.index }, { value: input ?? {} }],
220
+ awaitPromise: true,
221
+ returnByValue: true,
222
+ userGesture: true,
223
+ });
224
+ const { result, exceptionDetails } = await Promise.race([run, timeout(CALL_TIMEOUT, "the tool")]);
225
+ if (exceptionDetails) throw new Error(describeException(exceptionDetails));
226
+ return result.value;
227
+ }
228
+
229
+ /** The loader's `readPage(what)`, evaluated in the page (for the standard tools). */
230
+ read(what) {
231
+ return this.#evaluate(`${READ_PAGE}(${JSON.stringify(what)})`);
232
+ }
233
+ }
234
+
235
+ const describeException = (d) => d.exception?.description?.split("\n")[0] ?? d.text;
236
+
237
+ /** @typedef {{index: number, list: string, unique: boolean, name: string, description: string, inputSchema?: object, annotations: Record<string, boolean>, origin?: string}} PageTool */
@@ -0,0 +1,114 @@
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
+ #queue = Promise.resolve();
13
+
14
+ /** @param {{headed?: boolean, browser?: string, cdp?: string, onChange?: () => void}} options */
15
+ constructor(options = {}) {
16
+ this.#options = options;
17
+ }
18
+
19
+ #start() {
20
+ 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 });
24
+ })().catch((e) => {
25
+ this.#ready = undefined;
26
+ throw e;
27
+ });
28
+ return this.#ready;
29
+ }
30
+
31
+ /** Runs page work one at a time: there is one tab, so a navigation must not interleave with a call. */
32
+ #serial(work) {
33
+ const run = this.#queue.then(work);
34
+ this.#queue = run.catch(() => {});
35
+ return run;
36
+ }
37
+
38
+ /** @returns {Promise<{url: string, title: string, tools: Tool[] | null}>} */
39
+ goto(url) {
40
+ return this.#serial(async () => {
41
+ this.#page = await this.#start();
42
+ const page = await this.#page.open(normalize(url));
43
+ return { ...page, tools: page.tools && withStandard(page.tools) };
44
+ });
45
+ }
46
+
47
+ /** The current tool list (empty before a page is open, or without WebMCP). */
48
+ tools() {
49
+ return this.#serial(async () => (this.#page ? withStandard((await this.#page.tools()) ?? []) : []));
50
+ }
51
+
52
+ /** Calls a tool from the latest list; a page tool whose list went stale is found again by name and origin. */
53
+ call(tool, input) {
54
+ return this.#serial(async () => {
55
+ if (!this.#page) throw new Error("no page is open yet: call open_page first");
56
+ const page = this.#page;
57
+ if (tool.standard) return runStandard(tool.name, input, (what) => page.read(what));
58
+ try {
59
+ return await page.call(tool, input);
60
+ } 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);
68
+ }
69
+ });
70
+ }
71
+
72
+ close() {
73
+ this.#browser?.close();
74
+ }
75
+ }
76
+
77
+ /** The page's tools, then each standard tool the page does not already register. */
78
+ function withStandard(tools) {
79
+ const names = new Set(tools.map((t) => t.name));
80
+ return [...tools, ...standardTools().filter((t) => !names.has(t.name))];
81
+ }
82
+
83
+ /** `example.com` → `https://example.com`; `localhost:3000` → `http://localhost:3000`. */
84
+ export function normalize(url) {
85
+ const raw = String(url ?? "").trim();
86
+ if (!raw) throw new Error("give a URL, e.g. https://example.com or http://localhost:3000");
87
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || raw.startsWith("about:") || raw.startsWith("file:")) return raw;
88
+ const local = /^(localhost|127\.|\[::1\]|0\.0\.0\.0)/.test(raw);
89
+ return `${local ? "http" : "https"}://${raw}`;
90
+ }
91
+
92
+ /** Whether a tool result reports failure: a webmcpx envelope (`"ok": false`) or an MCP result (`"isError": true`). */
93
+ export function failed(text) {
94
+ try {
95
+ const value = JSON.parse(text);
96
+ return value?.ok === false || value?.isError === true;
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ /** A result some sites already shape like MCP (`{content: [...]}`, e.g. Shopify), or undefined. */
103
+ export function mcpResult(text) {
104
+ try {
105
+ const value = JSON.parse(text);
106
+ if (!Array.isArray(value?.content)) return undefined;
107
+ const { content, structuredContent, isError } = value;
108
+ return { content, ...(structuredContent && { structuredContent }), ...(isError === true && { isError }) };
109
+ } catch {
110
+ return undefined;
111
+ }
112
+ }
113
+
114
+ /** @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,152 @@
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
+ async function main() {
95
+ const { values, positionals } = parse(process.argv.slice(2));
96
+ const [command, ...rest] = positionals;
97
+ if (values.version) {
98
+ const { readFileSync } = await import("node:fs");
99
+ return console.log(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
100
+ }
101
+ if (values.help || !command || command === "help") return console.log(HELP);
102
+ const options = { headed: values.headed, browser: values.browser, cdp: values.cdp };
103
+
104
+ if (command === "mcp") return serve(options);
105
+ if (command === "lint") usage("The linter is the Rust CLI: cargo install webmcpx, then webmcpx lint <dir>.");
106
+ if (command !== "tools" && command !== "call") usage(`Unknown command "${command}".`);
107
+
108
+ const [url, name, json] = rest;
109
+ if (!url) usage(`Give a URL: npx webmcpx ${command} localhost:3000`);
110
+ if (command === "call" && !name) usage("Give a tool name: npx webmcpx call <url> <tool> [json]");
111
+ let input = {};
112
+ if (json) {
113
+ try {
114
+ input = JSON.parse(json);
115
+ } catch {
116
+ usage(`The input is not JSON: ${json}`);
117
+ }
118
+ if (!input || typeof input !== "object" || Array.isArray(input)) usage(`The input must be a JSON object, like '{"query": "pricing"}'.`);
119
+ }
120
+
121
+ const session = new Session(options);
122
+ try {
123
+ const page = await session.goto(url);
124
+ if (command === "tools") return printTools(page);
125
+ const entries = named(page.tools ?? []);
126
+ const tool = (entries.find((e) => e.tool.name === name) ?? entries.find((e) => e.name === name))?.tool;
127
+ if (!tool) {
128
+ console.error(`webmcpx: ${page.url} has no tool named "${name}". Run: npx webmcpx tools ${url}`);
129
+ process.exitCode = 2;
130
+ return;
131
+ }
132
+ const out = await session.call(tool, input);
133
+ const mcp = mcpResult(out);
134
+ let pretty = out;
135
+ try {
136
+ pretty = mcp
137
+ ? mcp.content.map((c) => (c.type === "text" ? c.text : JSON.stringify(c))).join("\n")
138
+ : JSON.stringify(JSON.parse(out), null, 2);
139
+ } catch {
140
+ // not JSON: print as is
141
+ }
142
+ console.log(pretty);
143
+ if (failed(out)) process.exitCode = 1;
144
+ } finally {
145
+ session.close();
146
+ }
147
+ }
148
+
149
+ main().catch((e) => {
150
+ console.error(`webmcpx: ${e.message}`);
151
+ process.exit(1);
152
+ });
package/package.json CHANGED
@@ -1,6 +1,95 @@
1
1
  {
2
2
  "name": "webmcpx",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
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
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://webmcpx.dev",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/webmcpx/webmcpx.git",
11
+ "directory": "packages/webmcpx"
12
+ },
13
+ "keywords": [
14
+ "agent",
15
+ "ai-agents",
16
+ "mcp",
17
+ "mcp-server",
18
+ "model-context",
19
+ "polyfill",
20
+ "react",
21
+ "svelte",
22
+ "vue",
23
+ "wasm",
24
+ "webmcp"
25
+ ],
26
+ "exports": {
27
+ ".": {
28
+ "types": "./src/index.d.ts",
29
+ "default": "./src/index.js"
30
+ },
31
+ "./auto": "./src/auto.js",
32
+ "./react": {
33
+ "types": "./src/react.d.ts",
34
+ "default": "./src/react.js"
35
+ },
36
+ "./vue": {
37
+ "types": "./src/vue.d.ts",
38
+ "default": "./src/vue.js"
39
+ },
40
+ "./svelte": {
41
+ "types": "./src/svelte.d.ts",
42
+ "default": "./src/svelte.js"
43
+ }
44
+ },
45
+ "types": "./src/index.d.ts",
46
+ "bin": {
47
+ "webmcpx": "bin/webmcpx.js"
48
+ },
49
+ "files": [
50
+ "src",
51
+ "agent",
52
+ "bin",
53
+ "wasm/webmcpx_wasm.js",
54
+ "wasm/webmcpx_wasm_bg.wasm",
55
+ "README.md",
56
+ "LICENSE"
57
+ ],
58
+ "sideEffects": [
59
+ "./src/auto.js"
60
+ ],
61
+ "scripts": {
62
+ "test": "node --test --experimental-test-module-mocks"
63
+ },
64
+ "engines": {
65
+ "node": ">=22"
66
+ },
67
+ "dependencies": {
68
+ "@w3c/webmcp-polyfill": "0.1.0"
69
+ },
70
+ "devDependencies": {
71
+ "linkedom": "0.18.15",
72
+ "react": "19.3.1",
73
+ "svelte": "5.57.3",
74
+ "vue": "3.5.45"
75
+ },
76
+ "bugs": {
77
+ "url": "https://github.com/webmcpx/webmcpx/issues"
78
+ },
79
+ "peerDependencies": {
80
+ "react": ">=16.8",
81
+ "vue": ">=2.7",
82
+ "svelte": ">=3"
83
+ },
84
+ "peerDependenciesMeta": {
85
+ "react": {
86
+ "optional": true
87
+ },
88
+ "vue": {
89
+ "optional": true
90
+ },
91
+ "svelte": {
92
+ "optional": true
93
+ }
94
+ }
95
+ }
package/src/auto.js ADDED
@@ -0,0 +1,13 @@
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).
4
+ import { register } from "./loader.js";
5
+
6
+ register();
7
+ const doc = globalThis.document;
8
+ const forms = () => import("./tool.js").then((m) => m.forms());
9
+ if (doc?.querySelector("form[toolname]")) forms();
10
+ else if (doc) {
11
+ const watch = new MutationObserver(() => doc.querySelector("form[toolname]") && (watch.disconnect(), forms()));
12
+ watch.observe(doc.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ["toolname"] });
13
+ }