@divlet/web 0.0.2 → 0.0.4

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.
@@ -0,0 +1,70 @@
1
+ import type { BrowserTool, ToolLimits, ToolRegistry } from './mcp-tools.ts';
2
+ export { defineTools, compileSchema, ToolError, DEFAULT_TOOL_LIMITS } from './mcp-tools.ts';
3
+ export type { BrowserTool, ToolAnnotations, ToolDescriptor, ToolExecution, ToolLimits, ToolOutcome, ToolRegistry, JsonSchema } from './mcp-tools.ts';
4
+ export { registerWebMcpTools } from './webmcp.ts';
5
+ export type { WebMcpModelContext } from './webmcp.ts';
6
+ export declare const PROTOCOL_VERSION = "2026-07-28";
7
+ export declare const SUPPORTED_VERSIONS: readonly string[];
8
+ /** The discovery contract between a page and an agent transport. Project-specific, versioned, not an MCP standard. */
9
+ export declare const ENDPOINT_PROTOCOL = "browser-mcp/1";
10
+ /** Where the endpoint lives on the page's global object: a registered symbol, so it collides with no page or browser name. */
11
+ export declare const ENDPOINT_KEY: unique symbol;
12
+ /** Optional static hint (`<meta name="browser-mcp" content="browser-mcp/1">`): the page intends to expose a provider. */
13
+ export declare const ENDPOINT_META = "browser-mcp";
14
+ /** JSON-RPC and MCP error codes this provider emits. `TOO_MANY_REQUESTS` is application-defined, outside the reserved range. */
15
+ export declare const ERRORS: Readonly<{
16
+ PARSE: -32700;
17
+ INVALID_REQUEST: -32600;
18
+ METHOD_NOT_FOUND: -32601;
19
+ INVALID_PARAMS: -32602;
20
+ INTERNAL: -32603;
21
+ UNSUPPORTED_VERSION: -32022;
22
+ TOO_MANY_REQUESTS: -31001;
23
+ }>;
24
+ export interface ProviderLimits extends ToolLimits {
25
+ /** Largest accepted request or emitted response, in UTF-8 bytes. */
26
+ readonly messageBytes: number;
27
+ /** Requests that may be executing at once; further requests are refused rather than queued without bound. */
28
+ readonly concurrent: number;
29
+ }
30
+ export declare const DEFAULT_PROVIDER_LIMITS: ProviderLimits;
31
+ export interface ProviderOptions {
32
+ readonly name: string;
33
+ readonly version: string;
34
+ readonly title?: string;
35
+ readonly instructions?: string;
36
+ readonly tools: readonly BrowserTool[] | ToolRegistry;
37
+ readonly limits?: Partial<ProviderLimits>;
38
+ }
39
+ type Id = string | number;
40
+ type Response = {
41
+ jsonrpc: '2.0';
42
+ id: Id | null;
43
+ result?: Record<string, unknown>;
44
+ error?: {
45
+ code: number;
46
+ message: string;
47
+ data?: unknown;
48
+ };
49
+ };
50
+ export interface BrowserMcpProvider {
51
+ /** Handle one JSON-RPC message (parsed JSON). Resolves to the response, or `null` for notifications and cancelled requests. */
52
+ handle(message: unknown): Promise<Response | null>;
53
+ /** Handle one message as JSON text and answer with JSON text (or `null`), enforcing the byte budget in both directions. */
54
+ handleText(text: string): Promise<string | null>;
55
+ /** Abort every running call; later requests are refused. */
56
+ close(): void;
57
+ readonly registry: ToolRegistry;
58
+ }
59
+ /** The object a page publishes at `globalThis[ENDPOINT_KEY]`. `instance` changes with every provider, so a transport sees replacement. */
60
+ export interface BrowserMcpEndpoint {
61
+ readonly protocol: typeof ENDPOINT_PROTOCOL;
62
+ readonly instance: string;
63
+ handle(text: string): Promise<string | null>;
64
+ }
65
+ export declare function createBrowserMcpProvider(options: ProviderOptions): BrowserMcpProvider;
66
+ /**
67
+ * Publish the provider for an agent transport on this page. The endpoint is non-writable and non-enumerable; publishing a second
68
+ * provider on the same page is refused. Returns a function that withdraws it (and closes the provider).
69
+ */
70
+ export declare function exposeBrowserMcp(provider: BrowserMcpProvider, target?: object): () => void;
@@ -0,0 +1,179 @@
1
+ /*
2
+ * Owns: the browser-side MCP provider — a JSON-RPC 2.0 handler for MCP protocol revision 2026-07-28 over the canonical tool
3
+ * registry — and the page's discovery endpoint a local agent transport looks for. Excludes: how messages reach the page
4
+ * (that is the agent's transport), application semantics (the registered tools own them) and any host privilege: the
5
+ * provider only answers messages handed to it and never calls out of the page.
6
+ *
7
+ * Revision 2026-07-28 is stateless: there is no `initialize` handshake. Every request carries its protocol version and the
8
+ * client capabilities in `params._meta`, and each request is answered independently. Implemented methods: `server/discover`,
9
+ * `tools/list`, `tools/call` and the `notifications/cancelled` notification. Resources, prompts, subscriptions, multi
10
+ * round-trip input and tasks are not offered and are not advertised.
11
+ */
12
+ import { defineTools, boundedJson } from "./mcp-tools.js";
13
+ export { defineTools, compileSchema, ToolError, DEFAULT_TOOL_LIMITS } from "./mcp-tools.js";
14
+ export { registerWebMcpTools } from "./webmcp.js";
15
+ export const PROTOCOL_VERSION = '2026-07-28';
16
+ export const SUPPORTED_VERSIONS = Object.freeze([PROTOCOL_VERSION]);
17
+ /** The discovery contract between a page and an agent transport. Project-specific, versioned, not an MCP standard. */
18
+ export const ENDPOINT_PROTOCOL = 'browser-mcp/1';
19
+ /** Where the endpoint lives on the page's global object: a registered symbol, so it collides with no page or browser name. */
20
+ export const ENDPOINT_KEY = Symbol.for(ENDPOINT_PROTOCOL);
21
+ /** Optional static hint (`<meta name="browser-mcp" content="browser-mcp/1">`): the page intends to expose a provider. */
22
+ export const ENDPOINT_META = 'browser-mcp';
23
+ const META_VERSION = 'io.modelcontextprotocol/protocolVersion';
24
+ const META_CAPABILITIES = 'io.modelcontextprotocol/clientCapabilities';
25
+ const META_SERVER = 'io.modelcontextprotocol/serverInfo';
26
+ /** Required caching hints for discovery and list results. The tool set belongs to one page instance and may change on reload. */
27
+ const CACHE = Object.freeze({ ttlMs: 0, cacheScope: 'private' });
28
+ /** JSON-RPC and MCP error codes this provider emits. `TOO_MANY_REQUESTS` is application-defined, outside the reserved range. */
29
+ export const ERRORS = Object.freeze({ PARSE: -32700, INVALID_REQUEST: -32600, METHOD_NOT_FOUND: -32601, INVALID_PARAMS: -32602, INTERNAL: -32603, UNSUPPORTED_VERSION: -32022, TOO_MANY_REQUESTS: -31001 });
30
+ export const DEFAULT_PROVIDER_LIMITS = Object.freeze({ messageBytes: 2 * 1024 * 1024, concurrent: 32, resultBytes: 1024 * 1024, timeoutMs: 60_000 });
31
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
32
+ const validId = (id) => (typeof id === 'string' && id.length <= 256) || Number.isSafeInteger(id);
33
+ const encoder = new TextEncoder();
34
+ export function createBrowserMcpProvider(options) {
35
+ if (!options || typeof options.name !== 'string' || !options.name || typeof options.version !== 'string' || !options.version)
36
+ throw new TypeError('A browser MCP provider needs a name and a version.');
37
+ const limits = { ...DEFAULT_PROVIDER_LIMITS, ...options.limits };
38
+ const registry = 'descriptors' in options.tools && typeof options.tools.run === 'function'
39
+ ? options.tools
40
+ : defineTools(options.tools, limits);
41
+ const serverInfo = Object.freeze({ name: options.name, version: options.version, ...(options.title ? { title: options.title } : {}) });
42
+ const running = new Map();
43
+ let closed = false;
44
+ const meta = () => ({ [META_SERVER]: serverInfo });
45
+ const ok = (id, result) => ({ jsonrpc: '2.0', id, result: { resultType: 'complete', ...result, _meta: meta() } });
46
+ const fail = (id, code, message, data) => ({ jsonrpc: '2.0', id, error: data === undefined ? { code, message } : { code, message, data } });
47
+ /** The per-request `_meta` this revision requires; `null` when the request may proceed. */
48
+ function admit(id, params) {
49
+ const fields = params._meta;
50
+ if (!isObject(fields))
51
+ return fail(id, ERRORS.INVALID_PARAMS, `Missing _meta: every request must carry ${META_VERSION} and ${META_CAPABILITIES}.`);
52
+ const version = fields[META_VERSION];
53
+ if (typeof version !== 'string' || !version)
54
+ return fail(id, ERRORS.INVALID_PARAMS, `Missing _meta["${META_VERSION}"].`);
55
+ if (!isObject(fields[META_CAPABILITIES]))
56
+ return fail(id, ERRORS.INVALID_PARAMS, `Missing _meta["${META_CAPABILITIES}"].`);
57
+ if (!SUPPORTED_VERSIONS.includes(version))
58
+ return fail(id, ERRORS.UNSUPPORTED_VERSION, 'Unsupported protocol version', { supported: [...SUPPORTED_VERSIONS], requested: version.slice(0, 64) });
59
+ return null;
60
+ }
61
+ async function call(id, params) {
62
+ const name = params.name;
63
+ if (typeof name !== 'string' || !registry.has(name))
64
+ return fail(id, ERRORS.INVALID_PARAMS, `Unknown tool: ${typeof name === 'string' ? name.slice(0, 128) : String(name)}`);
65
+ if (params.arguments !== undefined && !isObject(params.arguments))
66
+ return fail(id, ERRORS.INVALID_PARAMS, 'tools/call arguments must be an object.');
67
+ if (running.size >= limits.concurrent)
68
+ return fail(id, ERRORS.TOO_MANY_REQUESTS, `Too many concurrent requests (limit ${limits.concurrent}).`);
69
+ if (running.has(id))
70
+ return fail(id, ERRORS.INVALID_REQUEST, 'A request with this id is still in progress.');
71
+ const controller = new AbortController();
72
+ running.set(id, controller);
73
+ try {
74
+ const outcome = await registry.run(name, params.arguments ?? {}, { signal: controller.signal });
75
+ // A cancelled request gets no response (notifications/cancelled); a closed provider answers nothing further.
76
+ if (controller.signal.aborted)
77
+ return null;
78
+ if (outcome.ok) {
79
+ const structured = isObject(outcome.value) ? { structuredContent: outcome.value } : {};
80
+ return ok(id, { content: [{ type: 'text', text: outcome.json }], ...structured, isError: false });
81
+ }
82
+ const { code, message, details } = outcome.error;
83
+ const text = `${code}: ${message}${details === undefined ? '' : `\n${JSON.stringify(details)}`}`;
84
+ return ok(id, { content: [{ type: 'text', text }], structuredContent: { error: outcome.error }, isError: true });
85
+ }
86
+ finally {
87
+ running.delete(id);
88
+ }
89
+ }
90
+ async function handle(message) {
91
+ if (!isObject(message) || message.jsonrpc !== '2.0')
92
+ return fail(isObject(message) && validId(message.id) ? message.id : null, ERRORS.INVALID_REQUEST, 'Invalid request: expected one JSON-RPC 2.0 object.');
93
+ const { id, method } = message;
94
+ const params = message.params === undefined ? {} : message.params;
95
+ if (typeof method !== 'string') {
96
+ // Responses to requests this provider never sends are ignored rather than answered.
97
+ return 'result' in message || 'error' in message ? null : fail(validId(id) ? id : null, ERRORS.INVALID_REQUEST, 'Invalid request: method must be a string.');
98
+ }
99
+ if (id === undefined) {
100
+ // Notifications: only cancellation has meaning here; anything else is ignored as the specification requires.
101
+ if (method === 'notifications/cancelled' && isObject(params) && validId(params.requestId))
102
+ running.get(params.requestId)?.abort();
103
+ return null;
104
+ }
105
+ if (!validId(id))
106
+ return fail(null, ERRORS.INVALID_REQUEST, 'Invalid request: id must be a string or an integer.');
107
+ if (!isObject(params))
108
+ return fail(id, ERRORS.INVALID_PARAMS, 'params must be an object.');
109
+ if (closed)
110
+ return fail(id, ERRORS.INTERNAL, 'The provider has closed.');
111
+ if (method === 'initialize')
112
+ return fail(id, ERRORS.METHOD_NOT_FOUND, `This provider implements only the stateless MCP revision(s) ${SUPPORTED_VERSIONS.join(', ')}; there is no initialize handshake.`, { supported: [...SUPPORTED_VERSIONS] });
113
+ const refused = admit(id, params);
114
+ if (refused)
115
+ return refused;
116
+ switch (method) {
117
+ case 'server/discover':
118
+ return ok(id, { supportedVersions: [...SUPPORTED_VERSIONS], capabilities: { tools: {} }, ...(options.instructions ? { instructions: options.instructions } : {}), ...CACHE });
119
+ case 'tools/list':
120
+ if (params.cursor !== undefined)
121
+ return fail(id, ERRORS.INVALID_PARAMS, 'Invalid cursor: the tool list has a single page.');
122
+ return ok(id, { tools: registry.descriptors.map(descriptor => JSON.parse(JSON.stringify(descriptor))), ...CACHE });
123
+ case 'tools/call':
124
+ return call(id, params);
125
+ default:
126
+ return fail(id, ERRORS.METHOD_NOT_FOUND, `Method not found: ${method.slice(0, 128)}`);
127
+ }
128
+ }
129
+ async function handleText(text) {
130
+ if (typeof text !== 'string')
131
+ return JSON.stringify(fail(null, ERRORS.INVALID_REQUEST, 'Messages are JSON text.'));
132
+ if (text.length > limits.messageBytes || encoder.encode(text).byteLength > limits.messageBytes)
133
+ return JSON.stringify(fail(null, ERRORS.INVALID_REQUEST, `The message exceeds ${limits.messageBytes} bytes.`));
134
+ let message;
135
+ try {
136
+ message = JSON.parse(text);
137
+ }
138
+ catch {
139
+ return JSON.stringify(fail(null, ERRORS.PARSE, 'Parse error.'));
140
+ }
141
+ let response;
142
+ try {
143
+ response = await handle(message);
144
+ }
145
+ catch (error) {
146
+ response = fail(isObject(message) && validId(message.id) ? message.id : null, ERRORS.INTERNAL, String(error?.message || error).slice(0, 1000));
147
+ }
148
+ if (!response)
149
+ return null;
150
+ try {
151
+ return boundedJson(response, limits.messageBytes);
152
+ }
153
+ catch (error) {
154
+ return JSON.stringify(fail(response.id, ERRORS.INTERNAL, String(error?.message || error)));
155
+ }
156
+ }
157
+ return Object.freeze({
158
+ handle, handleText, registry,
159
+ close() { closed = true; for (const controller of running.values())
160
+ controller.abort(); running.clear(); },
161
+ });
162
+ }
163
+ /**
164
+ * Publish the provider for an agent transport on this page. The endpoint is non-writable and non-enumerable; publishing a second
165
+ * provider on the same page is refused. Returns a function that withdraws it (and closes the provider).
166
+ */
167
+ export function exposeBrowserMcp(provider, target = globalThis) {
168
+ if (Object.getOwnPropertyDescriptor(target, ENDPOINT_KEY)?.value)
169
+ throw new Error('A browser MCP endpoint is already published on this page.');
170
+ const random = globalThis.crypto?.getRandomValues?.(new Uint8Array(12));
171
+ const instance = random ? [...random].map(byte => byte.toString(16).padStart(2, '0')).join('') : `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
172
+ const endpoint = Object.freeze({ protocol: ENDPOINT_PROTOCOL, instance, handle: (text) => provider.handleText(text) });
173
+ Object.defineProperty(target, ENDPOINT_KEY, { value: endpoint, configurable: true, enumerable: false, writable: false });
174
+ return () => {
175
+ if (Object.getOwnPropertyDescriptor(target, ENDPOINT_KEY)?.value === endpoint)
176
+ delete target[ENDPOINT_KEY];
177
+ provider.close();
178
+ };
179
+ }
@@ -0,0 +1,71 @@
1
+ export type JsonSchema = {
2
+ readonly [key: string]: unknown;
3
+ };
4
+ /** Advisory hints, as MCP and WebMCP describe them. A consumer treats them as untrusted claims, never as proof of safety. */
5
+ export interface ToolAnnotations {
6
+ readonly title?: string;
7
+ readonly readOnlyHint?: boolean;
8
+ readonly destructiveHint?: boolean;
9
+ readonly idempotentHint?: boolean;
10
+ readonly openWorldHint?: boolean;
11
+ }
12
+ export interface ToolExecution {
13
+ readonly signal: AbortSignal;
14
+ }
15
+ /** One operation the application exposes. `execute` receives validated arguments and must honour `signal` where it can. */
16
+ export interface BrowserTool {
17
+ readonly name: string;
18
+ readonly title?: string;
19
+ readonly description: string;
20
+ readonly inputSchema: JsonSchema;
21
+ readonly annotations?: ToolAnnotations;
22
+ execute(args: Record<string, unknown>, context: ToolExecution): unknown;
23
+ }
24
+ /** The published description of a tool (no executable member). */
25
+ export interface ToolDescriptor {
26
+ name: string;
27
+ title?: string;
28
+ description: string;
29
+ inputSchema: JsonSchema;
30
+ annotations?: ToolAnnotations;
31
+ }
32
+ export interface ToolLimits {
33
+ /** Largest serialized result accepted from `execute`, in UTF-8 bytes. */
34
+ readonly resultBytes: number;
35
+ /** Longest a single execution may run before it is aborted. */
36
+ readonly timeoutMs: number;
37
+ }
38
+ export declare const DEFAULT_TOOL_LIMITS: ToolLimits;
39
+ /** A failure the caller can report: `code` is a stable machine-readable string. */
40
+ export declare class ToolError extends Error {
41
+ readonly code: string;
42
+ readonly details?: unknown;
43
+ constructor(code: string, message: string, details?: unknown);
44
+ }
45
+ /** The outcome of one call, already JSON-safe: either the tool's value or a structured refusal. */
46
+ export type ToolOutcome = {
47
+ readonly ok: true;
48
+ readonly value: unknown;
49
+ readonly json: string;
50
+ } | {
51
+ readonly ok: false;
52
+ readonly error: {
53
+ readonly code: string;
54
+ readonly message: string;
55
+ readonly details?: unknown;
56
+ };
57
+ };
58
+ export interface ToolRegistry {
59
+ readonly descriptors: readonly ToolDescriptor[];
60
+ has(name: string): boolean;
61
+ /** Validate `args` against the tool's schema, run it under a deadline and return a JSON-safe outcome. */
62
+ run(name: string, args: unknown, options?: {
63
+ signal?: AbortSignal;
64
+ }): Promise<ToolOutcome>;
65
+ }
66
+ /** Compile a schema once; throws a `ToolError('INVALID_SCHEMA')` for anything outside the supported subset. */
67
+ export declare function compileSchema(schema: unknown, label?: string): (value: unknown) => string | null;
68
+ /** The JSON text of a value, or a refusal when it is not plain JSON data or exceeds the byte budget. */
69
+ export declare function boundedJson(value: unknown, maxBytes: number): string;
70
+ /** Validate tool definitions once and return the registry every adapter shares. */
71
+ export declare function defineTools(tools: readonly BrowserTool[], limits?: Partial<ToolLimits>): ToolRegistry;
@@ -0,0 +1,277 @@
1
+ /*
2
+ * Owns: one canonical registry of browser-executed tools, the bounded JSON Schema subset that validates their input, and the
3
+ * single execution path every adapter (the browser MCP provider, WebMCP) calls. Excludes: transport, discovery and any
4
+ * application semantics — a tool's `execute` is the application's own operation.
5
+ *
6
+ * A schema keyword this module does not implement is refused when the tool is defined, so a registered schema never claims a
7
+ * validation this code would silently skip.
8
+ */
9
+ export const DEFAULT_TOOL_LIMITS = Object.freeze({ resultBytes: 1024 * 1024, timeoutMs: 60_000 });
10
+ /** A failure the caller can report: `code` is a stable machine-readable string. */
11
+ export class ToolError extends Error {
12
+ code;
13
+ details;
14
+ constructor(code, message, details) {
15
+ super(message);
16
+ this.name = 'ToolError';
17
+ this.code = code;
18
+ if (details !== undefined)
19
+ this.details = details;
20
+ }
21
+ }
22
+ /* ---- the JSON Schema subset ---------------------------------------------------------------------- */
23
+ const DIALECT = 'https://json-schema.org/draft/2020-12/schema';
24
+ const ANNOTATION_KEYWORDS = new Set(['$schema', 'title', 'description', 'default', 'examples', '$comment', 'deprecated', 'readOnly', 'writeOnly']);
25
+ const ASSERTION_KEYWORDS = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const', 'anyOf', 'oneOf', 'minimum', 'maximum', 'minLength', 'maxLength', 'minItems', 'maxItems']);
26
+ const TYPES = new Set(['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']);
27
+ const MAX_SCHEMA_NODES = 2000;
28
+ const MAX_SCHEMA_DEPTH = 32;
29
+ const MAX_VALUE_DEPTH = 64;
30
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
31
+ const typeOf = (value) => value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value === 'number' ? (Number.isInteger(value) ? 'integer' : 'number') : typeof value;
32
+ const matchesType = (value, type) => {
33
+ const actual = typeOf(value);
34
+ return actual === type || (type === 'number' && actual === 'integer');
35
+ };
36
+ const nonNegativeInteger = (value) => Number.isSafeInteger(value) && value >= 0;
37
+ function sameJson(a, b) {
38
+ if (a === b)
39
+ return true;
40
+ if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object')
41
+ return false;
42
+ if (Array.isArray(a) !== Array.isArray(b))
43
+ return false;
44
+ if (Array.isArray(a))
45
+ return a.length === b.length && a.every((item, i) => sameJson(item, b[i]));
46
+ const keysA = Object.keys(a), keysB = Object.keys(b);
47
+ return keysA.length === keysB.length && keysA.every(key => Object.hasOwn(b, key) && sameJson(a[key], b[key]));
48
+ }
49
+ /** Compile a schema once; throws a `ToolError('INVALID_SCHEMA')` for anything outside the supported subset. */
50
+ export function compileSchema(schema, label = 'schema') {
51
+ let nodes = 0;
52
+ const compile = (node, path, depth) => {
53
+ if (++nodes > MAX_SCHEMA_NODES || depth > MAX_SCHEMA_DEPTH)
54
+ throw new ToolError('INVALID_SCHEMA', `${label} is too large or too deeply nested.`);
55
+ if (node === true)
56
+ return () => null;
57
+ if (node === false)
58
+ return (_value, at) => `${at || 'value'} is not allowed`;
59
+ if (!isObject(node))
60
+ throw new ToolError('INVALID_SCHEMA', `${label} at ${path || '/'} must be an object or a boolean.`);
61
+ for (const key of Object.keys(node)) {
62
+ if (!ANNOTATION_KEYWORDS.has(key) && !ASSERTION_KEYWORDS.has(key))
63
+ throw new ToolError('INVALID_SCHEMA', `${label} uses the unsupported keyword "${key}" at ${path || '/'}.`);
64
+ }
65
+ if ('$schema' in node && node.$schema !== DIALECT)
66
+ throw new ToolError('INVALID_SCHEMA', `${label} declares an unsupported dialect; only JSON Schema 2020-12 is implemented.`);
67
+ const checks = [];
68
+ if ('type' in node) {
69
+ const types = Array.isArray(node.type) ? node.type : [node.type];
70
+ if (!types.length || types.some(type => typeof type !== 'string' || !TYPES.has(type)))
71
+ throw new ToolError('INVALID_SCHEMA', `${label} has an invalid type at ${path || '/'}.`);
72
+ checks.push((value, at) => types.some(type => matchesType(value, type)) ? null : `${at || 'value'} must be ${types.join(' or ')}`);
73
+ }
74
+ if ('enum' in node) {
75
+ if (!Array.isArray(node.enum) || !node.enum.length)
76
+ throw new ToolError('INVALID_SCHEMA', `${label} has an empty enum at ${path || '/'}.`);
77
+ const values = node.enum;
78
+ checks.push((value, at) => values.some(item => sameJson(item, value)) ? null : `${at || 'value'} must be one of ${values.map(item => JSON.stringify(item)).join(', ')}`);
79
+ }
80
+ if ('const' in node) {
81
+ const expected = node.const;
82
+ checks.push((value, at) => sameJson(expected, value) ? null : `${at || 'value'} must be ${JSON.stringify(expected)}`);
83
+ }
84
+ for (const [key, test] of [['minimum', (v, n) => v >= n], ['maximum', (v, n) => v <= n]]) {
85
+ if (!(key in node))
86
+ continue;
87
+ const limit = node[key];
88
+ if (typeof limit !== 'number' || !Number.isFinite(limit))
89
+ throw new ToolError('INVALID_SCHEMA', `${label} has an invalid ${key} at ${path || '/'}.`);
90
+ checks.push((value, at) => typeof value !== 'number' || test(value, limit) ? null : `${at || 'value'} must be ${key === 'minimum' ? '≥' : '≤'} ${limit}`);
91
+ }
92
+ for (const [key, measure, compare] of [
93
+ ['minLength', 'string', (n, l) => n >= l], ['maxLength', 'string', (n, l) => n <= l],
94
+ ['minItems', 'array', (n, l) => n >= l], ['maxItems', 'array', (n, l) => n <= l],
95
+ ]) {
96
+ if (!(key in node))
97
+ continue;
98
+ const limit = node[key];
99
+ if (!nonNegativeInteger(limit))
100
+ throw new ToolError('INVALID_SCHEMA', `${label} has an invalid ${key} at ${path || '/'}.`);
101
+ checks.push((value, at) => {
102
+ if (measure === 'string' && typeof value === 'string') {
103
+ let length = 0;
104
+ for (const _ of value)
105
+ length++;
106
+ return compare(length, limit) ? null : `${at || 'value'} must have ${key.startsWith('min') ? 'at least' : 'at most'} ${limit} characters`;
107
+ }
108
+ if (measure === 'array' && Array.isArray(value))
109
+ return compare(value.length, limit) ? null : `${at || 'value'} must have ${key.startsWith('min') ? 'at least' : 'at most'} ${limit} items`;
110
+ return null;
111
+ });
112
+ }
113
+ if ('properties' in node || 'required' in node || 'additionalProperties' in node) {
114
+ if ('properties' in node && !isObject(node.properties))
115
+ throw new ToolError('INVALID_SCHEMA', `${label} has invalid properties at ${path || '/'}.`);
116
+ const properties = new Map(Object.entries((node.properties ?? {})).map(([key, child]) => [key, compile(child, `${path}/properties/${key}`, depth + 1)]));
117
+ const required = node.required ?? [];
118
+ if (!Array.isArray(required) || required.some(key => typeof key !== 'string'))
119
+ throw new ToolError('INVALID_SCHEMA', `${label} has an invalid required list at ${path || '/'}.`);
120
+ const extra = 'additionalProperties' in node ? compile(node.additionalProperties, `${path}/additionalProperties`, depth + 1) : null;
121
+ checks.push((value, at, level) => {
122
+ if (!isObject(value))
123
+ return null;
124
+ for (const key of required)
125
+ if (!Object.hasOwn(value, key))
126
+ return `${at ? `${at}.` : ''}${key} is required`;
127
+ for (const [key, item] of Object.entries(value)) {
128
+ const child = properties.get(key) ?? extra;
129
+ const problem = child?.(item, at ? `${at}.${key}` : key, level + 1);
130
+ if (problem)
131
+ return problem;
132
+ }
133
+ return null;
134
+ });
135
+ }
136
+ if ('items' in node) {
137
+ const items = compile(node.items, `${path}/items`, depth + 1);
138
+ checks.push((value, at, level) => {
139
+ if (!Array.isArray(value))
140
+ return null;
141
+ for (let i = 0; i < value.length; i++) {
142
+ const problem = items(value[i], `${at || 'value'}[${i}]`, level + 1);
143
+ if (problem)
144
+ return problem;
145
+ }
146
+ return null;
147
+ });
148
+ }
149
+ for (const key of ['anyOf', 'oneOf']) {
150
+ if (!(key in node))
151
+ continue;
152
+ const list = node[key];
153
+ if (!Array.isArray(list) || !list.length)
154
+ throw new ToolError('INVALID_SCHEMA', `${label} has an empty ${key} at ${path || '/'}.`);
155
+ const variants = list.map((child, i) => compile(child, `${path}/${key}/${i}`, depth + 1));
156
+ checks.push((value, at, level) => {
157
+ const passing = variants.filter(variant => variant(value, at, level) === null).length;
158
+ if (key === 'anyOf' ? passing > 0 : passing === 1)
159
+ return null;
160
+ return `${at || 'value'} does not match ${key === 'anyOf' ? 'any allowed shape' : 'exactly one allowed shape'}`;
161
+ });
162
+ }
163
+ return (value, at, level) => {
164
+ if (level > MAX_VALUE_DEPTH)
165
+ return `${at || 'value'} is nested too deeply`;
166
+ for (const check of checks) {
167
+ const problem = check(value, at, level);
168
+ if (problem)
169
+ return problem;
170
+ }
171
+ return null;
172
+ };
173
+ };
174
+ const root = compile(schema, '', 0);
175
+ return value => root(value, '', 0);
176
+ }
177
+ /* ---- the registry -------------------------------------------------------------------------------- */
178
+ const TOOL_NAME = /^[A-Za-z0-9_.-]{1,128}$/;
179
+ const MAX_DESCRIPTION = 16_384;
180
+ const encoder = new TextEncoder();
181
+ /** The JSON text of a value, or a refusal when it is not plain JSON data or exceeds the byte budget. */
182
+ export function boundedJson(value, maxBytes) {
183
+ let text;
184
+ try {
185
+ text = JSON.stringify(value === undefined ? null : value);
186
+ }
187
+ catch (error) {
188
+ throw new ToolError('INVALID_RESULT', `The result is not JSON data: ${error?.message || error}`);
189
+ }
190
+ if (typeof text !== 'string')
191
+ throw new ToolError('INVALID_RESULT', 'The result is not JSON data.');
192
+ if (text.length > maxBytes || encoder.encode(text).byteLength > maxBytes)
193
+ throw new ToolError('RESULT_TOO_LARGE', `The result exceeds ${maxBytes} bytes.`);
194
+ return text;
195
+ }
196
+ function refusal(error) {
197
+ const code = typeof error?.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(error.code) ? error.code : 'TOOL_FAILED';
198
+ const message = String(error?.message ?? error ?? 'The tool failed.').slice(0, 4000);
199
+ let details;
200
+ try {
201
+ const raw = error?.details;
202
+ if (raw !== undefined)
203
+ details = JSON.parse(boundedJson(raw, 16_384));
204
+ }
205
+ catch { /* details are optional; drop what cannot travel */ }
206
+ return details === undefined ? { code, message } : { code, message, details };
207
+ }
208
+ function annotationsOf(input, name) {
209
+ if (input === undefined)
210
+ return undefined;
211
+ if (!isObject(input))
212
+ throw new ToolError('INVALID_TOOL', `${name}: annotations must be an object.`);
213
+ const out = {};
214
+ for (const [key, value] of Object.entries(input)) {
215
+ if (key === 'title' ? typeof value !== 'string' : !['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'].includes(key) || typeof value !== 'boolean')
216
+ throw new ToolError('INVALID_TOOL', `${name}: unsupported annotation ${key}.`);
217
+ out[key] = value;
218
+ }
219
+ return out;
220
+ }
221
+ /** Validate tool definitions once and return the registry every adapter shares. */
222
+ export function defineTools(tools, limits = {}) {
223
+ const budget = { ...DEFAULT_TOOL_LIMITS, ...limits };
224
+ const entries = new Map();
225
+ for (const tool of tools) {
226
+ if (!tool || typeof tool !== 'object' || typeof tool.name !== 'string' || !TOOL_NAME.test(tool.name))
227
+ throw new ToolError('INVALID_TOOL', 'A tool name must be 1–128 characters of A–Z, a–z, 0–9, "_", "-" or ".".');
228
+ if (entries.has(tool.name))
229
+ throw new ToolError('INVALID_TOOL', `Duplicate tool name: ${tool.name}.`);
230
+ if (typeof tool.description !== 'string' || !tool.description.trim() || tool.description.length > MAX_DESCRIPTION)
231
+ throw new ToolError('INVALID_TOOL', `${tool.name}: description must be a non-empty string of at most ${MAX_DESCRIPTION} characters.`);
232
+ if (tool.title !== undefined && typeof tool.title !== 'string')
233
+ throw new ToolError('INVALID_TOOL', `${tool.name}: title must be a string.`);
234
+ if (typeof tool.execute !== 'function')
235
+ throw new ToolError('INVALID_TOOL', `${tool.name}: execute must be a function.`);
236
+ if (!isObject(tool.inputSchema) || tool.inputSchema.type !== 'object')
237
+ throw new ToolError('INVALID_SCHEMA', `${tool.name}: inputSchema must be an object schema.`);
238
+ const annotations = annotationsOf(tool.annotations, tool.name);
239
+ // The descriptor is a detached JSON copy: later mutation of the caller's object cannot change what was validated.
240
+ const descriptor = JSON.parse(JSON.stringify({ name: tool.name, ...(tool.title ? { title: tool.title } : {}), description: tool.description, inputSchema: tool.inputSchema, ...(annotations ? { annotations } : {}) }));
241
+ entries.set(tool.name, { tool, validate: compileSchema(descriptor.inputSchema, `${tool.name} inputSchema`), descriptor });
242
+ }
243
+ const descriptors = Object.freeze([...entries.values()].map(entry => Object.freeze(entry.descriptor)));
244
+ async function run(name, args, { signal } = {}) {
245
+ const entry = entries.get(name);
246
+ if (!entry)
247
+ return { ok: false, error: { code: 'UNKNOWN_TOOL', message: `Unknown tool: ${name}.` } };
248
+ const input = args === undefined ? {} : args;
249
+ const problem = entry.validate(input);
250
+ if (problem)
251
+ return { ok: false, error: { code: 'INVALID_ARGUMENT', message: `Invalid arguments for ${name}: ${problem}.` } };
252
+ if (signal?.aborted)
253
+ return { ok: false, error: { code: 'CANCELLED', message: 'The call was cancelled.' } };
254
+ const controller = new AbortController();
255
+ const abort = () => controller.abort(signal?.reason);
256
+ signal?.addEventListener('abort', abort, { once: true });
257
+ let timer;
258
+ const deadline = new Promise((_, reject) => {
259
+ timer = setTimeout(() => { const error = new ToolError('TIMEOUT', `${name} did not finish within ${budget.timeoutMs} ms.`); controller.abort(error); reject(error); }, budget.timeoutMs);
260
+ });
261
+ const cancelled = new Promise((_, reject) => controller.signal.addEventListener('abort', () => reject(controller.signal.reason instanceof ToolError ? controller.signal.reason : new ToolError('CANCELLED', 'The call was cancelled.')), { once: true }));
262
+ try {
263
+ // A detached copy: the tool never receives an object the transport can still mutate.
264
+ const value = await Promise.race([Promise.resolve().then(() => entry.tool.execute(JSON.parse(JSON.stringify(input)), { signal: controller.signal })), deadline, cancelled]);
265
+ const json = boundedJson(value, budget.resultBytes);
266
+ return { ok: true, value: JSON.parse(json), json };
267
+ }
268
+ catch (error) {
269
+ return { ok: false, error: refusal(error) };
270
+ }
271
+ finally {
272
+ clearTimeout(timer);
273
+ signal?.removeEventListener('abort', abort);
274
+ }
275
+ }
276
+ return Object.freeze({ descriptors, has: (name) => entries.has(name), run });
277
+ }
@@ -1,4 +1,5 @@
1
1
  import { $, el, icon, keys } from './util.js';
2
+ import { native } from './host.js';
2
3
  import { store, on, documents, descriptor, cachedModel, loadModel, selectArtboards, selectNodes, undo, redo, textChild, parentOf } from './store.js';
3
4
  import * as canvas from './canvas.js';
4
5
  import * as actions from './actions.js';
@@ -122,8 +123,8 @@ function commands() {
122
123
  add(board, sidebar.isOpen('layers') ? 'Hide layers' : 'Show layers', () => sidebar.toggle('layers'), { icon: 'layers', keys: 'L' });
123
124
  add(board, sidebar.isOpen('activity') ? 'Hide history' : 'Show history', () => sidebar.toggle('activity'), { icon: 'chat', keys: 'H', words: 'activity log changes replies' });
124
125
  add(board, 'Open the site', () => window.open(store.state.site.url, '_blank', 'noopener'), { icon: 'site', when: !!store.state?.site, words: 'markdown pages' });
125
- add(board, 'Open project…', () => project.show('open'), { icon: 'folder', keys: keys('Mod+O'), words: 'switch folder load' });
126
- add(board, 'Save a copy as…', () => project.show('save'), { icon: 'download', keys: keys('Mod+Shift+S'), words: 'save as copy export project folder', when: project.canShowCopy() });
126
+ add(board, 'Open project…', () => project.show('open'), { icon: 'folder', keys: keys('Mod+O'), words: 'switch folder load', when: native });
127
+ add(board, 'Save a copy as…', () => project.show('save'), { icon: 'download', keys: keys('Mod+Shift+S'), words: 'save as copy export project folder', when: native && project.canShowCopy() });
127
128
  for (const percent of help.UI_SIZES) add(board, `Interface size ${percent}%`, () => help.setUiSize(percent), { icon: 'search', words: 'scale zoom text bigger smaller ui' });
128
129
  add(board, 'Keyboard shortcuts', () => shortcuts.toggle(true), { icon: 'keyboard', keys: '?', words: 'keys hotkeys help' });
129
130
  add(board, 'Settings', () => help.toggle(true), { icon: 'gear', keys: keys('Mod') + ' ,', words: 'preferences theme interface size' });