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/LICENSE +21 -0
- package/README.md +95 -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 +93 -4
- package/src/auto.js +13 -0
- package/src/generated.js +27 -0
- package/src/index.d.ts +82 -0
- package/src/index.js +3 -0
- 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/wasm/webmcpx_wasm.js +264 -0
- package/wasm/webmcpx_wasm_bg.wasm +0 -0
package/src/generated.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// @generated by `just gen` from crates/webmcpx-std. Do not edit.
|
|
2
|
+
|
|
3
|
+
export const TOOLS = {
|
|
4
|
+
get_page_context: {"name":"get_page_context","title":"Get page context","description":"Describe the page the user is on: title, URL, language, meta description, heading outline and the text the user has selected. Use it for the page's structure or the user's selection; to find a fact on the page, call search_page instead.","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"annotations":{"readOnlyHint":true,"untrustedContentHint":true}},
|
|
5
|
+
search_page: {"name":"search_page","title":"Search this page","description":"Find passages on the current page that match a query. Returns up to `limit` passages ranked by relevance, each with its section heading and a link to it. Prefer it to reading the whole page when looking for something specific.","inputSchema":{"type":"object","properties":{"query":{"type":"string","description":"Words to look for."},"limit":{"type":"integer","minimum":1,"maximum":20,"default":5,"description":"Maximum passages to return."}},"required":["query"],"additionalProperties":false},"annotations":{"readOnlyHint":true,"untrustedContentHint":true}},
|
|
6
|
+
fetch_as_markdown: {"name":"fetch_as_markdown","title":"Read page as Markdown","description":"Return the main content of the current page as Markdown. Long pages come in parts: when `truncated` is true, call again with `cursor` set to `next_cursor`.","inputSchema":{"type":"object","properties":{"cursor":{"type":"string","description":"The next_cursor value from a previous call."},"max_chars":{"type":"integer","minimum":100,"maximum":20000,"default":2000,"description":"Maximum characters to return."}},"additionalProperties":false},"annotations":{"readOnlyHint":true,"untrustedContentHint":true}},
|
|
7
|
+
search_site: {"name":"search_site","title":"Search this website","description":"Search the whole website and return matching pages with title, URL and a short snippet. Use it to find pages other than the current one.","inputSchema":{"type":"object","properties":{"query":{"type":"string","description":"Words to look for."},"limit":{"type":"integer","minimum":1,"maximum":20,"default":5,"description":"Maximum pages to return."}},"required":["query"],"additionalProperties":false},"annotations":{"readOnlyHint":true,"untrustedContentHint":true}},
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Error code -> retryable. */
|
|
11
|
+
export const CODES = {
|
|
12
|
+
INVALID_INPUT: false,
|
|
13
|
+
NOT_FOUND: false,
|
|
14
|
+
RESULT_TOO_LARGE: false,
|
|
15
|
+
REFUSED: false,
|
|
16
|
+
RATE_LIMITED: true,
|
|
17
|
+
REQUIRES_USER: false,
|
|
18
|
+
UNAVAILABLE: true,
|
|
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
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export type ToolName = "get_page_context" | "search_page" | "fetch_as_markdown" | "search_site";
|
|
2
|
+
|
|
3
|
+
export interface SiteSearchResult {
|
|
4
|
+
title: string;
|
|
5
|
+
url: string;
|
|
6
|
+
snippet?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface Options {
|
|
10
|
+
/** Tools to register. Default: get_page_context, search_page, fetch_as_markdown (+ search_site when `searchSite` is set). */
|
|
11
|
+
tools?: ToolName[];
|
|
12
|
+
/** Element (or selector) holding the main content. Default: `main`, `[role=main]`, `article`, else `body`. */
|
|
13
|
+
root?: Element | string;
|
|
14
|
+
/** Backend search for `search_site`. Return results best first. */
|
|
15
|
+
searchSite?: (query: string, options: { limit: number; signal?: AbortSignal }) => Promise<SiteSearchResult[]>;
|
|
16
|
+
/** Where to load the WebAssembly module from (URL, Response or bytes). Default: next to the loader. */
|
|
17
|
+
wasm?: string | URL | Response | BufferSource | WebAssembly.Module;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Registration {
|
|
21
|
+
registered: ToolName[];
|
|
22
|
+
failed: { name: string; error: string }[];
|
|
23
|
+
/** Unregisters every tool registered by this call. */
|
|
24
|
+
dispose(): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Registers webmcpx standard tools. A no-op (empty `registered`) where WebMCP is unavailable. */
|
|
28
|
+
export function register(options?: Options): Promise<Registration>;
|
|
29
|
+
|
|
30
|
+
/** The tool contracts (name, title, description, inputSchema, annotations). */
|
|
31
|
+
export const TOOLS: Record<ToolName, {
|
|
32
|
+
name: ToolName;
|
|
33
|
+
title: string;
|
|
34
|
+
description: string;
|
|
35
|
+
inputSchema: object;
|
|
36
|
+
annotations: Record<string, boolean>;
|
|
37
|
+
}>;
|
|
38
|
+
|
|
39
|
+
/** What every tool resolves with (as a JSON string). */
|
|
40
|
+
export type Envelope<T = unknown> =
|
|
41
|
+
| { ok: true; data: T; truncated?: true; next_cursor?: string; hint?: string }
|
|
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
ADDED
package/src/loader.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
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.
|
|
3
|
+
import { CODES, TOOLS } from "./generated.js";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TOOLS = ["get_page_context", "search_page", "fetch_as_markdown"];
|
|
6
|
+
|
|
7
|
+
const envelope = (body) => JSON.stringify(body);
|
|
8
|
+
const ok = (data) => envelope({ ok: true, data });
|
|
9
|
+
const err = (code, message, hint) =>
|
|
10
|
+
envelope({ ok: false, error: { code, message, retryable: CODES[code] }, ...(hint && { hint }) });
|
|
11
|
+
|
|
12
|
+
let wasm;
|
|
13
|
+
const engine = (source) =>
|
|
14
|
+
(wasm ??= import("../wasm/webmcpx_wasm.js")
|
|
15
|
+
.then(async (m) => (await m.default(source ? { module_or_path: source } : undefined), m))
|
|
16
|
+
.catch((e) => {
|
|
17
|
+
wasm = undefined;
|
|
18
|
+
throw e;
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// "blocks": main content as a webmcpx-std block stream; "context": the page. Self-contained:
|
|
22
|
+
// agent/ runs its source in pages. Internal, not public API.
|
|
23
|
+
export function readPage(what, root) {
|
|
24
|
+
const RECORD = "\x1e";
|
|
25
|
+
const FIELD = "\x1f";
|
|
26
|
+
const KINDS = {
|
|
27
|
+
H1: "h1", H2: "h2", H3: "h3", H4: "h4", H5: "h5", H6: "h6",
|
|
28
|
+
P: "p", LI: "li", PRE: "pre", BLOCKQUOTE: "quote",
|
|
29
|
+
DT: "p", DD: "p", FIGCAPTION: "p", SUMMARY: "p",
|
|
30
|
+
};
|
|
31
|
+
const SKIP = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "SVG", "NAV", "FOOTER", "ASIDE"]);
|
|
32
|
+
const base = location.href.split("#")[0];
|
|
33
|
+
const text = (s) => (s ?? "").replace(/\s+/g, " ").trim();
|
|
34
|
+
if (what === "context") {
|
|
35
|
+
return {
|
|
36
|
+
title: document.title,
|
|
37
|
+
url: location.href,
|
|
38
|
+
lang: document.documentElement.lang || undefined,
|
|
39
|
+
description: document.querySelector('meta[name="description"]')?.getAttribute("content") || undefined,
|
|
40
|
+
headings: [...document.querySelectorAll("h1, h2, h3")].slice(0, 30).map((h) => ({
|
|
41
|
+
level: Number(h.tagName[1]),
|
|
42
|
+
text: text(h.textContent).slice(0, 120),
|
|
43
|
+
url: h.id ? `${base}#${h.id}` : undefined,
|
|
44
|
+
})),
|
|
45
|
+
selection: text(globalThis.getSelection?.()?.toString()).slice(0, 1000) || undefined,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const skipped = (el) => SKIP.has(el.tagName) || el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true";
|
|
49
|
+
let out = "";
|
|
50
|
+
let anchor = "";
|
|
51
|
+
const visit = (el) => {
|
|
52
|
+
for (const child of el.children) {
|
|
53
|
+
if (skipped(child)) continue;
|
|
54
|
+
if (child.id) anchor = `${base}#${child.id}`;
|
|
55
|
+
// 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;
|
|
59
|
+
else visit(child);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
visit(root ?? document.querySelector("main, [role=main], article") ?? document.body);
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const contentRoot = (root) => (typeof root === "string" ? document.querySelector(root) ?? document.body : root);
|
|
67
|
+
|
|
68
|
+
function executors(options) {
|
|
69
|
+
const withEngine = (run) => async (input) => {
|
|
70
|
+
let m;
|
|
71
|
+
try {
|
|
72
|
+
m = await engine(options.wasm);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return err("UNAVAILABLE", `WebAssembly could not load: ${e?.message ?? e}`,
|
|
75
|
+
"If the page sets a Content-Security-Policy, add 'wasm-unsafe-eval' to script-src.");
|
|
76
|
+
}
|
|
77
|
+
return run(m, readPage("blocks", contentRoot(options.root)), input);
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
get_page_context: async () => ok(readPage("context")),
|
|
81
|
+
search_page: withEngine((m, page, { query, limit }) =>
|
|
82
|
+
typeof query === "string" ? m.search(page, query, limit ?? 0) : err("INVALID_INPUT", "`query` must be a string")),
|
|
83
|
+
fetch_as_markdown: withEngine((m, page, { cursor, max_chars }) => m.markdown(page, String(cursor ?? ""), max_chars ?? 0)),
|
|
84
|
+
search_site: async ({ query, limit = 5 }, { signal } = {}) => {
|
|
85
|
+
if (typeof query !== "string" || !query.trim()) return err("INVALID_INPUT", "`query` must be a non-empty string");
|
|
86
|
+
const found = await options.searchSite(query, { limit, signal });
|
|
87
|
+
if (!found?.length) return err("NOT_FOUND", "no page matches the query", "Try fewer or different words.");
|
|
88
|
+
return ok({ results: found.slice(0, limit).map(({ title, url, snippet }) => ({ title, url, snippet })) });
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Tools resolve with an envelope and never reject: hosts drop rejection reasons (webmcp#282). */
|
|
94
|
+
const guard = (run) => async (input, client) => {
|
|
95
|
+
try {
|
|
96
|
+
return await run(typeof input === "string" ? JSON.parse(input || "{}") : input ?? {}, client);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
return err("UNAVAILABLE", String(e?.message ?? e));
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Registers webmcpx standard tools on the current document.
|
|
104
|
+
* @param {import("./index").Options} [options]
|
|
105
|
+
* @returns {Promise<import("./index").Registration>}
|
|
106
|
+
*/
|
|
107
|
+
export async function register(options = {}) {
|
|
108
|
+
const mc = globalThis.document?.modelContext ?? globalThis.navigator?.modelContext;
|
|
109
|
+
const controller = new AbortController();
|
|
110
|
+
const result = { registered: [], failed: [], dispose: () => controller.abort() };
|
|
111
|
+
if (typeof mc?.registerTool !== "function") return result;
|
|
112
|
+
|
|
113
|
+
const run = executors(options);
|
|
114
|
+
const names = options.tools ?? [...DEFAULT_TOOLS, ...(options.searchSite ? ["search_site"] : [])];
|
|
115
|
+
await Promise.all(names.map(async (name) => {
|
|
116
|
+
if (!TOOLS[name]) return result.failed.push({ name, error: "unknown tool" });
|
|
117
|
+
if (name === "search_site" && !options.searchSite) {
|
|
118
|
+
return result.failed.push({ name, error: "search_site needs options.searchSite" });
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
// Never assign onto `mc`: some agent browsers expose a frozen object (webmcp#293).
|
|
122
|
+
await mc.registerTool({ ...TOOLS[name], execute: guard(run[name]) }, { signal: controller.signal });
|
|
123
|
+
result.registered.push(name);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
result.failed.push({ name, error: String(e?.message ?? e) });
|
|
126
|
+
}
|
|
127
|
+
}));
|
|
128
|
+
result.registered.sort();
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export { TOOLS };
|
package/src/react.d.ts
ADDED
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
|
+
}
|
package/src/svelte.d.ts
ADDED
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
|
+
}
|
package/src/tool.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
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.
|
|
4
|
+
import { CODES, LIMITS } from "./generated.js";
|
|
5
|
+
|
|
6
|
+
const context = () => globalThis.document?.modelContext ?? globalThis.navigator?.modelContext;
|
|
7
|
+
const envelope = (body) => JSON.stringify(body);
|
|
8
|
+
const fail = (code, message, hint) =>
|
|
9
|
+
envelope({ ok: false, error: { code, message, retryable: CODES[code] }, ...(hint && { hint }) });
|
|
10
|
+
const NAME = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
11
|
+
let count = 0;
|
|
12
|
+
|
|
13
|
+
/** Input shorthand to JSON Schema: `{ sku: "string", qty: "number?", size: ["S", "M"] }` (see index.d.ts). */
|
|
14
|
+
export function schema(input = {}) {
|
|
15
|
+
const properties = {};
|
|
16
|
+
const required = [];
|
|
17
|
+
for (const [rawKey, spec] of Object.entries(input)) {
|
|
18
|
+
const key = rawKey.replace(/\?$/, "");
|
|
19
|
+
const optional = rawKey !== key || (typeof spec === "string" && spec.endsWith("?"));
|
|
20
|
+
properties[key] =
|
|
21
|
+
typeof spec === "string" ? { type: spec.replace(/\?$/, "") } : Array.isArray(spec) ? { enum: spec } : spec;
|
|
22
|
+
if (!optional) required.push(key);
|
|
23
|
+
}
|
|
24
|
+
return { type: "object", properties, ...(required.length && { required }) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const TYPES = {
|
|
28
|
+
string: (v) => typeof v === "string",
|
|
29
|
+
number: (v) => typeof v === "number" && Number.isFinite(v),
|
|
30
|
+
integer: Number.isInteger,
|
|
31
|
+
boolean: (v) => typeof v === "boolean",
|
|
32
|
+
array: Array.isArray,
|
|
33
|
+
null: (v) => v === null,
|
|
34
|
+
object: (v) => v !== null && typeof v === "object" && !Array.isArray(v),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The first problem with `value` against JSON Schema `s` (the keywords tool inputs use), or "". */
|
|
38
|
+
function check(s, value, at = "input") {
|
|
39
|
+
if (!s || typeof s !== "object") return "";
|
|
40
|
+
const types = s.type === undefined ? [] : [].concat(s.type);
|
|
41
|
+
if (types.length && !types.some((t) => TYPES[t]?.(value))) return `${at} must be of type ${types.join(" or ")}`;
|
|
42
|
+
if (s.enum && !s.enum.includes(value)) return `${at} must be one of ${s.enum.join(", ")}`;
|
|
43
|
+
if (typeof value === "number") {
|
|
44
|
+
if (value < s.minimum) return `${at} must be at least ${s.minimum}`;
|
|
45
|
+
if (value > s.maximum) return `${at} must be at most ${s.maximum}`;
|
|
46
|
+
}
|
|
47
|
+
if (typeof value === "string") {
|
|
48
|
+
if (value.length < s.minLength) return `${at} must have at least ${s.minLength} chars`;
|
|
49
|
+
if (value.length > s.maxLength) return `${at} must have at most ${s.maxLength} chars`;
|
|
50
|
+
if (s.pattern && !new RegExp(s.pattern, "u").test(value)) return `${at} must match ${s.pattern}`;
|
|
51
|
+
}
|
|
52
|
+
if (Array.isArray(value)) {
|
|
53
|
+
if (value.length < s.minItems) return `${at} must have at least ${s.minItems} items`;
|
|
54
|
+
if (value.length > s.maxItems) return `${at} must have at most ${s.maxItems} items`;
|
|
55
|
+
for (let i = 0; i < value.length; i++) {
|
|
56
|
+
const problem = check(s.items, value[i], `${at}[${i}]`);
|
|
57
|
+
if (problem) return problem;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (TYPES.object(value)) {
|
|
61
|
+
// Own properties only ("__proto__" is not a declared input).
|
|
62
|
+
for (const key of s.required ?? []) if (!Object.hasOwn(value, key) || value[key] === undefined) return `\`${key}\` is required`;
|
|
63
|
+
for (const [key, v] of Object.entries(value)) {
|
|
64
|
+
const p = s.properties && Object.hasOwn(s.properties, key) ? s.properties[key] : undefined;
|
|
65
|
+
if (!p && s.additionalProperties === false) return `\`${key}\` is not an input of this tool`;
|
|
66
|
+
const problem = p && v !== undefined ? check(p, v, `\`${key}\``) : "";
|
|
67
|
+
if (problem) return problem;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Wraps `run` in the standard contract: parsed and checked input, `{ok, data}` or `{ok: false, error}`. */
|
|
74
|
+
const contract = (inputSchema, run) => async (raw, client) => {
|
|
75
|
+
let args;
|
|
76
|
+
try {
|
|
77
|
+
// Omitted input is {} (as in the spec); anything that is not an object is refused below.
|
|
78
|
+
args = typeof raw === "string" ? JSON.parse(raw || "{}") : raw === undefined ? {} : raw;
|
|
79
|
+
} catch {
|
|
80
|
+
return fail("INVALID_INPUT", "the input is not JSON");
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const problem = TYPES.object(args) ? check(inputSchema, args) : "the input must be an object";
|
|
84
|
+
if (problem) return fail("INVALID_INPUT", problem);
|
|
85
|
+
const out = envelope({ ok: true, data: (await run(args, client)) ?? null });
|
|
86
|
+
return out.length > LIMITS.OUTPUT_MAX
|
|
87
|
+
? fail("RESULT_TOO_LARGE", `the result has ${out.length} chars`, `Return at most ${LIMITS.OUTPUT_MAX}: page it.`)
|
|
88
|
+
: out;
|
|
89
|
+
} catch (e) {
|
|
90
|
+
return fail(e?.code in CODES ? e.code : "UNAVAILABLE", String(e?.message ?? e));
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const warn = (name, message) => console.warn(`webmcpx: tool "${name}": ${message}`);
|
|
95
|
+
|
|
96
|
+
function register(definition, signal) {
|
|
97
|
+
const mc = context();
|
|
98
|
+
const { name, description } = definition;
|
|
99
|
+
if (!NAME.test(name ?? "")) warn(name, "names are 1-128 of A-Z a-z 0-9 _ . -");
|
|
100
|
+
else if (name.length > LIMITS.NAME_WARN) warn(name, `keep names under ${LIMITS.NAME_WARN} chars`);
|
|
101
|
+
if (!description) warn(name, "say what it does and when to use it in `description`");
|
|
102
|
+
else if (description.length > LIMITS.DESCRIPTION_WARN) warn(name, `keep the description under ${LIMITS.DESCRIPTION_WARN} chars`);
|
|
103
|
+
if (++count === LIMITS.TOOLS_WARN + 1) console.warn(`webmcpx: more than ${LIMITS.TOOLS_WARN} tools on one page`);
|
|
104
|
+
if (typeof mc?.registerTool !== "function") return Promise.resolve(false);
|
|
105
|
+
signal.addEventListener("abort", () => count--, { once: true });
|
|
106
|
+
// Never assign onto `mc`: some agent browsers expose a frozen object (webmcp#293).
|
|
107
|
+
return mc.registerTool(definition, { signal }).then(
|
|
108
|
+
() => true,
|
|
109
|
+
(e) => (warn(name, e?.message ?? e), false),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
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
|
+
*/
|
|
119
|
+
export function tool({ name, description, input, inputSchema = schema(input), run, readOnly, acts, untrusted, signal } = {}) {
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
if (signal?.aborted) return { ready: Promise.resolve(false), dispose() {} };
|
|
122
|
+
signal?.addEventListener("abort", () => controller.abort(), { once: true });
|
|
123
|
+
const ready = register(
|
|
124
|
+
{
|
|
125
|
+
name,
|
|
126
|
+
description,
|
|
127
|
+
inputSchema,
|
|
128
|
+
annotations: {
|
|
129
|
+
...(readOnly && { readOnlyHint: true }),
|
|
130
|
+
...(acts && { consequentialHint: true }),
|
|
131
|
+
...(untrusted && { untrustedContentHint: true }),
|
|
132
|
+
},
|
|
133
|
+
execute: contract(inputSchema, run),
|
|
134
|
+
},
|
|
135
|
+
controller.signal,
|
|
136
|
+
);
|
|
137
|
+
return { ready, dispose: () => controller.abort() };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const TOOL_ATTRIBUTES = ["toolname", "tooldescription", "toolautosubmit"];
|
|
141
|
+
// What a control contributes to the schema (option values included).
|
|
142
|
+
const CONTROL_ATTRIBUTES = ["name", "type", "required", "disabled", "min", "max", "multiple", "value", "toolparamdescription", "method"];
|
|
143
|
+
const FORMATS = { email: "email", url: "uri", date: "date", "datetime-local": "date-time", time: "time" };
|
|
144
|
+
const SKIPPED = /^(submit|button|reset|image|file|password|hidden)$/;
|
|
145
|
+
|
|
146
|
+
/** A form's input schema, per the declarative explainer: control `name`s, `toolparamdescription`s. */
|
|
147
|
+
export function formSchema(form) {
|
|
148
|
+
const properties = {};
|
|
149
|
+
const required = [];
|
|
150
|
+
for (const el of form.elements) {
|
|
151
|
+
const { name, type } = el;
|
|
152
|
+
if (!name || el.disabled || SKIPPED.test(type) || !("value" in el) || el.tagName === "BUTTON") continue;
|
|
153
|
+
let p;
|
|
154
|
+
if (type === "checkbox") p = { type: "boolean" };
|
|
155
|
+
else if (type === "radio") (p = properties[name] ?? { type: "string", enum: [] }).enum.push(el.value);
|
|
156
|
+
else if (el.tagName === "SELECT") {
|
|
157
|
+
p = { type: "string", enum: [...el.options].map((o) => o.value) };
|
|
158
|
+
if (el.multiple) p = { type: "array", items: p };
|
|
159
|
+
} else if (type === "number" || type === "range") {
|
|
160
|
+
p = { type: "number", ...(el.min && { minimum: +el.min }), ...(el.max && { maximum: +el.max }) };
|
|
161
|
+
} else p = { type: "string", ...(FORMATS[type] && { format: FORMATS[type] }) };
|
|
162
|
+
const description =
|
|
163
|
+
el.getAttribute("toolparamdescription") || el.labels?.[0]?.textContent.trim() || el.getAttribute("placeholder");
|
|
164
|
+
if (description) p.description = description;
|
|
165
|
+
properties[name] = p;
|
|
166
|
+
if (el.required && !required.includes(name)) required.push(name);
|
|
167
|
+
}
|
|
168
|
+
return { type: "object", properties, ...(required.length && { required }) };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function fill(form, args) {
|
|
172
|
+
for (const [name, value] of Object.entries(args)) {
|
|
173
|
+
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);
|
|
179
|
+
// Frameworks listen for these, not for property writes.
|
|
180
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
181
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Fills the form; with `toolautosubmit`, submits it and returns what `respondWith()` gave. */
|
|
187
|
+
function runForm(form) {
|
|
188
|
+
return async (args) => {
|
|
189
|
+
fill(form, args);
|
|
190
|
+
const invalid = [...form.elements].find((el) => el.willValidate && !el.checkValidity());
|
|
191
|
+
if (invalid) throw Object.assign(new Error(`\`${invalid.name}\`: ${invalid.validationMessage}`), { code: "INVALID_INPUT" });
|
|
192
|
+
if (!form.hasAttribute("toolautosubmit")) {
|
|
193
|
+
form.querySelector("[type=submit], button:not([type])")?.focus();
|
|
194
|
+
return { submitted: false, next: "Ask the user to review the form and submit it." };
|
|
195
|
+
}
|
|
196
|
+
let response;
|
|
197
|
+
// The explainer's SubmitEvent#agentInvoked and #respondWith(), for the page's submit handlers.
|
|
198
|
+
const mark = (event) => {
|
|
199
|
+
if (event.target !== form) return;
|
|
200
|
+
Object.defineProperties(event, {
|
|
201
|
+
agentInvoked: { value: true },
|
|
202
|
+
respondWith: { value: (promise) => void (response = promise) },
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
addEventListener("submit", mark, true);
|
|
206
|
+
try {
|
|
207
|
+
form.requestSubmit();
|
|
208
|
+
} finally {
|
|
209
|
+
removeEventListener("submit", mark, true);
|
|
210
|
+
}
|
|
211
|
+
return response ? await response : { submitted: true, next: "The page may navigate: read it for the result." };
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
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
|
+
*/
|
|
220
|
+
export function forms() {
|
|
221
|
+
const doc = globalThis.document;
|
|
222
|
+
if (!doc || typeof context()?.registerTool !== "function" || "toolName" in HTMLFormElement.prototype) {
|
|
223
|
+
return { dispose() {} };
|
|
224
|
+
}
|
|
225
|
+
const live = new Map();
|
|
226
|
+
// A form's tool is its attributes plus the schema of its controls: re-registered when either changes.
|
|
227
|
+
const key = (form) =>
|
|
228
|
+
JSON.stringify([...TOOL_ATTRIBUTES.map((a) => form.getAttribute(a)), form.method, formSchema(form)]);
|
|
229
|
+
const sync = () => {
|
|
230
|
+
for (const [form, entry] of live) {
|
|
231
|
+
if (!form.isConnected || !form.hasAttribute("toolname") || entry.key !== key(form)) {
|
|
232
|
+
entry.controller.abort();
|
|
233
|
+
live.delete(form);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
for (const form of doc.querySelectorAll("form[toolname]")) {
|
|
237
|
+
if (live.has(form)) continue;
|
|
238
|
+
const controller = new AbortController();
|
|
239
|
+
const inputSchema = formSchema(form);
|
|
240
|
+
live.set(form, { key: key(form), controller });
|
|
241
|
+
register(
|
|
242
|
+
{
|
|
243
|
+
// Names allow [A-Za-z0-9_.-]: "Search flights" becomes "Search_flights".
|
|
244
|
+
name: form.getAttribute("toolname").trim().replace(/[^A-Za-z0-9_.-]+/g, "_"),
|
|
245
|
+
description: form.getAttribute("tooldescription") ?? "",
|
|
246
|
+
inputSchema,
|
|
247
|
+
annotations: form.method === "get" ? { readOnlyHint: true } : { consequentialHint: true },
|
|
248
|
+
execute: contract(inputSchema, runForm(form)),
|
|
249
|
+
},
|
|
250
|
+
controller.signal,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
sync();
|
|
255
|
+
const observer = new MutationObserver(sync);
|
|
256
|
+
observer.observe(doc.documentElement, {
|
|
257
|
+
subtree: true,
|
|
258
|
+
childList: true,
|
|
259
|
+
attributes: true,
|
|
260
|
+
attributeFilter: [...TOOL_ATTRIBUTES, ...CONTROL_ATTRIBUTES],
|
|
261
|
+
});
|
|
262
|
+
return {
|
|
263
|
+
dispose() {
|
|
264
|
+
observer.disconnect();
|
|
265
|
+
for (const { controller } of live.values()) controller.abort();
|
|
266
|
+
live.clear();
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
package/src/vue.d.ts
ADDED
package/src/vue.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Vue 2.7 and 3: `useTool(definition)` registers the tool for the component's life. Pass a getter or a
|
|
2
|
+
// ref to re-register when the definition changes.
|
|
3
|
+
import { unref, watchEffect } from "vue";
|
|
4
|
+
import { tool } from "./tool.js";
|
|
5
|
+
|
|
6
|
+
export function useTool(definition) {
|
|
7
|
+
watchEffect((onCleanup) => {
|
|
8
|
+
const current = typeof definition === "function" ? definition() : unref(definition);
|
|
9
|
+
onCleanup(tool(current).dispose);
|
|
10
|
+
});
|
|
11
|
+
}
|