@jarenjs/contract 0.83.3 → 0.85.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/README.md CHANGED
@@ -21,7 +21,7 @@ task slot per operation and one registered effect; and the
21
21
  consumer wants beside the runtime — a browser-safe public subset that is
22
22
  itself a `$contract` document, a valid OpenAPI 3.1 document, TypeScript
23
23
  declarations with a typed operation map, Markdown reference docs and
24
- `@jarenjs/ai` tool definitions — with a `jaren-contract` CLI whose
24
+ plain operation definitions — with a `jaren-contract` CLI whose
25
25
  `--check` fails CI the moment an artifact drifts. The contract knows its
26
26
  own identity: `contract.revision()` is the SHA-256 of the canonical
27
27
  public projection, served at the well-known path and carried in every
@@ -513,7 +513,7 @@ toTypeScript(contract); // one .d.ts: CatalogLoadInput/Output per operatio
513
513
  // typed Client and Handlers — invoke('product.save', …) is fully typed
514
514
  toMarkdown(contract); // reference docs: operations table, per-operation sections, the type tables
515
515
  contractTools(contract, client);
516
- // @jarenjs/ai ToolDefs (WebMCP for free) without importing that package:
516
+ // plain ToolDefs for the shared WebMCP adapter:
517
517
  // name 'product_save', a self-contained inputSchema, execute → the outcome
518
518
  ```
519
519
 
@@ -738,6 +738,7 @@ Every subpath a consumer can import, derived from the manifest by
738
738
  | `@jarenjs/contract/package.json` | metadata | — |
739
739
  | `@jarenjs/contract/provider` | JavaScript | declared |
740
740
  | `@jarenjs/contract/command` | JavaScript | declared |
741
+ | `@jarenjs/contract/webmcp` | JavaScript | declared |
741
742
  <!--/fact-->
742
743
 
743
744
  Author JSON template catalogs and MessageSpec references with the
@@ -780,3 +781,45 @@ finalizers remain part of the same end-to-end test.
780
781
  REST/GraphQL dialects and private run authority. Partial observations retain
781
782
  wire text and never become complete snapshots. See
782
783
  [provider descriptors and ingestion](docs/PROVIDER-FORMAT.md).
784
+
785
+ ## Browser operation registration
786
+
787
+ `@jarenjs/contract/webmcp` exports `registerWebMcp` for plain invokable tool
788
+ definitions. Its browser boundary needs no assistant, provider or model. Supply
789
+ operations that already validate their inputs, for example projected contract
790
+ operations. Importing the module does not inspect browser globals.
791
+
792
+ ```js
793
+ import { registerWebMcp } from '@jarenjs/contract/webmcp';
794
+ const binding = registerWebMcp(tools, { realm: window });
795
+ const outcome = await binding.ready;
796
+ // On host teardown:
797
+ await binding.dispose();
798
+ ```
799
+
800
+ An explicitly supplied `context` is authoritative, including null or undefined.
801
+ Otherwise the adapter discovers a usable `document.modelContext`, then
802
+ `navigator.modelContext`, preserving method receivers and registering through
803
+ only one context. Missing/inaccessible objects and non-callable methods do not
804
+ mask the other root. `registerTool` takes precedence over legacy `provideContext`
805
+ on either root. Only the explicit `WEBMCP_UNSUPPORTED` sentinel permits fallback
806
+ after invoking a method; an injected implementation may return it only before
807
+ any mutation. Exceptions, permission refusals and partial registration never
808
+ trigger a second registration attempt.
809
+
810
+ The binding immediately exposes `ready`, `status` and `dispose`. `ready` resolves
811
+ to `registered`, `unavailable`, `failed` or `disposed` with counts, the selected
812
+ root/method and diagnostics; starting a promise does not report completion.
813
+ Browser failures retain their original error and separate cleanup errors.
814
+ Dispose deactivates callbacks immediately, then waits for pending registration
815
+ and removes only owned successful registrations. Repeated disposal is harmless.
816
+
817
+ Removal uses a returned cleanup handle or `unregisterTool` where available.
818
+ Registration always receives an abort signal. Set `lifecycle: 'abort'` only for
819
+ a host qualified to support it; otherwise native removal without a handle or
820
+ unregister method is explicitly `unverified`, while owned callbacks are inert.
821
+ Legacy `clearContext` is used only with `exclusiveLegacyContext: true`, a host
822
+ guarantee that this binding owns the whole catalog, and only for its current
823
+ owner. Shared legacy catalogs are never cleared. Later explicit mounts discover
824
+ capabilities again. Unknown future method shapes fail visibly without breaking
825
+ ordinary application operations.
@@ -3,7 +3,7 @@
3
3
  * `publicProjection` (the browser-safe subset — itself a `$contract`
4
4
  * document, and what the revision hashes), `toOpenApi` (OpenAPI 3.1
5
5
  * through a JSLT stylesheet), `toTypeScript` and `toMarkdown` (on
6
- * `@jarenjs/emit`'s type model), `contractTools` (`@jarenjs/ai` tool
6
+ * `@jarenjs/emit`'s type model), `contractTools` (plain operation
7
7
  * definitions, no import edge) and the same-document bundler they share.
8
8
  * This is the one subpath that imports `@jarenjs/emit` — the CLI loads
9
9
  * it lazily, only for `types` and `docs`; a consumer that never imports
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file `contractTools`: the public, invokable operations of a contract as
3
- * tool definitions for `@jarenjs/ai`'s toolbox — `{ name, description,
3
+ * tool definitions for host operation registries — `{ name, description,
4
4
  * inputSchema, execute }`, a plain object the toolbox and a WebMCP host
5
5
  * read, so this package never imports the ai package (the generated-
6
6
  * document rule). `execute` calls the client's `invoke` and answers the
@@ -50,8 +50,9 @@ export type ContractToolsOptions = {
50
50
  * without `invoke`, `ops` naming an unknown or opaque operation, a tool
51
51
  * name outside `^[a-zA-Z0-9_-]{1,64}$`, or two operations mapping to one name
52
52
  * @example
53
- * const toolbox = createToolbox(); // @jarenjs/ai
54
- * for (const tool of contractTools(contract, client)) toolbox.add(tool);
55
- * await toolbox.execute('product_save', { id: 1, revision: 2, product: { } }); // → an outcome
53
+ * const tools = contractTools(contract, client);
54
+ * const save = tools.find(tool => tool.name === 'product_save');
55
+ * await save.execute({ id: 1, revision: 2, product: { title: 'Example' } }); // → an outcome
56
+ * // registerWebMcp(tools) exposes the same definitions to a browser host.
56
57
  */
57
58
  export declare function contractTools(contract: Contract, client: ToolClient, options?: ContractToolsOptions): ToolDefinition[];
@@ -0,0 +1,54 @@
1
+ /** Browser registration of plain operations, with an owned asynchronous lifecycle. */
2
+ /** An injected adapter may return this only when an operation had no side effects. */
3
+ export declare const WEBMCP_UNSUPPORTED: unique symbol;
4
+ export type WebMcpTool = {
5
+ name: string;
6
+ description: string;
7
+ inputSchema: object;
8
+ execute: (input: any, options?: {
9
+ signal?: AbortSignal;
10
+ }) => any;
11
+ annotations?: Record<string, boolean>;
12
+ };
13
+ export type WebMcpDiagnostic = {
14
+ code: string;
15
+ root?: string;
16
+ error?: unknown;
17
+ };
18
+ export type WebMcpResult = {
19
+ status: 'pending' | 'registered' | 'unavailable' | 'failed' | 'disposed';
20
+ registered: number;
21
+ root: string | null;
22
+ method: string | null;
23
+ error?: unknown;
24
+ diagnostics: WebMcpDiagnostic[];
25
+ cleanupErrors: unknown[];
26
+ nativeRemoval: 'none' | 'unregister' | 'abort' | 'handle' | 'legacy-clear' | 'unverified';
27
+ };
28
+ export type WebMcpOptions = {
29
+ context?: unknown;
30
+ realm?: object;
31
+ onError?: (error: unknown) => void;
32
+ signal?: AbortSignal;
33
+ lifecycle?: 'abort' | 'callback-only';
34
+ exclusiveLegacyContext?: boolean;
35
+ };
36
+ /**
37
+ * Register plain, already validated operations. Explicit context overrides are
38
+ * authoritative. Otherwise a usable document context precedes navigator.
39
+ * `ready` resolves after completion; dispose deactivates callbacks immediately
40
+ * and waits for pending registration and owned native cleanup. Neither promise
41
+ * rejects on browser failures. Each explicit call discovers capabilities again.
42
+ *
43
+ * `lifecycle: 'abort'` qualifies a host known to support registration signals.
44
+ * Without an unregister method, cleanup handle or that qualification, callbacks
45
+ * are deactivated and native removal is reported as unverified. A legacy clear
46
+ * requires the host's explicit exclusive-catalog guarantee.
47
+ * @param {WebMcpTool[]} definitions
48
+ * @param {WebMcpOptions} [options]
49
+ */
50
+ export declare function registerWebMcp(definitions: WebMcpTool[], options?: WebMcpOptions): {
51
+ ready: Promise<WebMcpResult>;
52
+ dispose: () => Promise<WebMcpResult>;
53
+ readonly status: "disposed" | "failed" | "pending" | "registered" | "unavailable";
54
+ };
@@ -1940,7 +1940,7 @@ rendered by emit's Markdown target over the **same** type model as
1940
1940
 
1941
1941
  `contractTools(contract, client, { ops?, name? })` → an array of
1942
1942
  `{ name, description, inputSchema, execute }` — the `ToolDef` shape
1943
- `@jarenjs/ai`'s `createToolbox().add` takes and WebMCP's `registerTool`
1943
+ browser or host operation registries takes and WebMCP's `registerTool`
1944
1944
  reads, **without importing that package** (the generated-document rule:
1945
1945
  the shape is a plain object; the test suite registers them into a real
1946
1946
  toolbox). Per public, invokable operation (opaque and subscribe
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/contract",
3
3
  "private": false,
4
- "version": "0.83.3",
4
+ "version": "0.85.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -67,6 +67,10 @@
67
67
  "./command": {
68
68
  "types": "./dist/types/command.d.ts",
69
69
  "default": "./src/command.js"
70
+ },
71
+ "./webmcp": {
72
+ "types": "./dist/types/webmcp.d.ts",
73
+ "default": "./src/webmcp.js"
70
74
  }
71
75
  },
72
76
  "files": [
@@ -110,9 +114,9 @@
110
114
  "prepack": "npm run build:types"
111
115
  },
112
116
  "dependencies": {
113
- "@jarenjs/core": "^0.83.3",
114
- "@jarenjs/json": "^0.83.3",
115
- "@jarenjs/validate": "^0.83.3",
116
- "@jarenjs/emit": "^0.83.3"
117
+ "@jarenjs/core": "^0.85.0",
118
+ "@jarenjs/json": "^0.85.0",
119
+ "@jarenjs/validate": "^0.85.0",
120
+ "@jarenjs/emit": "^0.85.0"
117
121
  }
118
122
  }
@@ -4,7 +4,7 @@
4
4
  * `publicProjection` (the browser-safe subset — itself a `$contract`
5
5
  * document, and what the revision hashes), `toOpenApi` (OpenAPI 3.1
6
6
  * through a JSLT stylesheet), `toTypeScript` and `toMarkdown` (on
7
- * `@jarenjs/emit`'s type model), `contractTools` (`@jarenjs/ai` tool
7
+ * `@jarenjs/emit`'s type model), `contractTools` (plain operation
8
8
  * definitions, no import edge) and the same-document bundler they share.
9
9
  * This is the one subpath that imports `@jarenjs/emit` — the CLI loads
10
10
  * it lazily, only for `types` and `docs`; a consumer that never imports
@@ -1,7 +1,7 @@
1
1
  //@ts-check
2
2
  /**
3
3
  * @file `contractTools`: the public, invokable operations of a contract as
4
- * tool definitions for `@jarenjs/ai`'s toolbox — `{ name, description,
4
+ * tool definitions for host operation registries — `{ name, description,
5
5
  * inputSchema, execute }`, a plain object the toolbox and a WebMCP host
6
6
  * read, so this package never imports the ai package (the generated-
7
7
  * document rule). `execute` calls the client's `invoke` and answers the
@@ -23,7 +23,7 @@ import { bundleSameDocument } from '../bundle.js';
23
23
  */
24
24
 
25
25
  /**
26
- * A tool definition as `@jarenjs/ai`'s `createToolbox().add` takes it —
26
+ * A tool definition as browser or host operation registries takes it —
27
27
  * the same shape WebMCP's `registerTool` reads.
28
28
  * @typedef {Object} ToolDefinition
29
29
  * @property {string} name
@@ -92,9 +92,10 @@ function toolInputSchema(op, contract) {
92
92
  * without `invoke`, `ops` naming an unknown or opaque operation, a tool
93
93
  * name outside `^[a-zA-Z0-9_-]{1,64}$`, or two operations mapping to one name
94
94
  * @example
95
- * const toolbox = createToolbox(); // @jarenjs/ai
96
- * for (const tool of contractTools(contract, client)) toolbox.add(tool);
97
- * await toolbox.execute('product_save', { id: 1, revision: 2, product: { } }); // → an outcome
95
+ * const tools = contractTools(contract, client);
96
+ * const save = tools.find(tool => tool.name === 'product_save');
97
+ * await save.execute({ id: 1, revision: 2, product: { title: 'Example' } }); // → an outcome
98
+ * // registerWebMcp(tools) exposes the same definitions to a browser host.
98
99
  */
99
100
  export function contractTools(contract, client, options = {}) {
100
101
  if (!isJsonObject(options)) throw new ContractHostError('JC1008', 'contractTools: options must be an object');
package/src/webmcp.js ADDED
@@ -0,0 +1,193 @@
1
+ //@ts-check
2
+ /** Browser registration of plain operations, with an owned asynchronous lifecycle. */
3
+
4
+ /** An injected adapter may return this only when an operation had no side effects. */
5
+ export const WEBMCP_UNSUPPORTED = Symbol('WebMCP operation unsupported before registration');
6
+
7
+ /**
8
+ * @typedef {{ name: string, description: string, inputSchema: object,
9
+ * execute: (input: any, options?: { signal?: AbortSignal }) => any,
10
+ * annotations?: Record<string, boolean> }} WebMcpTool
11
+ * @typedef {{ code: string, root?: string, error?: unknown }} WebMcpDiagnostic
12
+ * @typedef {{ status: 'pending'|'registered'|'unavailable'|'failed'|'disposed',
13
+ * registered: number, root: string|null, method: string|null,
14
+ * error?: unknown, diagnostics: WebMcpDiagnostic[], cleanupErrors: unknown[],
15
+ * nativeRemoval: 'none'|'unregister'|'abort'|'handle'|'legacy-clear'|'unverified' }} WebMcpResult
16
+ * @typedef {{ context?: unknown, realm?: object,
17
+ * onError?: (error: unknown) => void, signal?: AbortSignal,
18
+ * lifecycle?: 'abort'|'callback-only', exclusiveLegacyContext?: boolean }} WebMcpOptions
19
+ */
20
+
21
+ /** Only an explicitly exclusive legacy binding may clear its whole catalog. */
22
+ const legacyOwners = new WeakMap();
23
+
24
+ /**
25
+ * Register plain, already validated operations. Explicit context overrides are
26
+ * authoritative. Otherwise a usable document context precedes navigator.
27
+ * `ready` resolves after completion; dispose deactivates callbacks immediately
28
+ * and waits for pending registration and owned native cleanup. Neither promise
29
+ * rejects on browser failures. Each explicit call discovers capabilities again.
30
+ *
31
+ * `lifecycle: 'abort'` qualifies a host known to support registration signals.
32
+ * Without an unregister method, cleanup handle or that qualification, callbacks
33
+ * are deactivated and native removal is reported as unverified. A legacy clear
34
+ * requires the host's explicit exclusive-catalog guarantee.
35
+ * @param {WebMcpTool[]} definitions
36
+ * @param {WebMcpOptions} [options]
37
+ */
38
+ export function registerWebMcp(definitions, options = {}) {
39
+ const names = new Set();
40
+ for (const tool of definitions) {
41
+ if (!tool || typeof tool.name !== 'string' || !tool.name || names.has(tool.name)
42
+ || typeof tool.description !== 'string' || typeof tool.execute !== 'function'
43
+ || !tool.inputSchema || typeof tool.inputSchema !== 'object')
44
+ throw new TypeError('WebMCP needs uniquely named, invokable tool definitions');
45
+ names.add(tool.name);
46
+ }
47
+ /** @type {WebMcpResult} */
48
+ const result = { status: 'pending', registered: 0, root: null, method: null,
49
+ diagnostics: [], cleanupErrors: [], nativeRemoval: 'none' };
50
+ let active = true, disposed = false;
51
+ const controller = new AbortController();
52
+ /** @type {Promise<WebMcpResult>|undefined} */
53
+ let disposal;
54
+ /** @type {Array<() => unknown>} */
55
+ const removals = [];
56
+ const token = {};
57
+ /** @type {object|undefined} */
58
+ let selected;
59
+ /** @param {unknown} error */
60
+ function report(error) {
61
+ try { options.onError?.(error); }
62
+ catch (hookError) { result.diagnostics.push({ code: 'error-handler-failed', error: hookError }); }
63
+ }
64
+ /** Read accessors inside the failure boundary, including method accessors. */
65
+ function member(object, key, root) {
66
+ try { return object?.[key]; }
67
+ catch (error) { result.diagnostics.push({ code: 'inaccessible', root, error }); return undefined; }
68
+ }
69
+ function candidates() {
70
+ if (Object.hasOwn(options, 'context')) return [{ value: options.context, root: 'explicit' }];
71
+ const realm = options.realm ?? globalThis;
72
+ return ['document', 'navigator'].map(root => ({
73
+ root, value: member(member(realm, root, root), 'modelContext', root),
74
+ }));
75
+ }
76
+ /** @type {WebMcpTool[]} */
77
+ const tools = definitions.map(tool => ({ ...tool, execute(input, execution) {
78
+ if (!active) return { error: 'WebMCP registration is inactive' };
79
+ return tool.execute(input, execution);
80
+ } }));
81
+ async function cleanup() {
82
+ active = false;
83
+ controller.abort();
84
+ for (const remove of removals.splice(0).reverse()) {
85
+ try { await remove(); }
86
+ catch (error) { result.cleanupErrors.push(error); report(error); }
87
+ }
88
+ if (selected && legacyOwners.get(selected) === token) legacyOwners.delete(selected);
89
+ }
90
+ function removal(context, method, name, handle, root) {
91
+ if (typeof handle === 'function') {
92
+ result.nativeRemoval = 'handle';
93
+ removals.push(handle);
94
+ return;
95
+ }
96
+ const handleDispose = member(handle, 'dispose', root);
97
+ if (typeof handleDispose === 'function') {
98
+ result.nativeRemoval = 'handle';
99
+ removals.push(() => handleDispose.call(handle));
100
+ return;
101
+ }
102
+ const unregister = member(context, 'unregisterTool', root);
103
+ if (method === 'registerTool' && typeof unregister === 'function') {
104
+ result.nativeRemoval = 'unregister';
105
+ removals.push(() => unregister.call(context, name));
106
+ return;
107
+ }
108
+ const clear = member(context, 'clearContext', root);
109
+ if (method === 'provideContext' && options.exclusiveLegacyContext && typeof clear === 'function') {
110
+ result.nativeRemoval = 'legacy-clear';
111
+ removals.push(() => legacyOwners.get(context) === token ? clear.call(context) : undefined);
112
+ return;
113
+ }
114
+ result.nativeRemoval = options.lifecycle === 'abort' ? 'abort' : 'unverified';
115
+ if (result.nativeRemoval === 'unverified' && !result.diagnostics.some(d => d.code === 'native-removal-unverified'))
116
+ result.diagnostics.push({ code: 'native-removal-unverified', root });
117
+ }
118
+ async function register() {
119
+ const seen = new Set();
120
+ for (const { value: context, root } of candidates()) {
121
+ if (disposed) break;
122
+ if (context == null || (typeof context !== 'object' && typeof context !== 'function')) {
123
+ result.diagnostics.push({ code: context == null ? 'absent' : 'incompatible', root });
124
+ continue;
125
+ }
126
+ if (seen.has(context)) continue;
127
+ seen.add(context);
128
+ let usable = false;
129
+ for (const method of ['registerTool', 'provideContext']) {
130
+ const invoke = member(context, method, root);
131
+ if (typeof invoke !== 'function') continue;
132
+ usable = true;
133
+ selected = context;
134
+ result.root = root;
135
+ result.method = method;
136
+ const batch = method === 'registerTool' ? tools : [{ tools }];
137
+ let unsupported = false;
138
+ try {
139
+ for (const item of batch) {
140
+ if (disposed) break;
141
+ const handle = await invoke.call(context, item, { signal: controller.signal });
142
+ if (handle === WEBMCP_UNSUPPORTED) {
143
+ if (result.registered) throw new Error('WebMCP became unsupported after partial registration');
144
+ unsupported = true;
145
+ result.diagnostics.push({ code: 'unsupported-before-registration', root });
146
+ break;
147
+ }
148
+ result.registered += method === 'registerTool' ? 1 : tools.length;
149
+ if (method === 'provideContext') legacyOwners.set(context, token);
150
+ removal(context, method, 'name' in item ? item.name : undefined, handle, root);
151
+ }
152
+ if (unsupported) continue;
153
+ result.status = disposed ? 'disposed' : 'registered';
154
+ if (disposed) await cleanup();
155
+ return result;
156
+ }
157
+ catch (error) {
158
+ result.error = error;
159
+ result.status = disposed ? 'disposed' : 'failed';
160
+ report(error);
161
+ await cleanup();
162
+ return result;
163
+ }
164
+ }
165
+ if (!usable) result.diagnostics.push({ code: 'incompatible', root });
166
+ }
167
+ active = false;
168
+ result.status = disposed ? 'disposed' : 'unavailable';
169
+ return result;
170
+ }
171
+ /** Stop callbacks now, including while the browser is still registering. */
172
+ function dispose() {
173
+ if (disposal) return disposal;
174
+ disposed = true;
175
+ active = false;
176
+ controller.abort();
177
+ options.signal?.removeEventListener('abort', onAbort);
178
+ disposal = ready.then(async () => { await cleanup(); result.status = 'disposed'; return result; });
179
+ return disposal;
180
+ }
181
+ function onAbort() { void dispose(); }
182
+ /** @type {(value: WebMcpResult) => void} */
183
+ let complete;
184
+ /** @type {Promise<WebMcpResult>} */
185
+ const ready = new Promise(resolve => { complete = resolve; });
186
+ if (options.signal?.aborted) { disposed = true; active = false; controller.abort(); }
187
+ else options.signal?.addEventListener('abort', onAbort, { once: true });
188
+ void register().then(complete, error => {
189
+ result.error = error; result.status = 'failed'; active = false; report(error);
190
+ complete(result);
191
+ });
192
+ return { ready, dispose, get status() { return result.status; } };
193
+ }