@terramind/webmcp 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Terramind
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,54 @@
1
- # Temporary Holding Version
1
+ # @terramind/webmcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Registers [WebMCP](https://webmachinelearning.github.io/webmcp/) page tools from React, so agent browsers can call a site's own functions instead of driving its UI.
4
+
5
+ ```sh
6
+ bun add @terramind/webmcp
7
+ ```
8
+
9
+ ```tsx
10
+ "use client"
11
+ import { defineTool, useWebMcpTools } from "@terramind/webmcp"
12
+
13
+ const getPricing = defineTool({
14
+ name: "get_pricing",
15
+ description: "Returns the current plans and their monthly prices.",
16
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
17
+ annotations: { readOnlyHint: true },
18
+ execute: () => ({ plans: [{ name: "Pro", usdPerMonth: 20 }] }),
19
+ })
20
+
21
+ export function PricingTools() {
22
+ useWebMcpTools([getPricing])
23
+ return null
24
+ }
25
+ ```
26
+
27
+ Tools are registered with `document.modelContext.registerTool` (falling back to the older `navigator.modelContext`) and unregistered when the component unmounts. Where the browser has no WebMCP API, every call does nothing, so the page behaves as it did without tools. Nothing is polyfilled.
28
+
29
+ ## API
30
+
31
+ | Export | What it does |
32
+ | --- | --- |
33
+ | `defineTool(tool)` | Types a tool. `execute` returns a JSON-serialisable value. |
34
+ | `useWebMcpTools(tools, deps?)` | Registers `tools` while the component is mounted. Re-registers when `deps` change. |
35
+ | `WebMcpTools` | The hook as a component, for layouts that can't call hooks. |
36
+ | `registerTools(tools, options?)` | Registers outside React. Returns an unregister function. Errors go to `options.onError` and are never thrown. |
37
+ | `getModelContext()` | The browser's model context, or `null`. |
38
+ | `validateTools(tools)` | Lists every convention a set breaks; use it in a test. |
39
+
40
+ ## Conventions `validateTools` checks
41
+
42
+ - Names are snake_case, start with a letter, at most 64 characters, unique in a set.
43
+ - A description is set and at most 500 characters.
44
+ - `annotations.readOnlyHint` is always set, and a read-only tool is never `consequentialHint`.
45
+ - `inputSchema` is a JSON object schema with `additionalProperties: false` at every object level.
46
+ - A tool that takes an email address sets `consequentialHint`.
47
+
48
+ ## Browser support
49
+
50
+ WebMCP is behind an origin trial in Chrome 149 to 156. A site needs a trial token for its own origin.
51
+
52
+ ## License
53
+
54
+ MIT
@@ -0,0 +1,69 @@
1
+ export type JsonSchema = {
2
+ [key: string]: unknown;
3
+ };
4
+ export interface ToolAnnotations {
5
+ /** True when the tool only reads. Required so every tool states it. */
6
+ readOnlyHint: boolean;
7
+ /** True when the tool submits personal data or commits to something. */
8
+ consequentialHint?: boolean;
9
+ /** True when the result may contain text the site does not control. */
10
+ untrustedContentHint?: boolean;
11
+ }
12
+ export interface WebMcpTool<Input = Record<string, unknown>> {
13
+ name: string;
14
+ title?: string;
15
+ description: string;
16
+ inputSchema: JsonSchema;
17
+ annotations: ToolAnnotations;
18
+ /** Returns a JSON-serialisable value; the browser hands it to the agent as JSON. */
19
+ execute: (input: Input, options: {
20
+ signal?: AbortSignal;
21
+ }) => unknown;
22
+ }
23
+ export type AnyWebMcpTool = WebMcpTool<any>;
24
+ export declare function defineTool<Input = Record<string, unknown>>(tool: WebMcpTool<Input>): WebMcpTool<Input>;
25
+ /** The object handed to `registerTool`, as the spec's ModelContextTool. */
26
+ export interface RegisteredTool {
27
+ name: string;
28
+ title?: string;
29
+ description: string;
30
+ inputSchema: JsonSchema;
31
+ annotations: ToolAnnotations;
32
+ execute: (input: unknown, options?: {
33
+ signal?: AbortSignal;
34
+ }) => unknown;
35
+ }
36
+ /** The subset of the browser API this module touches. */
37
+ export interface ModelContextLike {
38
+ registerTool: (tool: RegisteredTool, options?: {
39
+ signal?: AbortSignal;
40
+ }) => unknown;
41
+ /** Only on the pre-July-2026 `navigator.modelContext` builds. */
42
+ unregisterTool?: (name: string) => unknown;
43
+ }
44
+ /** `document.modelContext`, else the deprecated `navigator.modelContext`, else null. */
45
+ export declare function getModelContext(): ModelContextLike | null;
46
+ /**
47
+ * Registers `tools` and returns a function that unregisters them.
48
+ * Registration failures (an older browser rejecting a field, a name another
49
+ * component already holds) are reported to `onError` and never thrown: a
50
+ * missing tool must not break the page.
51
+ */
52
+ export declare function registerTools(tools: readonly AnyWebMcpTool[], options?: {
53
+ context?: ModelContextLike | null;
54
+ onError?: (error: unknown, name: string) => void;
55
+ }): () => void;
56
+ /**
57
+ * Registers `tools` while the calling component is mounted. Pass `deps` for
58
+ * anything the tools close over; the set is re-registered when they change.
59
+ * Strict-mode double mounts are safe: the first mount's tools are aborted
60
+ * before the second registers.
61
+ */
62
+ export declare function useWebMcpTools(tools: readonly AnyWebMcpTool[], deps?: readonly unknown[]): void;
63
+ /** `useWebMcpTools` as a component, for server layouts that cannot call hooks. */
64
+ export declare function WebMcpTools({ tools }: {
65
+ tools: readonly AnyWebMcpTool[];
66
+ }): null;
67
+ /** Returns every convention a set of tools breaks; an empty list means it passes. */
68
+ export declare function validateTools(tools: readonly AnyWebMcpTool[]): string[];
69
+ //# sourceMappingURL=webmcp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webmcp.d.ts","sourceRoot":"","sources":["../src/webmcp.ts"],"names":[],"mappings":"AAkBA,MAAM,MAAM,UAAU,GAAG;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,CAAA;AAEnD,MAAM,WAAW,eAAe;IAC9B,uEAAuE;IACvE,YAAY,EAAE,OAAO,CAAA;IACrB,wEAAwE;IACxE,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,uEAAuE;IACvE,oBAAoB,CAAC,EAAE,OAAO,CAAA;CAC/B;AAED,MAAM,WAAW,UAAU,CAAC,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACzD,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,MAAM,CAAA;IACnB,WAAW,EAAE,UAAU,CAAA;IACvB,WAAW,EAAE,eAAe,CAAA;IAC5B,oFAAoF;IACpF,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,KAAK,OAAO,CAAA;CACtE;AAGD,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,GAAG,CAAC,CAAA;AAE3C,wBAAgB,UAAU,CAAC,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,IAAI,EAAE,UAAU,CAAC,KAAK,CAAC,GAAG,UAAU,CAAC,KAAK,CAAC,CAEtG;AAED,2EAA2E;AAC3E,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,MAAM,CAAA;IACnB,WAAW,EAAE,UAAU,CAAA;IACvB,WAAW,EAAE,eAAe,CAAA;IAC5B,OAAO,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,KAAK,OAAO,CAAA;CACzE;AAED,yDAAyD;AACzD,MAAM,WAAW,gBAAgB;IAC/B,YAAY,EAAE,CAAC,IAAI,EAAE,cAAc,EAAE,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,KAAK,OAAO,CAAA;IACnF,iEAAiE;IACjE,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAA;CAC3C;AAQD,wFAAwF;AACxF,wBAAgB,eAAe,IAAI,gBAAgB,GAAG,IAAI,CASzD;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,SAAS,aAAa,EAAE,EAC/B,OAAO,GAAE;IAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;CAAO,GACpG,MAAM,IAAI,CAqCZ;AAMD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,EAAE,IAAI,GAAE,SAAS,OAAO,EAAO,GAAG,IAAI,CAGnG;AAED,kFAAkF;AAClF,wBAAgB,WAAW,CAAC,EAAE,KAAK,EAAE,EAAE;IAAE,KAAK,EAAE,SAAS,aAAa,EAAE,CAAA;CAAE,GAAG,IAAI,CAGhF;AAQD,qFAAqF;AACrF,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,GAAG,MAAM,EAAE,CAsBvE"}
package/dist/webmcp.js ADDED
@@ -0,0 +1,186 @@
1
+ "use client";
2
+ /**
3
+ * WebMCP page tools for Terramind sites.
4
+ *
5
+ * A page lists its tools with `defineTool` and mounts them with
6
+ * `useWebMcpTools`. The hook registers each tool through the browser's
7
+ * `document.modelContext.registerTool` (spec:
8
+ * https://webmachinelearning.github.io/webmcp/) and unregisters them by
9
+ * aborting one signal when the component unmounts.
10
+ *
11
+ * Nothing here polyfills the API. Where it is missing every call is a no-op,
12
+ * so a page behaves exactly as it did without tools. A polyfill would also
13
+ * shadow the native object that agent browsers (Nucleus, Chrome) read.
14
+ */
15
+ import { useEffect } from "react";
16
+ export function defineTool(tool) {
17
+ return tool;
18
+ }
19
+ function asModelContext(value) {
20
+ return value && typeof value.registerTool === "function"
21
+ ? value
22
+ : null;
23
+ }
24
+ /** `document.modelContext`, else the deprecated `navigator.modelContext`, else null. */
25
+ export function getModelContext() {
26
+ if (typeof document !== "undefined") {
27
+ const current = asModelContext(document.modelContext);
28
+ if (current)
29
+ return current;
30
+ }
31
+ if (typeof navigator !== "undefined") {
32
+ return asModelContext(navigator.modelContext);
33
+ }
34
+ return null;
35
+ }
36
+ /**
37
+ * Registers `tools` and returns a function that unregisters them.
38
+ * Registration failures (an older browser rejecting a field, a name another
39
+ * component already holds) are reported to `onError` and never thrown: a
40
+ * missing tool must not break the page.
41
+ */
42
+ export function registerTools(tools, options = {}) {
43
+ const context = options.context === undefined ? getModelContext() : options.context;
44
+ if (!context || tools.length === 0)
45
+ return () => { };
46
+ const onError = options.onError ?? defaultOnError;
47
+ const controller = new AbortController();
48
+ for (const tool of tools) {
49
+ const registration = {
50
+ name: tool.name,
51
+ ...(tool.title ? { title: tool.title } : {}),
52
+ description: tool.description,
53
+ inputSchema: tool.inputSchema,
54
+ annotations: tool.annotations,
55
+ execute: (input, executeOptions) => tool.execute((input ?? {}), { signal: executeOptions?.signal }),
56
+ };
57
+ try {
58
+ const result = context.registerTool(registration, { signal: controller.signal });
59
+ if (result && typeof result.then === "function") {
60
+ ;
61
+ result.then(undefined, (error) => onError(error, tool.name));
62
+ }
63
+ }
64
+ catch (error) {
65
+ onError(error, tool.name);
66
+ }
67
+ }
68
+ return () => {
69
+ controller.abort();
70
+ // Builds that predate signal-based unregistration ignore the abort.
71
+ if (typeof context.unregisterTool === "function") {
72
+ for (const tool of tools) {
73
+ try {
74
+ context.unregisterTool(tool.name);
75
+ }
76
+ catch {
77
+ // Already gone.
78
+ }
79
+ }
80
+ }
81
+ };
82
+ }
83
+ function defaultOnError(error, name) {
84
+ if (typeof console !== "undefined")
85
+ console.warn(`[webmcp] could not register ${name}`, error);
86
+ }
87
+ /**
88
+ * Registers `tools` while the calling component is mounted. Pass `deps` for
89
+ * anything the tools close over; the set is re-registered when they change.
90
+ * Strict-mode double mounts are safe: the first mount's tools are aborted
91
+ * before the second registers.
92
+ */
93
+ export function useWebMcpTools(tools, deps = []) {
94
+ // biome-ignore lint/correctness/useExhaustiveDependencies: callers name what the tools close over
95
+ useEffect(() => registerTools(tools), deps);
96
+ }
97
+ /** `useWebMcpTools` as a component, for server layouts that cannot call hooks. */
98
+ export function WebMcpTools({ tools }) {
99
+ useWebMcpTools(tools, [tools]);
100
+ return null;
101
+ }
102
+ // ---------------------------------------------------------------------------
103
+ // Conventions, checked by each site's test through `validateTools`.
104
+ const NAME = /^[a-z][a-z0-9_]{0,63}$/;
105
+ const MAX_DESCRIPTION = 500;
106
+ /** Returns every convention a set of tools breaks; an empty list means it passes. */
107
+ export function validateTools(tools) {
108
+ const problems = [];
109
+ const seen = new Set();
110
+ for (const tool of tools) {
111
+ const at = `tool ${JSON.stringify(tool.name)}`;
112
+ if (!NAME.test(tool.name))
113
+ problems.push(`${at}: name must be snake_case, start with a letter, at most 64 chars`);
114
+ if (seen.has(tool.name))
115
+ problems.push(`${at}: name is used twice in one set`);
116
+ seen.add(tool.name);
117
+ const description = tool.description?.trim() ?? "";
118
+ if (!description)
119
+ problems.push(`${at}: description is empty`);
120
+ if (description.length > MAX_DESCRIPTION)
121
+ problems.push(`${at}: description is over ${MAX_DESCRIPTION} chars`);
122
+ if (typeof tool.annotations?.readOnlyHint !== "boolean")
123
+ problems.push(`${at}: annotations.readOnlyHint must be set`);
124
+ if (tool.annotations?.readOnlyHint && tool.annotations.consequentialHint) {
125
+ problems.push(`${at}: a read-only tool cannot be consequential`);
126
+ }
127
+ if (typeof tool.execute !== "function")
128
+ problems.push(`${at}: execute is not a function`);
129
+ problems.push(...schemaProblems(tool.inputSchema, `${at}: inputSchema`, true));
130
+ if (schemaMentions(tool.inputSchema, "email") && !tool.annotations?.consequentialHint) {
131
+ problems.push(`${at}: takes an email, so it must set consequentialHint`);
132
+ }
133
+ }
134
+ return problems;
135
+ }
136
+ function schemaProblems(schema, at, root) {
137
+ if (!schema || typeof schema !== "object" || Array.isArray(schema))
138
+ return [`${at} must be an object`];
139
+ const node = schema;
140
+ const problems = [];
141
+ try {
142
+ JSON.stringify(node);
143
+ }
144
+ catch {
145
+ return [`${at} is not JSON-serialisable`];
146
+ }
147
+ if (root && node.type !== "object")
148
+ problems.push(`${at} must have type "object"`);
149
+ if (node.type === "object") {
150
+ if (node.additionalProperties !== false)
151
+ problems.push(`${at} must set additionalProperties: false`);
152
+ const properties = (node.properties ?? {});
153
+ if (typeof properties !== "object" || Array.isArray(properties))
154
+ problems.push(`${at}.properties must be an object`);
155
+ for (const [key, value] of Object.entries(properties)) {
156
+ problems.push(...schemaProblems(value, `${at}.properties.${key}`, false));
157
+ }
158
+ const required = node.required;
159
+ if (required !== undefined) {
160
+ if (!Array.isArray(required))
161
+ problems.push(`${at}.required must be an array`);
162
+ else
163
+ for (const key of required)
164
+ if (!(key in properties))
165
+ problems.push(`${at}.required names unknown "${key}"`);
166
+ }
167
+ }
168
+ if (node.type === "string" && !Array.isArray(node.enum) && typeof node.maxLength !== "number") {
169
+ problems.push(`${at} is a free string, so it needs maxLength`);
170
+ }
171
+ if (node.type === "array" && node.items)
172
+ problems.push(...schemaProblems(node.items, `${at}.items`, false));
173
+ if (!root && typeof node.description !== "string")
174
+ problems.push(`${at} needs a description`);
175
+ return problems;
176
+ }
177
+ function schemaMentions(schema, needle) {
178
+ if (!schema || typeof schema !== "object")
179
+ return false;
180
+ const node = schema;
181
+ if (node.format === needle)
182
+ return true;
183
+ const properties = (node.properties ?? {});
184
+ return Object.entries(properties).some(([key, value]) => key === needle || schemaMentions(value, needle));
185
+ }
186
+ //# sourceMappingURL=webmcp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webmcp.js","sourceRoot":"","sources":["../src/webmcp.ts"],"names":[],"mappings":"AAAA,YAAY,CAAA;AAEZ;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AA0BjC,MAAM,UAAU,UAAU,CAAkC,IAAuB;IACjF,OAAO,IAAI,CAAA;AACb,CAAC;AAmBD,SAAS,cAAc,CAAC,KAAc;IACpC,OAAO,KAAK,IAAI,OAAQ,KAA0B,CAAC,YAAY,KAAK,UAAU;QAC5E,CAAC,CAAE,KAA0B;QAC7B,CAAC,CAAC,IAAI,CAAA;AACV,CAAC;AAED,wFAAwF;AACxF,MAAM,UAAU,eAAe;IAC7B,IAAI,OAAO,QAAQ,KAAK,WAAW,EAAE,CAAC;QACpC,MAAM,OAAO,GAAG,cAAc,CAAE,QAAkD,CAAC,YAAY,CAAC,CAAA;QAChG,IAAI,OAAO;YAAE,OAAO,OAAO,CAAA;IAC7B,CAAC;IACD,IAAI,OAAO,SAAS,KAAK,WAAW,EAAE,CAAC;QACrC,OAAO,cAAc,CAAE,SAAmD,CAAC,YAAY,CAAC,CAAA;IAC1F,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,aAAa,CAC3B,KAA+B,EAC/B,UAAmG,EAAE;IAErG,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,eAAe,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAA;IACnF,IAAI,CAAC,OAAO,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,GAAG,EAAE,GAAE,CAAC,CAAA;IACnD,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,cAAc,CAAA;IACjD,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAA;IACxC,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,YAAY,GAAmB;YACnC,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC5C,WAAW,EAAE,IAAI,CAAC,WAAW;YAC7B,WAAW,EAAE,IAAI,CAAC,WAAW;YAC7B,WAAW,EAAE,IAAI,CAAC,WAAW;YAC7B,OAAO,EAAE,CAAC,KAAc,EAAE,cAAyC,EAAE,EAAE,CACrE,IAAI,CAAC,OAAO,CAAC,CAAC,KAAK,IAAI,EAAE,CAAU,EAAE,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,CAAC;SAC3E,CAAA;QACD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,YAAY,EAAE,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,CAAC,CAAA;YAChF,IAAI,MAAM,IAAI,OAAQ,MAA2B,CAAC,IAAI,KAAK,UAAU,EAAE,CAAC;gBACtE,CAAC;gBAAC,MAA2B,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC,CAAA;YACrF,CAAC;QACH,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,CAAA;QAC3B,CAAC;IACH,CAAC;IACD,OAAO,GAAG,EAAE;QACV,UAAU,CAAC,KAAK,EAAE,CAAA;QAClB,oEAAoE;QACpE,IAAI,OAAO,OAAO,CAAC,cAAc,KAAK,UAAU,EAAE,CAAC;YACjD,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;gBACzB,IAAI,CAAC;oBACH,OAAO,CAAC,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;gBACnC,CAAC;gBAAC,MAAM,CAAC;oBACP,gBAAgB;gBAClB,CAAC;YACH,CAAC;QACH,CAAC;IACH,CAAC,CAAA;AACH,CAAC;AAED,SAAS,cAAc,CAAC,KAAc,EAAE,IAAY;IAClD,IAAI,OAAO,OAAO,KAAK,WAAW;QAAE,OAAO,CAAC,IAAI,CAAC,+BAA+B,IAAI,EAAE,EAAE,KAAK,CAAC,CAAA;AAChG,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,KAA+B,EAAE,OAA2B,EAAE;IAC3F,kGAAkG;IAClG,SAAS,CAAC,GAAG,EAAE,CAAC,aAAa,CAAC,KAAK,CAAC,EAAE,IAAI,CAAC,CAAA;AAC7C,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,WAAW,CAAC,EAAE,KAAK,EAAuC;IACxE,cAAc,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,CAAC,CAAA;IAC9B,OAAO,IAAI,CAAA;AACb,CAAC;AAED,8EAA8E;AAC9E,oEAAoE;AAEpE,MAAM,IAAI,GAAG,wBAAwB,CAAA;AACrC,MAAM,eAAe,GAAG,GAAG,CAAA;AAE3B,qFAAqF;AACrF,MAAM,UAAU,aAAa,CAAC,KAA+B;IAC3D,MAAM,QAAQ,GAAa,EAAE,CAAA;IAC7B,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAA;IAC9B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,EAAE,GAAG,QAAQ,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAA;QAC9C,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,kEAAkE,CAAC,CAAA;QACjH,IAAI,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,iCAAiC,CAAC,CAAA;QAC9E,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QACnB,MAAM,WAAW,GAAG,IAAI,CAAC,WAAW,EAAE,IAAI,EAAE,IAAI,EAAE,CAAA;QAClD,IAAI,CAAC,WAAW;YAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,wBAAwB,CAAC,CAAA;QAC9D,IAAI,WAAW,CAAC,MAAM,GAAG,eAAe;YAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,yBAAyB,eAAe,QAAQ,CAAC,CAAA;QAC9G,IAAI,OAAO,IAAI,CAAC,WAAW,EAAE,YAAY,KAAK,SAAS;YAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,wCAAwC,CAAC,CAAA;QACrH,IAAI,IAAI,CAAC,WAAW,EAAE,YAAY,IAAI,IAAI,CAAC,WAAW,CAAC,iBAAiB,EAAE,CAAC;YACzE,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,4CAA4C,CAAC,CAAA;QAClE,CAAC;QACD,IAAI,OAAO,IAAI,CAAC,OAAO,KAAK,UAAU;YAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,6BAA6B,CAAC,CAAA;QACzF,QAAQ,CAAC,IAAI,CAAC,GAAG,cAAc,CAAC,IAAI,CAAC,WAAW,EAAE,GAAG,EAAE,eAAe,EAAE,IAAI,CAAC,CAAC,CAAA;QAC9E,IAAI,cAAc,CAAC,IAAI,CAAC,WAAW,EAAE,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,iBAAiB,EAAE,CAAC;YACtF,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,oDAAoD,CAAC,CAAA;QAC1E,CAAC;IACH,CAAC;IACD,OAAO,QAAQ,CAAA;AACjB,CAAC;AAED,SAAS,cAAc,CAAC,MAAe,EAAE,EAAU,EAAE,IAAa;IAChE,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC;QAAE,OAAO,CAAC,GAAG,EAAE,oBAAoB,CAAC,CAAA;IACtG,MAAM,IAAI,GAAG,MAAoB,CAAA;IACjC,MAAM,QAAQ,GAAa,EAAE,CAAA;IAC7B,IAAI,CAAC;QACH,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAA;IACtB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,CAAC,GAAG,EAAE,2BAA2B,CAAC,CAAA;IAC3C,CAAC;IACD,IAAI,IAAI,IAAI,IAAI,CAAC,IAAI,KAAK,QAAQ;QAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,0BAA0B,CAAC,CAAA;IAClF,IAAI,IAAI,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;QAC3B,IAAI,IAAI,CAAC,oBAAoB,KAAK,KAAK;YAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,uCAAuC,CAAC,CAAA;QACpG,MAAM,UAAU,GAAG,CAAC,IAAI,CAAC,UAAU,IAAI,EAAE,CAA4B,CAAA;QACrE,IAAI,OAAO,UAAU,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC;YAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,+BAA+B,CAAC,CAAA;QACpH,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC,EAAE,CAAC;YACtD,QAAQ,CAAC,IAAI,CAAC,GAAG,cAAc,CAAC,KAAK,EAAE,GAAG,EAAE,eAAe,GAAG,EAAE,EAAE,KAAK,CAAC,CAAC,CAAA;QAC3E,CAAC;QACD,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAA;QAC9B,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;YAC3B,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC;gBAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,4BAA4B,CAAC,CAAA;;gBACzE,KAAK,MAAM,GAAG,IAAI,QAAQ;oBAAE,IAAI,CAAC,CAAC,GAAG,IAAI,UAAU,CAAC;wBAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,4BAA4B,GAAG,GAAG,CAAC,CAAA;QACnH,CAAC;IACH,CAAC;IACD,IAAI,IAAI,CAAC,IAAI,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,OAAO,IAAI,CAAC,SAAS,KAAK,QAAQ,EAAE,CAAC;QAC9F,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,0CAA0C,CAAC,CAAA;IAChE,CAAC;IACD,IAAI,IAAI,CAAC,IAAI,KAAK,OAAO,IAAI,IAAI,CAAC,KAAK;QAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,cAAc,CAAC,IAAI,CAAC,KAAK,EAAE,GAAG,EAAE,QAAQ,EAAE,KAAK,CAAC,CAAC,CAAA;IAC3G,IAAI,CAAC,IAAI,IAAI,OAAO,IAAI,CAAC,WAAW,KAAK,QAAQ;QAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,sBAAsB,CAAC,CAAA;IAC7F,OAAO,QAAQ,CAAA;AACjB,CAAC;AAED,SAAS,cAAc,CAAC,MAAe,EAAE,MAAc;IACrD,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAA;IACvD,MAAM,IAAI,GAAG,MAAoB,CAAA;IACjC,IAAI,IAAI,CAAC,MAAM,KAAK,MAAM;QAAE,OAAO,IAAI,CAAA;IACvC,MAAM,UAAU,GAAG,CAAC,IAAI,CAAC,UAAU,IAAI,EAAE,CAA4B,CAAA;IACrE,OAAO,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,GAAG,KAAK,MAAM,IAAI,cAAc,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,CAAA;AAC3G,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@terramind/webmcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "WebMCP page tools for Terramind sites: register, unregister and check tools on document.modelContext.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/webmcp.js",
8
+ "types": "dist/webmcp.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/webmcp.d.ts",
12
+ "import": "./dist/webmcp.js"
13
+ }
14
+ },
15
+ "sideEffects": false,
16
+ "files": [
17
+ "dist",
18
+ "src",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
24
+ "prepublishOnly": "bun test && bun run ts:check && bun run build",
25
+ "test": "bun test",
26
+ "ts:check": "tsc --noEmit",
27
+ "vendor": "bun scripts/vendor.ts"
28
+ },
29
+ "peerDependencies": {
30
+ "react": ">=18"
31
+ },
32
+ "devDependencies": {
33
+ "@types/bun": "latest",
34
+ "@types/react": "^19.2.0",
35
+ "react": "^19.2.0",
36
+ "typescript": "^5.9.0"
37
+ }
38
+ }
package/src/webmcp.ts ADDED
@@ -0,0 +1,219 @@
1
+ "use client"
2
+
3
+ /**
4
+ * WebMCP page tools for Terramind sites.
5
+ *
6
+ * A page lists its tools with `defineTool` and mounts them with
7
+ * `useWebMcpTools`. The hook registers each tool through the browser's
8
+ * `document.modelContext.registerTool` (spec:
9
+ * https://webmachinelearning.github.io/webmcp/) and unregisters them by
10
+ * aborting one signal when the component unmounts.
11
+ *
12
+ * Nothing here polyfills the API. Where it is missing every call is a no-op,
13
+ * so a page behaves exactly as it did without tools. A polyfill would also
14
+ * shadow the native object that agent browsers (Nucleus, Chrome) read.
15
+ */
16
+
17
+ import { useEffect } from "react"
18
+
19
+ export type JsonSchema = { [key: string]: unknown }
20
+
21
+ export interface ToolAnnotations {
22
+ /** True when the tool only reads. Required so every tool states it. */
23
+ readOnlyHint: boolean
24
+ /** True when the tool submits personal data or commits to something. */
25
+ consequentialHint?: boolean
26
+ /** True when the result may contain text the site does not control. */
27
+ untrustedContentHint?: boolean
28
+ }
29
+
30
+ export interface WebMcpTool<Input = Record<string, unknown>> {
31
+ name: string
32
+ title?: string
33
+ description: string
34
+ inputSchema: JsonSchema
35
+ annotations: ToolAnnotations
36
+ /** Returns a JSON-serialisable value; the browser hands it to the agent as JSON. */
37
+ execute: (input: Input, options: { signal?: AbortSignal }) => unknown
38
+ }
39
+
40
+ // biome-ignore lint/suspicious/noExplicitAny: tools of different input types share one list
41
+ export type AnyWebMcpTool = WebMcpTool<any>
42
+
43
+ export function defineTool<Input = Record<string, unknown>>(tool: WebMcpTool<Input>): WebMcpTool<Input> {
44
+ return tool
45
+ }
46
+
47
+ /** The object handed to `registerTool`, as the spec's ModelContextTool. */
48
+ export interface RegisteredTool {
49
+ name: string
50
+ title?: string
51
+ description: string
52
+ inputSchema: JsonSchema
53
+ annotations: ToolAnnotations
54
+ execute: (input: unknown, options?: { signal?: AbortSignal }) => unknown
55
+ }
56
+
57
+ /** The subset of the browser API this module touches. */
58
+ export interface ModelContextLike {
59
+ registerTool: (tool: RegisteredTool, options?: { signal?: AbortSignal }) => unknown
60
+ /** Only on the pre-July-2026 `navigator.modelContext` builds. */
61
+ unregisterTool?: (name: string) => unknown
62
+ }
63
+
64
+ function asModelContext(value: unknown): ModelContextLike | null {
65
+ return value && typeof (value as ModelContextLike).registerTool === "function"
66
+ ? (value as ModelContextLike)
67
+ : null
68
+ }
69
+
70
+ /** `document.modelContext`, else the deprecated `navigator.modelContext`, else null. */
71
+ export function getModelContext(): ModelContextLike | null {
72
+ if (typeof document !== "undefined") {
73
+ const current = asModelContext((document as unknown as { modelContext?: unknown }).modelContext)
74
+ if (current) return current
75
+ }
76
+ if (typeof navigator !== "undefined") {
77
+ return asModelContext((navigator as unknown as { modelContext?: unknown }).modelContext)
78
+ }
79
+ return null
80
+ }
81
+
82
+ /**
83
+ * Registers `tools` and returns a function that unregisters them.
84
+ * Registration failures (an older browser rejecting a field, a name another
85
+ * component already holds) are reported to `onError` and never thrown: a
86
+ * missing tool must not break the page.
87
+ */
88
+ export function registerTools(
89
+ tools: readonly AnyWebMcpTool[],
90
+ options: { context?: ModelContextLike | null; onError?: (error: unknown, name: string) => void } = {},
91
+ ): () => void {
92
+ const context = options.context === undefined ? getModelContext() : options.context
93
+ if (!context || tools.length === 0) return () => {}
94
+ const onError = options.onError ?? defaultOnError
95
+ const controller = new AbortController()
96
+ for (const tool of tools) {
97
+ const registration: RegisteredTool = {
98
+ name: tool.name,
99
+ ...(tool.title ? { title: tool.title } : {}),
100
+ description: tool.description,
101
+ inputSchema: tool.inputSchema,
102
+ annotations: tool.annotations,
103
+ execute: (input: unknown, executeOptions?: { signal?: AbortSignal }) =>
104
+ tool.execute((input ?? {}) as never, { signal: executeOptions?.signal }),
105
+ }
106
+ try {
107
+ const result = context.registerTool(registration, { signal: controller.signal })
108
+ if (result && typeof (result as Promise<unknown>).then === "function") {
109
+ ;(result as Promise<unknown>).then(undefined, (error) => onError(error, tool.name))
110
+ }
111
+ } catch (error) {
112
+ onError(error, tool.name)
113
+ }
114
+ }
115
+ return () => {
116
+ controller.abort()
117
+ // Builds that predate signal-based unregistration ignore the abort.
118
+ if (typeof context.unregisterTool === "function") {
119
+ for (const tool of tools) {
120
+ try {
121
+ context.unregisterTool(tool.name)
122
+ } catch {
123
+ // Already gone.
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ function defaultOnError(error: unknown, name: string) {
131
+ if (typeof console !== "undefined") console.warn(`[webmcp] could not register ${name}`, error)
132
+ }
133
+
134
+ /**
135
+ * Registers `tools` while the calling component is mounted. Pass `deps` for
136
+ * anything the tools close over; the set is re-registered when they change.
137
+ * Strict-mode double mounts are safe: the first mount's tools are aborted
138
+ * before the second registers.
139
+ */
140
+ export function useWebMcpTools(tools: readonly AnyWebMcpTool[], deps: readonly unknown[] = []): void {
141
+ // biome-ignore lint/correctness/useExhaustiveDependencies: callers name what the tools close over
142
+ useEffect(() => registerTools(tools), deps)
143
+ }
144
+
145
+ /** `useWebMcpTools` as a component, for server layouts that cannot call hooks. */
146
+ export function WebMcpTools({ tools }: { tools: readonly AnyWebMcpTool[] }): null {
147
+ useWebMcpTools(tools, [tools])
148
+ return null
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Conventions, checked by each site's test through `validateTools`.
153
+
154
+ const NAME = /^[a-z][a-z0-9_]{0,63}$/
155
+ const MAX_DESCRIPTION = 500
156
+
157
+ /** Returns every convention a set of tools breaks; an empty list means it passes. */
158
+ export function validateTools(tools: readonly AnyWebMcpTool[]): string[] {
159
+ const problems: string[] = []
160
+ const seen = new Set<string>()
161
+ for (const tool of tools) {
162
+ const at = `tool ${JSON.stringify(tool.name)}`
163
+ if (!NAME.test(tool.name)) problems.push(`${at}: name must be snake_case, start with a letter, at most 64 chars`)
164
+ if (seen.has(tool.name)) problems.push(`${at}: name is used twice in one set`)
165
+ seen.add(tool.name)
166
+ const description = tool.description?.trim() ?? ""
167
+ if (!description) problems.push(`${at}: description is empty`)
168
+ if (description.length > MAX_DESCRIPTION) problems.push(`${at}: description is over ${MAX_DESCRIPTION} chars`)
169
+ if (typeof tool.annotations?.readOnlyHint !== "boolean") problems.push(`${at}: annotations.readOnlyHint must be set`)
170
+ if (tool.annotations?.readOnlyHint && tool.annotations.consequentialHint) {
171
+ problems.push(`${at}: a read-only tool cannot be consequential`)
172
+ }
173
+ if (typeof tool.execute !== "function") problems.push(`${at}: execute is not a function`)
174
+ problems.push(...schemaProblems(tool.inputSchema, `${at}: inputSchema`, true))
175
+ if (schemaMentions(tool.inputSchema, "email") && !tool.annotations?.consequentialHint) {
176
+ problems.push(`${at}: takes an email, so it must set consequentialHint`)
177
+ }
178
+ }
179
+ return problems
180
+ }
181
+
182
+ function schemaProblems(schema: unknown, at: string, root: boolean): string[] {
183
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) return [`${at} must be an object`]
184
+ const node = schema as JsonSchema
185
+ const problems: string[] = []
186
+ try {
187
+ JSON.stringify(node)
188
+ } catch {
189
+ return [`${at} is not JSON-serialisable`]
190
+ }
191
+ if (root && node.type !== "object") problems.push(`${at} must have type "object"`)
192
+ if (node.type === "object") {
193
+ if (node.additionalProperties !== false) problems.push(`${at} must set additionalProperties: false`)
194
+ const properties = (node.properties ?? {}) as Record<string, unknown>
195
+ if (typeof properties !== "object" || Array.isArray(properties)) problems.push(`${at}.properties must be an object`)
196
+ for (const [key, value] of Object.entries(properties)) {
197
+ problems.push(...schemaProblems(value, `${at}.properties.${key}`, false))
198
+ }
199
+ const required = node.required
200
+ if (required !== undefined) {
201
+ if (!Array.isArray(required)) problems.push(`${at}.required must be an array`)
202
+ else for (const key of required) if (!(key in properties)) problems.push(`${at}.required names unknown "${key}"`)
203
+ }
204
+ }
205
+ if (node.type === "string" && !Array.isArray(node.enum) && typeof node.maxLength !== "number") {
206
+ problems.push(`${at} is a free string, so it needs maxLength`)
207
+ }
208
+ if (node.type === "array" && node.items) problems.push(...schemaProblems(node.items, `${at}.items`, false))
209
+ if (!root && typeof node.description !== "string") problems.push(`${at} needs a description`)
210
+ return problems
211
+ }
212
+
213
+ function schemaMentions(schema: unknown, needle: string): boolean {
214
+ if (!schema || typeof schema !== "object") return false
215
+ const node = schema as JsonSchema
216
+ if (node.format === needle) return true
217
+ const properties = (node.properties ?? {}) as Record<string, unknown>
218
+ return Object.entries(properties).some(([key, value]) => key === needle || schemaMentions(value, needle))
219
+ }