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/src/tool.js ADDED
@@ -0,0 +1,271 @@
1
+ // Your own tools, with the standard tools' contract, and <form toolname> (webmcp's
2
+ // declarative-api-explainer.md) where the browser lacks it.
3
+ import { CODES, LIMITS } from "./generated.js";
4
+
5
+ const context = () => globalThis.document?.modelContext ?? globalThis.navigator?.modelContext;
6
+ const envelope = (body) => JSON.stringify(body);
7
+ const fail = (code, message, hint) =>
8
+ envelope({ ok: false, error: { code, message, retryable: CODES[code] }, ...(hint && { hint }) });
9
+ const NAME = /^[A-Za-z0-9_.-]{1,128}$/;
10
+ let count = 0;
11
+
12
+ /** Input shorthand to JSON Schema: `{ sku: "string", qty: "number?", size: ["S", "M"] }` (see index.d.ts). */
13
+ export function schema(input = {}) {
14
+ const properties = {};
15
+ const required = [];
16
+ for (const [rawKey, spec] of Object.entries(input)) {
17
+ const key = rawKey.replace(/\?$/, "");
18
+ const optional = rawKey !== key || (typeof spec === "string" && spec.endsWith("?"));
19
+ properties[key] =
20
+ typeof spec === "string" ? { type: spec.replace(/\?$/, "") } : Array.isArray(spec) ? { enum: spec } : spec;
21
+ if (!optional) required.push(key);
22
+ }
23
+ return { type: "object", properties, ...(required.length && { required }) };
24
+ }
25
+
26
+ const TYPES = {
27
+ string: (v) => typeof v === "string",
28
+ number: (v) => typeof v === "number" && Number.isFinite(v),
29
+ integer: Number.isInteger,
30
+ boolean: (v) => typeof v === "boolean",
31
+ array: Array.isArray,
32
+ null: (v) => v === null,
33
+ object: (v) => v !== null && typeof v === "object" && !Array.isArray(v),
34
+ };
35
+
36
+ /** The first problem with `value` against JSON Schema `s` (the keywords tool inputs use), or "". */
37
+ function check(s, value, at = "input") {
38
+ if (!s || typeof s !== "object") return "";
39
+ const types = s.type === undefined ? [] : [].concat(s.type);
40
+ if (types.length && !types.some((t) => TYPES[t]?.(value))) return `${at} must be of type ${types.join(" or ")}`;
41
+ if (s.enum && !s.enum.includes(value)) return `${at} must be one of ${s.enum.join(", ")}`;
42
+ if (typeof value === "number") {
43
+ if (value < s.minimum) return `${at} must be at least ${s.minimum}`;
44
+ if (value > s.maximum) return `${at} must be at most ${s.maximum}`;
45
+ }
46
+ if (typeof value === "string") {
47
+ if (value.length < s.minLength) return `${at} must have at least ${s.minLength} chars`;
48
+ if (value.length > s.maxLength) return `${at} must have at most ${s.maxLength} chars`;
49
+ if (s.pattern && !new RegExp(s.pattern, "u").test(value)) return `${at} must match ${s.pattern}`;
50
+ }
51
+ if (Array.isArray(value)) {
52
+ if (value.length < s.minItems) return `${at} must have at least ${s.minItems} items`;
53
+ if (value.length > s.maxItems) return `${at} must have at most ${s.maxItems} items`;
54
+ for (let i = 0; i < value.length; i++) {
55
+ const problem = check(s.items, value[i], `${at}[${i}]`);
56
+ if (problem) return problem;
57
+ }
58
+ }
59
+ if (TYPES.object(value)) {
60
+ // Own properties only ("__proto__" is not a declared input).
61
+ for (const key of s.required ?? []) if (!Object.hasOwn(value, key) || value[key] === undefined) return `\`${key}\` is required`;
62
+ for (const [key, v] of Object.entries(value)) {
63
+ const p = s.properties && Object.hasOwn(s.properties, key) ? s.properties[key] : undefined;
64
+ if (!p && s.additionalProperties === false) return `\`${key}\` is not an input of this tool`;
65
+ const problem = p && v !== undefined ? check(p, v, `\`${key}\``) : "";
66
+ if (problem) return problem;
67
+ }
68
+ }
69
+ return "";
70
+ }
71
+
72
+ /** `run` in the standard contract: checked input, `{ok, data}` or `{ok: false, error}`. */
73
+ const contract = (inputSchema, run) => async (raw, client) => {
74
+ let args;
75
+ try {
76
+ // Omitted input is {} (as in the spec); anything that is not an object is refused below.
77
+ args = typeof raw === "string" ? JSON.parse(raw || "{}") : raw === undefined ? {} : raw;
78
+ } catch {
79
+ return fail("INVALID_INPUT", "the input is not JSON");
80
+ }
81
+ try {
82
+ const problem = TYPES.object(args) ? check(inputSchema, args) : "the input must be an object";
83
+ if (problem) return fail("INVALID_INPUT", problem);
84
+ const out = envelope({ ok: true, data: (await run(args, client)) ?? null });
85
+ return out.length > LIMITS.OUTPUT_MAX
86
+ ? fail("RESULT_TOO_LARGE", `the result has ${out.length} chars`, `Return at most ${LIMITS.OUTPUT_MAX}: page it.`)
87
+ : out;
88
+ } catch (e) {
89
+ return fail(e?.code in CODES ? e.code : "UNAVAILABLE", String(e?.message ?? e));
90
+ }
91
+ };
92
+
93
+ const warn = (name, message) => console.warn(`webmcpx: tool "${name}": ${message}`);
94
+
95
+ function register(definition, signal) {
96
+ const mc = context();
97
+ const { name, description } = definition;
98
+ if (!NAME.test(name ?? "")) warn(name, "names are 1-128 of A-Z a-z 0-9 _ . -");
99
+ else if (name.length > LIMITS.NAME_WARN) warn(name, `keep names under ${LIMITS.NAME_WARN} chars`);
100
+ if (!description) warn(name, "say what it does and when to use it in `description`");
101
+ else if (description.length > LIMITS.DESCRIPTION_WARN) warn(name, `keep the description under ${LIMITS.DESCRIPTION_WARN} chars`);
102
+ if (++count === LIMITS.TOOLS_WARN + 1) console.warn(`webmcpx: more than ${LIMITS.TOOLS_WARN} tools on one page`);
103
+ if (typeof mc?.registerTool !== "function") return Promise.resolve(false);
104
+ signal.addEventListener("abort", () => count--, { once: true });
105
+ // Never assign onto `mc`: some agent browsers expose a frozen object (webmcp#293).
106
+ return mc.registerTool(definition, { signal }).then(
107
+ () => true,
108
+ (e) => (warn(name, e?.message ?? e), false),
109
+ );
110
+ }
111
+
112
+ /** Registers one of your own tools (types and docs: index.d.ts). */
113
+ export function tool({ name, description, input, inputSchema = schema(input), run, readOnly, acts, untrusted, signal } = {}) {
114
+ const controller = new AbortController();
115
+ if (signal?.aborted) return { ready: Promise.resolve(false), dispose() {} };
116
+ signal?.addEventListener("abort", () => controller.abort(), { once: true });
117
+ const ready = register(
118
+ {
119
+ name,
120
+ description,
121
+ inputSchema,
122
+ annotations: {
123
+ ...(readOnly && { readOnlyHint: true }),
124
+ ...(acts && { consequentialHint: true }),
125
+ ...(untrusted && { untrustedContentHint: true }),
126
+ },
127
+ execute: contract(inputSchema, run),
128
+ },
129
+ controller.signal,
130
+ );
131
+ return { ready, dispose: () => controller.abort() };
132
+ }
133
+
134
+ const TOOL_ATTRIBUTES = ["toolname", "tooldescription", "toolautosubmit"];
135
+ // What a control contributes to the schema (option values included).
136
+ const CONTROL_ATTRIBUTES = ["name", "type", "required", "disabled", "readonly", "min", "max", "multiple", "value", "toolparamdescription", "method"];
137
+ const FORMATS = { email: "email", url: "uri", date: "date", "datetime-local": "date-time", time: "time" };
138
+ const SKIPPED = /^(submit|button|reset|image|file|password|hidden)$/;
139
+
140
+ /** A form's input schema, per the declarative explainer: control `name`s, `toolparamdescription`s. */
141
+ export function formSchema(form) {
142
+ const properties = {};
143
+ const required = [];
144
+ for (const el of form.elements) {
145
+ const { name, type } = el;
146
+ if (!fillable(el)) continue;
147
+ let p;
148
+ // Several checkboxes with one name: a set of choices.
149
+ if (type === "checkbox" && [...form.elements].filter((e) => e.name === name && fillable(e)).length > 1) {
150
+ (p = properties[name] ?? { type: "array", items: { enum: [] } }).items.enum.push(el.value);
151
+ } else if (type === "checkbox") p = { type: "boolean" };
152
+ else if (type === "radio") (p = properties[name] ?? { type: "string", enum: [] }).enum.push(el.value);
153
+ else if (el.tagName === "SELECT") {
154
+ p = { type: "string", enum: [...el.options].map((o) => o.value) };
155
+ if (el.multiple) p = { type: "array", items: p };
156
+ } else if (type === "number" || type === "range") {
157
+ p = { type: "number", ...(el.min && { minimum: +el.min }), ...(el.max && { maximum: +el.max }) };
158
+ } else p = { type: "string", ...(FORMATS[type] && { format: FORMATS[type] }) };
159
+ const description =
160
+ el.getAttribute("toolparamdescription") || (!p.items && (el.labels?.[0]?.textContent.trim() || el.getAttribute("placeholder")));
161
+ if (description) p.description = description;
162
+ properties[name] = p;
163
+ if (el.required && !required.includes(name)) required.push(name);
164
+ }
165
+ // Only fields it shows: never hidden, password or disabled ones.
166
+ return { type: "object", properties, ...(required.length && { required }), additionalProperties: false };
167
+ }
168
+
169
+ /** A control a user could edit: named, enabled (fieldsets too), not readonly, hidden, password or a file. */
170
+ const fillable = (el) =>
171
+ el.name && !el.matches(":disabled, [readonly]") && !SKIPPED.test(el.type) && "value" in el && el.tagName !== "BUTTON";
172
+
173
+ function fill(form, args) {
174
+ for (const [name, value] of Object.entries(args)) {
175
+ const field = form.elements.namedItem(name);
176
+ for (const el of (field instanceof Element ? [field] : [...(field ?? [])]).filter(fillable)) {
177
+ // Click / native setter: frameworks (React) ignore plain property writes.
178
+ const on = el.type === "checkbox" ? ([].concat(value).includes(el.value) || value === true) : el.value === String(value);
179
+ if (el.type === "checkbox" || el.type === "radio") {
180
+ if (el.checked !== on) el.click();
181
+ continue;
182
+ }
183
+ if (el.multiple) for (const o of el.options) o.selected = [].concat(value).includes(o.value);
184
+ else Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value").set.call(el, String(value));
185
+ // Frameworks listen for these, not for property writes.
186
+ el.dispatchEvent(new Event("input", { bubbles: true }));
187
+ el.dispatchEvent(new Event("change", { bubbles: true }));
188
+ }
189
+ }
190
+ }
191
+
192
+ /** Fills the form; with `toolautosubmit`, submits it and returns what `respondWith()` gave. */
193
+ function runForm(form) {
194
+ return async (args) => {
195
+ fill(form, args);
196
+ const invalid = [...form.elements].find((el) => el.willValidate && !el.checkValidity());
197
+ if (invalid) throw Object.assign(new Error(`\`${invalid.name}\`: ${invalid.validationMessage}`), { code: "INVALID_INPUT" });
198
+ if (!form.hasAttribute("toolautosubmit")) {
199
+ form.querySelector("[type=submit], button:not([type])")?.focus();
200
+ return { submitted: false, next: "Ask the user to review the form and submit it." };
201
+ }
202
+ let response;
203
+ // The explainer's SubmitEvent#agentInvoked and #respondWith(), for the page's submit handlers.
204
+ const mark = (event) => {
205
+ if (event.target !== form) return;
206
+ Object.defineProperties(event, {
207
+ agentInvoked: { value: true },
208
+ respondWith: { value: (promise) => void (response = promise) },
209
+ });
210
+ };
211
+ addEventListener("submit", mark, true);
212
+ try {
213
+ form.requestSubmit();
214
+ } finally {
215
+ removeEventListener("submit", mark, true);
216
+ }
217
+ return response ? await response : { submitted: true, next: "The page may navigate: read it for the result." };
218
+ };
219
+ }
220
+
221
+ /** Every `<form toolname>` as a tool, following DOM changes; not where the browser does it itself. */
222
+ export function forms() {
223
+ const doc = globalThis.document;
224
+ if (!doc || typeof context()?.registerTool !== "function" || "toolName" in HTMLFormElement.prototype) {
225
+ return { dispose() {} };
226
+ }
227
+ const live = new Map();
228
+ // A form's tool is its attributes plus the schema of its controls: re-registered when either changes.
229
+ const key = (form) =>
230
+ JSON.stringify([...TOOL_ATTRIBUTES.map((a) => form.getAttribute(a)), form.method, formSchema(form)]);
231
+ const sync = () => {
232
+ for (const [form, entry] of live) {
233
+ if (!form.isConnected || !form.hasAttribute("toolname") || entry.key !== key(form)) {
234
+ entry.controller.abort();
235
+ live.delete(form);
236
+ }
237
+ }
238
+ for (const form of doc.querySelectorAll("form[toolname]")) {
239
+ if (live.has(form)) continue;
240
+ const controller = new AbortController();
241
+ const inputSchema = formSchema(form);
242
+ live.set(form, { key: key(form), controller });
243
+ register(
244
+ {
245
+ // Names allow [A-Za-z0-9_.-]: "Search flights" becomes "Search_flights".
246
+ name: form.getAttribute("toolname").trim().replace(/[^A-Za-z0-9_.-]+/g, "_"),
247
+ description: form.getAttribute("tooldescription") ?? "",
248
+ inputSchema,
249
+ annotations: form.method === "get" ? { readOnlyHint: true } : { consequentialHint: true },
250
+ execute: contract(inputSchema, runForm(form)),
251
+ },
252
+ controller.signal,
253
+ );
254
+ }
255
+ };
256
+ sync();
257
+ const observer = new MutationObserver(sync);
258
+ observer.observe(doc.documentElement, {
259
+ subtree: true,
260
+ childList: true,
261
+ attributes: true,
262
+ attributeFilter: [...TOOL_ATTRIBUTES, ...CONTROL_ATTRIBUTES],
263
+ });
264
+ return {
265
+ dispose() {
266
+ observer.disconnect();
267
+ for (const { controller } of live.values()) controller.abort();
268
+ live.clear();
269
+ },
270
+ };
271
+ }
package/src/vue.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 | (() => ToolDefinition) | { value: ToolDefinition }): void;
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
+ }