@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 +21 -0
- package/README.md +53 -2
- package/dist/webmcp.d.ts +69 -0
- package/dist/webmcp.d.ts.map +1 -0
- package/dist/webmcp.js +186 -0
- package/dist/webmcp.js.map +1 -0
- package/package.json +36 -4
- package/src/webmcp.ts +219 -0
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
|
-
#
|
|
1
|
+
# @terramind/webmcp
|
|
2
2
|
|
|
3
|
-
|
|
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
|
package/dist/webmcp.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|