webmcpx 0.1.0 → 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/README.md +56 -2
- package/agent/browser.js +171 -0
- package/agent/mcp.js +190 -0
- package/agent/page.js +237 -0
- package/agent/session.js +114 -0
- package/agent/standard.js +57 -0
- package/bin/webmcpx.js +152 -0
- package/package.json +56 -8
- package/src/auto.js +11 -2
- package/src/generated.js +8 -0
- package/src/index.d.ts +40 -0
- package/src/index.js +3 -139
- package/src/loader.js +132 -0
- package/src/react.d.ts +4 -0
- package/src/react.js +17 -0
- package/src/svelte.d.ts +4 -0
- package/src/svelte.js +8 -0
- package/src/tool.js +269 -0
- package/src/vue.d.ts +4 -0
- package/src/vue.js +11 -0
package/agent/session.js
ADDED
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webmcpx",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Standard WebMCP tools for any website
|
|
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
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"homepage": "https://webmcpx.dev",
|
|
@@ -11,22 +11,45 @@
|
|
|
11
11
|
"directory": "packages/webmcpx"
|
|
12
12
|
},
|
|
13
13
|
"keywords": [
|
|
14
|
-
"
|
|
15
|
-
"mcp",
|
|
14
|
+
"agent",
|
|
16
15
|
"ai-agents",
|
|
16
|
+
"mcp",
|
|
17
|
+
"mcp-server",
|
|
17
18
|
"model-context",
|
|
18
|
-
"
|
|
19
|
+
"polyfill",
|
|
20
|
+
"react",
|
|
21
|
+
"svelte",
|
|
22
|
+
"vue",
|
|
23
|
+
"wasm",
|
|
24
|
+
"webmcp"
|
|
19
25
|
],
|
|
20
26
|
"exports": {
|
|
21
27
|
".": {
|
|
22
28
|
"types": "./src/index.d.ts",
|
|
23
29
|
"default": "./src/index.js"
|
|
24
30
|
},
|
|
25
|
-
"./auto": "./src/auto.js"
|
|
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
|
+
}
|
|
26
44
|
},
|
|
27
45
|
"types": "./src/index.d.ts",
|
|
46
|
+
"bin": {
|
|
47
|
+
"webmcpx": "bin/webmcpx.js"
|
|
48
|
+
},
|
|
28
49
|
"files": [
|
|
29
50
|
"src",
|
|
51
|
+
"agent",
|
|
52
|
+
"bin",
|
|
30
53
|
"wasm/webmcpx_wasm.js",
|
|
31
54
|
"wasm/webmcpx_wasm_bg.wasm",
|
|
32
55
|
"README.md",
|
|
@@ -36,12 +59,37 @@
|
|
|
36
59
|
"./src/auto.js"
|
|
37
60
|
],
|
|
38
61
|
"scripts": {
|
|
39
|
-
"test": "node --test"
|
|
62
|
+
"test": "node --test --experimental-test-module-mocks"
|
|
63
|
+
},
|
|
64
|
+
"engines": {
|
|
65
|
+
"node": ">=22"
|
|
66
|
+
},
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"@w3c/webmcp-polyfill": "0.1.0"
|
|
40
69
|
},
|
|
41
70
|
"devDependencies": {
|
|
42
|
-
"linkedom": "0.18.15"
|
|
71
|
+
"linkedom": "0.18.15",
|
|
72
|
+
"react": "19.3.1",
|
|
73
|
+
"svelte": "5.57.3",
|
|
74
|
+
"vue": "3.5.45"
|
|
43
75
|
},
|
|
44
76
|
"bugs": {
|
|
45
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
|
+
}
|
|
46
94
|
}
|
|
47
95
|
}
|
package/src/auto.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
// Drop-in: <script type="module" src="https://cdn.jsdelivr.net/npm/webmcpx/src/auto.js"></script>
|
|
2
|
-
|
|
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";
|
|
3
5
|
|
|
4
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
|
+
}
|
package/src/generated.js
CHANGED
|
@@ -17,3 +17,11 @@ export const CODES = {
|
|
|
17
17
|
REQUIRES_USER: false,
|
|
18
18
|
UNAVAILABLE: true,
|
|
19
19
|
};
|
|
20
|
+
|
|
21
|
+
/** Authoring limits, in chars (webmcpx-core `limits`). */
|
|
22
|
+
export const LIMITS = {
|
|
23
|
+
NAME_WARN: 30,
|
|
24
|
+
DESCRIPTION_WARN: 500,
|
|
25
|
+
TOOLS_WARN: 20,
|
|
26
|
+
OUTPUT_MAX: 20000,
|
|
27
|
+
};
|
package/src/index.d.ts
CHANGED
|
@@ -40,3 +40,43 @@ export const TOOLS: Record<ToolName, {
|
|
|
40
40
|
export type Envelope<T = unknown> =
|
|
41
41
|
| { ok: true; data: T; truncated?: true; next_cursor?: string; hint?: string }
|
|
42
42
|
| { ok: false; error: { code: string; message: string; retryable: boolean }; hint?: string };
|
|
43
|
+
|
|
44
|
+
/** An input field: a JSON type ("string", "number?" for optional), a list of choices, or a property schema. */
|
|
45
|
+
export type InputField = string | readonly (string | number)[] | Record<string, unknown>;
|
|
46
|
+
|
|
47
|
+
export interface ToolDefinition<I = any, O = unknown> {
|
|
48
|
+
/** 1–128 of A-Z a-z 0-9 _ . - (keep it under 30). */
|
|
49
|
+
name: string;
|
|
50
|
+
/** What it does and when to use it (under 500 chars). */
|
|
51
|
+
description: string;
|
|
52
|
+
/** Shorthand: `{ sku: "string", "qty?": "number", size: ["S", "M"] }`. */
|
|
53
|
+
input?: Record<string, InputField>;
|
|
54
|
+
/** A full JSON Schema instead of `input` (checked: type, enum, required, properties, bounds, pattern...). */
|
|
55
|
+
inputSchema?: { type: "object"; [key: string]: unknown };
|
|
56
|
+
/** Returns the data for `{ ok: true, data }`. Throw for errors; an error with a webmcpx `code` keeps it. */
|
|
57
|
+
run(input: I, client?: unknown): O | Promise<O>;
|
|
58
|
+
/** Only reads (readOnlyHint). */
|
|
59
|
+
readOnly?: boolean;
|
|
60
|
+
/** Changes something for the user: cart, booking, message (consequentialHint). */
|
|
61
|
+
acts?: boolean;
|
|
62
|
+
/** Returns third-party content, like reviews or comments (untrustedContentHint). */
|
|
63
|
+
untrusted?: boolean;
|
|
64
|
+
/** Abort to unregister. */
|
|
65
|
+
signal?: AbortSignal;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ToolHandle {
|
|
69
|
+
/** Resolves true once registered, false where WebMCP is missing or the host refused it. */
|
|
70
|
+
ready: Promise<boolean>;
|
|
71
|
+
/** Unregisters the tool. */
|
|
72
|
+
dispose(): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Registers one of your own tools with the webmcpx contract. A no-op where WebMCP is missing. */
|
|
76
|
+
export function tool<I = any, O = unknown>(definition: ToolDefinition<I, O>): ToolHandle;
|
|
77
|
+
|
|
78
|
+
/** Exposes every `<form toolname>` as a tool (WebMCP declarative API), following DOM changes. */
|
|
79
|
+
export function forms(): { dispose(): void };
|
|
80
|
+
|
|
81
|
+
/** The JSON Schema an input shorthand stands for. */
|
|
82
|
+
export function schema(input?: Record<string, InputField>): object;
|
package/src/index.js
CHANGED
|
@@ -1,139 +1,3 @@
|
|
|
1
|
-
// webmcpx
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const RECORD = "\x1e";
|
|
6
|
-
const FIELD = "\x1f";
|
|
7
|
-
const KINDS = {
|
|
8
|
-
H1: "h1", H2: "h2", H3: "h3", H4: "h4", H5: "h5", H6: "h6",
|
|
9
|
-
P: "p", LI: "li", PRE: "pre", BLOCKQUOTE: "quote",
|
|
10
|
-
DT: "p", DD: "p", FIGCAPTION: "p", SUMMARY: "p",
|
|
11
|
-
};
|
|
12
|
-
const SKIP = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "SVG", "NAV", "FOOTER", "ASIDE"]);
|
|
13
|
-
const DEFAULT_TOOLS = ["get_page_context", "search_page", "fetch_as_markdown"];
|
|
14
|
-
|
|
15
|
-
const envelope = (body) => JSON.stringify(body);
|
|
16
|
-
const ok = (data) => envelope({ ok: true, data });
|
|
17
|
-
const err = (code, message, hint) =>
|
|
18
|
-
envelope({ ok: false, error: { code, message, retryable: CODES[code] }, ...(hint && { hint }) });
|
|
19
|
-
|
|
20
|
-
let wasm;
|
|
21
|
-
const engine = (source) =>
|
|
22
|
-
(wasm ??= import("../wasm/webmcpx_wasm.js")
|
|
23
|
-
.then(async (m) => (await m.default(source ? { module_or_path: source } : undefined), m))
|
|
24
|
-
.catch((e) => {
|
|
25
|
-
wasm = undefined;
|
|
26
|
-
throw e;
|
|
27
|
-
}));
|
|
28
|
-
|
|
29
|
-
const pageUrl = () => location.href.split("#")[0];
|
|
30
|
-
const text = (s) => (s ?? "").replace(/\s+/g, " ").trim();
|
|
31
|
-
|
|
32
|
-
const skipped = (el) => SKIP.has(el.tagName) || el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true";
|
|
33
|
-
|
|
34
|
-
/** Serializes readable content of `root` as a block stream (see webmcpx-std `blocks`). */
|
|
35
|
-
function blocks(root) {
|
|
36
|
-
let out = "";
|
|
37
|
-
let anchor = "";
|
|
38
|
-
const base = pageUrl();
|
|
39
|
-
const visit = (el) => {
|
|
40
|
-
for (const child of el.children) {
|
|
41
|
-
if (skipped(child)) continue;
|
|
42
|
-
if (child.id) anchor = `${base}#${child.id}`;
|
|
43
|
-
// A table row is one block ("Monday | Closed"), so search keeps a value next to its label.
|
|
44
|
-
const row = child.tagName === "TR" && [...child.children].filter((c) => !skipped(c)).map((c) => text(c.textContent)).join(" | ");
|
|
45
|
-
const kind = row ? "p" : KINDS[child.tagName] ?? (child.children.length === 0 && "p");
|
|
46
|
-
if (kind) out += kind + FIELD + (row || child.textContent) + FIELD + anchor + RECORD;
|
|
47
|
-
else visit(child);
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
visit(root);
|
|
51
|
-
return out;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function contentRoot(root) {
|
|
55
|
-
if (typeof root === "string") return document.querySelector(root) ?? document.body;
|
|
56
|
-
return root ?? document.querySelector("main, [role=main], article") ?? document.body;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function pageContext() {
|
|
60
|
-
const base = pageUrl();
|
|
61
|
-
return ok({
|
|
62
|
-
title: document.title,
|
|
63
|
-
url: location.href,
|
|
64
|
-
lang: document.documentElement.lang || undefined,
|
|
65
|
-
description: document.querySelector('meta[name="description"]')?.getAttribute("content") || undefined,
|
|
66
|
-
headings: [...document.querySelectorAll("h1, h2, h3")].slice(0, 30).map((h) => ({
|
|
67
|
-
level: Number(h.tagName[1]),
|
|
68
|
-
text: text(h.textContent).slice(0, 120),
|
|
69
|
-
url: h.id ? `${base}#${h.id}` : undefined,
|
|
70
|
-
})),
|
|
71
|
-
selection: text(globalThis.getSelection?.()?.toString()).slice(0, 1000) || undefined,
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function executors(options) {
|
|
76
|
-
const withEngine = (run) => async (input) => {
|
|
77
|
-
let m;
|
|
78
|
-
try {
|
|
79
|
-
m = await engine(options.wasm);
|
|
80
|
-
} catch (e) {
|
|
81
|
-
return err("UNAVAILABLE", `WebAssembly could not load: ${e?.message ?? e}`,
|
|
82
|
-
"If the page sets a Content-Security-Policy, add 'wasm-unsafe-eval' to script-src.");
|
|
83
|
-
}
|
|
84
|
-
return run(m, blocks(contentRoot(options.root)), input);
|
|
85
|
-
};
|
|
86
|
-
return {
|
|
87
|
-
get_page_context: async () => pageContext(),
|
|
88
|
-
search_page: withEngine((m, page, { query, limit }) =>
|
|
89
|
-
typeof query === "string" ? m.search(page, query, limit ?? 0) : err("INVALID_INPUT", "`query` must be a string")),
|
|
90
|
-
fetch_as_markdown: withEngine((m, page, { cursor, max_chars }) => m.markdown(page, String(cursor ?? ""), max_chars ?? 0)),
|
|
91
|
-
search_site: async ({ query, limit = 5 }, { signal } = {}) => {
|
|
92
|
-
if (typeof query !== "string" || !query.trim()) return err("INVALID_INPUT", "`query` must be a non-empty string");
|
|
93
|
-
const found = await options.searchSite(query, { limit, signal });
|
|
94
|
-
if (!found?.length) return err("NOT_FOUND", "no page matches the query", "Try fewer or different words.");
|
|
95
|
-
return ok({ results: found.slice(0, limit).map(({ title, url, snippet }) => ({ title, url, snippet })) });
|
|
96
|
-
},
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** Tools resolve with an envelope and never reject: hosts drop rejection reasons (webmcp#282). */
|
|
101
|
-
const guard = (run) => async (input, client) => {
|
|
102
|
-
try {
|
|
103
|
-
return await run(typeof input === "string" ? JSON.parse(input || "{}") : input ?? {}, client);
|
|
104
|
-
} catch (e) {
|
|
105
|
-
return err("UNAVAILABLE", String(e?.message ?? e));
|
|
106
|
-
}
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Registers webmcpx standard tools on the current document.
|
|
111
|
-
* @param {import("./index").Options} [options]
|
|
112
|
-
* @returns {Promise<import("./index").Registration>}
|
|
113
|
-
*/
|
|
114
|
-
export async function register(options = {}) {
|
|
115
|
-
const mc = globalThis.document?.modelContext ?? globalThis.navigator?.modelContext;
|
|
116
|
-
const controller = new AbortController();
|
|
117
|
-
const result = { registered: [], failed: [], dispose: () => controller.abort() };
|
|
118
|
-
if (typeof mc?.registerTool !== "function") return result;
|
|
119
|
-
|
|
120
|
-
const run = executors(options);
|
|
121
|
-
const names = options.tools ?? [...DEFAULT_TOOLS, ...(options.searchSite ? ["search_site"] : [])];
|
|
122
|
-
await Promise.all(names.map(async (name) => {
|
|
123
|
-
if (!TOOLS[name]) return result.failed.push({ name, error: "unknown tool" });
|
|
124
|
-
if (name === "search_site" && !options.searchSite) {
|
|
125
|
-
return result.failed.push({ name, error: "search_site needs options.searchSite" });
|
|
126
|
-
}
|
|
127
|
-
try {
|
|
128
|
-
// Never assign onto `mc`: some agent browsers expose a frozen object (webmcp#293).
|
|
129
|
-
await mc.registerTool({ ...TOOLS[name], execute: guard(run[name]) }, { signal: controller.signal });
|
|
130
|
-
result.registered.push(name);
|
|
131
|
-
} catch (e) {
|
|
132
|
-
result.failed.push({ name, error: String(e?.message ?? e) });
|
|
133
|
-
}
|
|
134
|
-
}));
|
|
135
|
-
result.registered.sort();
|
|
136
|
-
return result;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export { TOOLS };
|
|
1
|
+
// webmcpx: the standard WebMCP tools, your own tools and <form toolname>, in one package.
|
|
2
|
+
export { readPage, register, TOOLS } from "./loader.js";
|
|
3
|
+
export { forms, schema, tool } from "./tool.js";
|