webmcpx 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "webmcpx",
3
- "version": "0.1.0",
4
- "description": "Standard WebMCP tools for any website: one script tag, zero config.",
3
+ "version": "0.2.1",
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
- "webmcp",
15
- "mcp",
14
+ "agent",
16
15
  "ai-agents",
16
+ "mcp",
17
+ "mcp-server",
17
18
  "model-context",
18
- "wasm"
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,12 @@
1
- // Drop-in: <script type="module" src="https://cdn.jsdelivr.net/npm/webmcpx/src/auto.js"></script>
2
- import { register } from "./index.js";
1
+ // Drop-in: <script type="module" src="https://cdn.jsdelivr.net/npm/webmcpx@0.2/src/auto.js"></script>
2
+ // Standard tools, plus <form toolname> as tools (that code loads only where such a form exists).
3
+ import { register } from "./loader.js";
3
4
 
4
5
  register();
6
+ const doc = globalThis.document;
7
+ const forms = () => import("./tool.js").then((m) => m.forms());
8
+ if (doc?.querySelector("form[toolname]")) forms();
9
+ else if (doc) {
10
+ const watch = new MutationObserver(() => doc.querySelector("form[toolname]") && (watch.disconnect(), forms()));
11
+ watch.observe(doc.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ["toolname"] });
12
+ }
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 loader: registers the standard WebMCP tools. Owns the DOM and registerTool();
2
- // all text processing runs in a WebAssembly module loaded on the first tool call.
3
- import { CODES, TOOLS } from "./generated.js";
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";
package/src/loader.js ADDED
@@ -0,0 +1,133 @@
1
+ // The standard tools: DOM and registerTool() here, text work in WebAssembly (loaded on first call).
2
+ import { CODES, TOOLS } from "./generated.js";
3
+
4
+ const DEFAULT_TOOLS = ["get_page_context", "search_page", "fetch_as_markdown"];
5
+
6
+ const envelope = (body) => JSON.stringify(body);
7
+ const ok = (data) => envelope({ ok: true, data });
8
+ const err = (code, message, hint) =>
9
+ envelope({ ok: false, error: { code, message, retryable: CODES[code] }, ...(hint && { hint }) });
10
+
11
+ let wasm;
12
+ const engine = (source) =>
13
+ (wasm ??= import("../wasm/webmcpx_wasm.js")
14
+ .then(async (m) => (await m.default(source ? { module_or_path: source } : undefined), m))
15
+ .catch((e) => {
16
+ wasm = undefined;
17
+ throw e;
18
+ }));
19
+
20
+ // "blocks": main content as a webmcpx-std block stream; "context": the page. Self-contained:
21
+ // agent/ runs its source in pages. Internal, not public API.
22
+ export function readPage(what, root) {
23
+ const RECORD = "\x1e";
24
+ const FIELD = "\x1f";
25
+ const KINDS = {
26
+ H1: "h1", H2: "h2", H3: "h3", H4: "h4", H5: "h5", H6: "h6",
27
+ P: "p", LI: "li", PRE: "pre", BLOCKQUOTE: "quote",
28
+ DT: "p", DD: "p", FIGCAPTION: "p", SUMMARY: "p",
29
+ };
30
+ const SKIP = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "SVG", "NAV", "FOOTER", "ASIDE"]);
31
+ const base = location.href.split("#")[0];
32
+ const text = (s) => (s ?? "").replace(/\s+/g, " ").trim();
33
+ if (what === "context") {
34
+ return {
35
+ title: document.title,
36
+ url: location.href,
37
+ lang: document.documentElement.lang || undefined,
38
+ description: document.querySelector('meta[name="description"]')?.getAttribute("content") || undefined,
39
+ headings: [...document.querySelectorAll("h1, h2, h3")].slice(0, 30).map((h) => ({
40
+ level: Number(h.tagName[1]),
41
+ text: text(h.textContent).slice(0, 120),
42
+ url: h.id ? `${base}#${h.id}` : undefined,
43
+ })),
44
+ selection: text(globalThis.getSelection?.()?.toString()).slice(0, 1000) || undefined,
45
+ };
46
+ }
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";
52
+ let out = "";
53
+ let anchor = "";
54
+ const add = (kind, s) => text(s) && (out += kind + FIELD + s + FIELD + anchor + RECORD);
55
+ const visit = (el) => {
56
+ for (const child of el.childNodes) {
57
+ if (child.nodeType === 3) add("p", child.data);
58
+ if (child.nodeType !== 1 || skipped(child)) continue;
59
+ if (child.id) anchor = `${base}#${child.id}`;
60
+ // A table row is one block ("Monday | Closed"), so search keeps a value next to its label.
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));
64
+ else visit(child);
65
+ }
66
+ };
67
+ visit(root ?? document.querySelector("main, [role=main], article") ?? document.body);
68
+ return out;
69
+ }
70
+
71
+ const contentRoot = (root) => (typeof root === "string" ? document.querySelector(root) ?? document.body : root);
72
+
73
+ function executors(options) {
74
+ const withEngine = (run) => async (input) => {
75
+ let m;
76
+ try {
77
+ m = await engine(options.wasm);
78
+ } catch (e) {
79
+ return err("UNAVAILABLE", `WebAssembly could not load: ${e?.message ?? e}`,
80
+ "If the page sets a Content-Security-Policy, add 'wasm-unsafe-eval' to script-src.");
81
+ }
82
+ return run(m, readPage("blocks", contentRoot(options.root)), input);
83
+ };
84
+ return {
85
+ get_page_context: async () => ok(readPage("context")),
86
+ search_page: withEngine((m, page, { query, limit }) =>
87
+ typeof query === "string" ? m.search(page, query, limit ?? 0) : err("INVALID_INPUT", "`query` must be a string")),
88
+ fetch_as_markdown: withEngine((m, page, { cursor, max_chars }) => m.markdown(page, String(cursor ?? ""), max_chars ?? 0)),
89
+ search_site: async ({ query, limit = 5 }, { signal } = {}) => {
90
+ if (typeof query !== "string" || !query.trim()) return err("INVALID_INPUT", "`query` must be a non-empty string");
91
+ const found = await options.searchSite(query, { limit, signal });
92
+ if (!found?.length) return err("NOT_FOUND", "no page matches the query", "Try fewer or different words.");
93
+ return ok({ results: found.slice(0, limit).map(({ title, url, snippet }) => ({ title, url, snippet })) });
94
+ },
95
+ };
96
+ }
97
+
98
+ /** Tools resolve with an envelope and never reject: hosts drop rejection reasons (webmcp#282). */
99
+ const guard = (run) => async (input, client) => {
100
+ try {
101
+ return await run(typeof input === "string" ? JSON.parse(input || "{}") : input ?? {}, client);
102
+ } catch (e) {
103
+ return err("UNAVAILABLE", String(e?.message ?? e));
104
+ }
105
+ };
106
+
107
+ /** Registers the standard tools (types: index.d.ts). */
108
+ export async function register(options = {}) {
109
+ const mc = globalThis.document?.modelContext ?? globalThis.navigator?.modelContext;
110
+ const controller = new AbortController();
111
+ const result = { registered: [], failed: [], dispose: () => controller.abort() };
112
+ if (typeof mc?.registerTool !== "function") return result;
113
+
114
+ const run = executors(options);
115
+ const names = options.tools ?? [...DEFAULT_TOOLS, ...(options.searchSite ? ["search_site"] : [])];
116
+ await Promise.all(names.map(async (name) => {
117
+ if (!TOOLS[name]) return result.failed.push({ name, error: "unknown tool" });
118
+ if (name === "search_site" && !options.searchSite) {
119
+ return result.failed.push({ name, error: "search_site needs options.searchSite" });
120
+ }
121
+ try {
122
+ // Never assign onto `mc`: some agent browsers expose a frozen object (webmcp#293).
123
+ await mc.registerTool({ ...TOOLS[name], execute: guard(run[name]) }, { signal: controller.signal });
124
+ result.registered.push(name);
125
+ } catch (e) {
126
+ result.failed.push({ name, error: String(e?.message ?? e) });
127
+ }
128
+ }));
129
+ result.registered.sort();
130
+ return result;
131
+ }
132
+
133
+ export { TOOLS };
package/src/react.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import type { ToolDefinition } from "./index.js";
2
+
3
+ /** Registers the tool while the component is mounted. */
4
+ export function useTool(definition: ToolDefinition): void;
package/src/react.js ADDED
@@ -0,0 +1,17 @@
1
+ // React 16.8+ (and Preact via preact/compat): `useTool({ name, description, input, run })` registers the tool while the component is
2
+ // mounted. `run` always sees the latest props and state; the tool re-registers when what the agent
3
+ // sees changes: name, description, input or annotations.
4
+ import { useEffect, useRef } from "react";
5
+ import { tool } from "./tool.js";
6
+
7
+ export function useTool(definition) {
8
+ const latest = useRef(definition);
9
+ latest.current = definition;
10
+ const { name, description, input, inputSchema, readOnly, acts, untrusted, signal } = definition;
11
+ // What the agent sees (and is told about safety) re-registers the tool; `run` is always the latest.
12
+ const shape = JSON.stringify([input ?? {}, inputSchema ?? null]);
13
+ useEffect(
14
+ () => tool({ ...latest.current, run: (args, client) => latest.current.run(args, client) }).dispose,
15
+ [name, description, shape, Boolean(readOnly), Boolean(acts), Boolean(untrusted), signal],
16
+ );
17
+ }
@@ -0,0 +1,4 @@
1
+ import type { ToolDefinition } from "./index.js";
2
+
3
+ /** Registers the tool while the component is mounted. */
4
+ export function useTool(definition: ToolDefinition): void;
package/src/svelte.js ADDED
@@ -0,0 +1,8 @@
1
+ // Svelte 3, 4 and 5: `useTool(definition)` in a component's script registers the tool while it is
2
+ // mounted. (In Svelte 5 you can also write `$effect(() => tool(definition).dispose)`.)
3
+ import { onMount } from "svelte";
4
+ import { tool } from "./tool.js";
5
+
6
+ export function useTool(definition) {
7
+ onMount(() => tool(definition).dispose);
8
+ }