@happyvertical/smrt-web 0.46.0 → 0.47.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/AGENTS.md CHANGED
@@ -40,6 +40,7 @@ module you are editing. This file keeps what holds in every module.
40
40
  | `sse-client.ts` | the client half of live cache invalidation — the app-wide subscriber, the wire contract it consumes, and SSE-vs-polling behaviour | [agents/live-invalidation.md](agents/live-invalidation.md) |
41
41
  | `webmcp.ts` | framework-agnostic WebMCP registrar; validates an optionally bounded/namespaced, effect-filtered prospective set atomically, keeps legacy list-backed tools on collection state, and executes canonical tool-only definitions directly through REST fetchers. `registerWebMcpBespokeTool` is the single-tool sibling a UI layer's hand-written component tool routes through (`useWebMcpTool` in smrt-svelte, #2586): same fail-closed effect classification and `effects` policy, no `namespace`/`maxTools` | — |
42
42
  | `persistence/` + `update-state.ts` | the read-cache rehydrate capability and the framework-free `updateAvailable` primitive (bundle + contract signals) | [agents/version-persistence.md](agents/version-persistence.md) |
43
+ | `webmcp-tool-names.ts` | the document-global tool-name lock every registration path reserves through (#2613). Dependency-free; ships as `@happyvertical/smrt-web/webmcp-tool-names` | see below |
43
44
  | `intents.ts` + `capability-classification.ts` | the framework-agnostic declarative view-intent contract, its declaration registry, and the one capability classification rule both it and `webmcp.ts` apply (#2587, #2588). Ships as the dependency-free `@happyvertical/smrt-web/intents` entry — importing it pulls no client-data engine | — |
44
45
  | `data-query.ts` | dependency-free browser mirror and defensive response normalizer for the canonical bounded data-query envelope (#2444) | — |
45
46
  | `remote-query.ts` | query-shaped remote pages over a `SmrtWebCollection`, with keyed stale cache, execution modes, cancellation/latest-query-wins, and optional query-scoped live subscriptions (#2445) | — |
@@ -172,15 +173,13 @@ declared intent derives, at the one place both are visible. Two such intents
172
173
  would otherwise fight over a single WebMCP tool name at mount, where the
173
174
  failure is a shadowed or rejected registration rather than a clear error.
174
175
 
175
- **Gotcha — that check covers intents only.** A derived name can still collide
176
- with a GENERATED model tool (`${model}_${action}`) or, under a custom
177
- `webmcp.ui.prefix`, with one of the six fixed UI tools. Neither is knowable at
178
- declaration time, because both depend on a runtime `namespace`/`prefix` the
179
- declaration never sees, and there is no document-global tool-name lock for
180
- bespoke registrations to participate in. Nothing escalates an intent stays
181
- browser-only and fail-closed either way — but one registration silently
182
- shadows or loses to the other. Keep an intent's first id segment out of your
183
- model names, or give `registerWebMcpTools` a `namespace`.
176
+ **That check covers intents only** a derived name can still collide with a
177
+ GENERATED model tool (`${model}_${action}`) or, under a custom
178
+ `webmcp.ui.prefix`, with one of the six fixed UI tools, and neither is
179
+ knowable at declaration time because both depend on a runtime
180
+ `namespace`/`prefix` the declaration never sees. Those cross-path collisions
181
+ are caught at REGISTRATION instead, by the document-global tool-name lock
182
+ below (#2613).
184
183
 
185
184
  ### The no-REST invariant
186
185
 
@@ -195,6 +194,59 @@ tool's `execute` is then CONSTRUCTED by `compileViewIntentToolSpec` from
195
194
  declaration, it must be data, and it must be reachable by the scanner without
196
195
  evaluation.
197
196
 
197
+ ## The document-global tool-name lock (#2613)
198
+
199
+ `webmcp-tool-names.ts` is the reservation table the three name-deriving paths
200
+ coordinate through, and ships as the dependency-free
201
+ `@happyvertical/smrt-web/webmcp-tool-names` entry so a UI layer can reserve
202
+ its own names without pulling the client-data engine.
203
+
204
+ The three paths do NOT share one registrar — verify this before assuming a
205
+ funnel. `registerWebMcpTools` and `registerWebMcpUiTools` (in
206
+ `@happyvertical/smrt-svelte`) each call `document.modelContext.registerTool`
207
+ directly; only intents and `useWebMcpTool` route through
208
+ `registerWebMcpBespokeTool`. So each path reserves for itself:
209
+
210
+ | Path | Owner | Reserves in |
211
+ |---|---|---|
212
+ | generated model tools | `generated` | `registerWebMcpTools`, after selection/budget validation, before the first `registerTool` |
213
+ | the six fixed `smrt_ui_*` tools | `ui` | `registerWebMcpUiTools` (`smrt-svelte`), after the prefix lock |
214
+ | declared view intents | `intent` | `registerWebMcpBespokeTool`'s shared body — via `registerViewIntent`, or via `registerWebMcpBespokeTool` with `owner: 'intent'` |
215
+ | bespoke `useWebMcpTool` tools | `bespoke` | the same shared body (the default owner) |
216
+
217
+ Reservation is all-or-nothing and rejects a duplicate SYNCHRONOUSLY with
218
+ `WebMcpToolNameCollisionError`, which carries the colliding name plus the
219
+ owner holding it and the owner that asked. Previously the host rejected the
220
+ later registration and the tool was silently absent. **This is a behavior
221
+ change**: `registerWebMcpBespokeTool` and `registerViewIntent` now throw where
222
+ they used to succeed-then-lose-the-tool.
223
+
224
+ Rules for anything that adds a fourth path or edits an existing one:
225
+
226
+ - **The table is keyed on the `document`, not on module state.** A registrar
227
+ with an injectable document must pass the SAME object it reads
228
+ `modelContext` from, or its reservations land in a different table. Module
229
+ state would fragment across bundle chunks, a duplicated dependency, and HMR;
230
+ the table is stored on the document under a `Symbol.for` key so every copy
231
+ of the module resolves to one slot. It is stamped with the `modelContext` it
232
+ was built for and resets when the host installs a new one, so tools the old
233
+ registry held never strand their names.
234
+ - **Dispose must release**, or a mount/unmount cycle wedges the name
235
+ permanently — the failure mode #2595 already hit as a re-registration race.
236
+ A reservation releases only names it still holds.
237
+ - **A tool the effects policy excluded reserves nothing**: it was never
238
+ registered, so its name stays available.
239
+ - **A binding that compiles an intent itself must pass `owner: 'intent'`.**
240
+ `registerViewIntent` is not the shipped intent path: `useViewIntent` in
241
+ `@happyvertical/smrt-svelte` compiles the spec and hands it to
242
+ `useWebMcpTool`, because that package must not duplicate the WebMCP
243
+ lifecycle. Without the explicit owner every intent collision would report
244
+ `bespoke` and send an author looking for a `useWebMcpTool` call that does
245
+ not exist. The label is a diagnostic only — it grants nothing.
246
+ - Consumer app code that registers directly against `document.modelContext`
247
+ (for example `packages/template-sveltekit`'s runtime-diagnostics tools) does
248
+ not participate and can still collide at the host.
249
+
198
250
  ## Reference consumer
199
251
 
200
252
  `packages/products` consumes the runtime as its reference store across npm,
package/README.md CHANGED
@@ -131,7 +131,7 @@ core.
131
131
  | Persistence | `persistCollection`, `wipeDurableStore` |
132
132
  | Live updates | `createSmrtWebEventSubscriber`, `liveInvalidation` |
133
133
  | Version awareness | `createUpdateState` |
134
- | WebMCP | `registerWebMcpTools` |
134
+ | WebMCP | `registerWebMcpTools`, `registerWebMcpBespokeTool`, `registerViewIntent`, `reserveWebMcpToolNames` |
135
135
 
136
136
  ## WebMCP capability exposure
137
137
 
@@ -191,6 +191,29 @@ collections. Their duplicate tool names or collection/action identities reject
191
191
  the registration atomically. Prefer the canonical set for complete generated
192
192
  coverage, or compose only disjoint legacy and canonical subsets.
193
193
 
194
+ ### Tool names are locked per document
195
+
196
+ Generated model tools, the six fixed `smrt_ui_*` tools a UI layer registers
197
+ under its own prefix, declared view intents, and hand-written bespoke tools all
198
+ derive names independently and all end at one `document.modelContext`. None can
199
+ see the others at declaration time, because a `namespace` and a UI `prefix` are
200
+ runtime values.
201
+
202
+ Every path reserves its names through a document-global lock before the browser
203
+ sees them, so a cross-path collision is refused synchronously with a
204
+ `WebMcpToolNameCollisionError` naming the tool and the owner holding it
205
+ (`generated`, `ui`, `intent`, or `bespoke`). The browser used to reject the
206
+ later registration and the tool was simply absent.
207
+
208
+ Reservation is all-or-nothing, and disposing a registration releases its names,
209
+ so a mount / unmount / remount cycle under one name keeps working. A UI layer
210
+ that registers its own fixed tool set reserves through the dependency-free
211
+ `@happyvertical/smrt-web/webmcp-tool-names` entry, passing the same `document`
212
+ it reads `modelContext` from. A binding that compiles a declared view intent
213
+ itself and registers it with `registerWebMcpBespokeTool` — rather than through
214
+ `registerViewIntent` — passes `owner: 'intent'` so the diagnostic names the
215
+ intent. The owner is a label only; it grants nothing.
216
+
194
217
  WebMCP policy controls which capabilities a page advertises; it is not an
195
218
  authorization boundary. Execution still uses the page's authenticated REST
196
219
  transport, whose auth, tenancy, writable-field, and sensitive-field guards must
@@ -1,4 +1,5 @@
1
1
  import { c as capabilityAnnotations, l as resolveDeclaredCapability, n as compileViewIntentToolSpec, s as WEBMCP_TOOL_EFFECTS } from "./intents-BUTyN7YQ.js";
2
+ import { reserveWebMcpToolNames } from "../webmcp-tool-names.js";
2
3
  import { createCollection } from "@tanstack/db";
3
4
  import { QueryClient } from "@tanstack/query-core";
4
5
  import { queryCollectionOptions } from "@tanstack/query-db-collection";
@@ -2127,11 +2128,13 @@ function registerWebMcpTools(definitions, options = {}) {
2127
2128
  const controller = new AbortController();
2128
2129
  const collections = /* @__PURE__ */ new Map();
2129
2130
  const collectionFetchers = /* @__PURE__ */ new Map();
2131
+ const reservation = reserveWebMcpToolNames(tools.map((tool) => tool.name), "generated");
2130
2132
  let disposed = false;
2131
2133
  const dispose = () => {
2132
2134
  if (disposed) return;
2133
2135
  disposed = true;
2134
2136
  controller.abort();
2137
+ reservation.release();
2135
2138
  for (const collection of collections.values()) collection.cleanup().catch(() => void 0);
2136
2139
  };
2137
2140
  const registrations = [];
@@ -2196,16 +2199,22 @@ function registrationDisposer(dispose, ready) {
2196
2199
  return Object.assign(dispose, { ready });
2197
2200
  }
2198
2201
  function registerWebMcpBespokeTool(spec, options = {}) {
2202
+ return registerSingleTool(spec, options, options.owner ?? "bespoke");
2203
+ }
2204
+ function registerSingleTool(spec, options, owner) {
2199
2205
  const { allowedEffects } = validateExposurePolicy(options);
2200
2206
  const ctx = getModelContext();
2201
2207
  if (!ctx) return registrationDisposer(() => {}, Promise.resolve());
2202
2208
  const semantics = actionSemantics("bespoke", bespokeDeclaredSemantics(spec.annotations));
2203
2209
  if (!allowedEffects.has(semantics.effect)) return registrationDisposer(() => {}, Promise.resolve());
2210
+ const reservation = reserveWebMcpToolNames([spec.name], owner);
2204
2211
  const controller = new AbortController();
2205
2212
  let disposed = false;
2206
2213
  const dispose = () => {
2214
+ if (disposed) return;
2207
2215
  disposed = true;
2208
2216
  controller.abort();
2217
+ reservation.release();
2209
2218
  };
2210
2219
  let registration;
2211
2220
  try {
@@ -2231,7 +2240,7 @@ function registerWebMcpBespokeTool(spec, options = {}) {
2231
2240
  return registrationDisposer(dispose, ready);
2232
2241
  }
2233
2242
  function registerViewIntent(intent, binding, options = {}) {
2234
- return registerWebMcpBespokeTool(compileViewIntentToolSpec(intent, binding), options);
2243
+ return registerSingleTool(compileViewIntentToolSpec(intent, binding), options, "intent");
2235
2244
  }
2236
2245
  var VALID_EFFECTS = WEBMCP_TOOL_EFFECTS;
2237
2246
  var NAMESPACE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
@@ -3030,4 +3039,4 @@ function createSmrtCollection(definition, options) {
3030
3039
  //#endregion
3031
3040
  export { MAX_SMRT_WEB_DATA_QUERY_OFFSET as A, offlineOutbox as C, MAX_SMRT_WEB_DATA_QUERY_CONTAINER_ITEMS as D, wipeDurableStore as E, MAX_SMRT_WEB_DATA_QUERY_WARNINGS as F, executeSmrtWebDataQuery as I, normalizeSmrtWebDataQueryResult as L, MAX_SMRT_WEB_DATA_QUERY_RESULT_BYTES as M, MAX_SMRT_WEB_DATA_QUERY_ROWS as N, MAX_SMRT_WEB_DATA_QUERY_FACETS as O, MAX_SMRT_WEB_DATA_QUERY_STRING_LENGTH as P, runWrapMutation as R, getOutboxHandle as S, registerDurableResource as T, createSmrtWebEventSubscriber as _, createSmrtWebClient as a, DEFAULT_PERSIST_DEBOUNCE_MS as b, newLocalId as c, unwrapListResult as d, validateSmrtWebClient as f, createUpdateState as g, registerWebMcpTools as h, createSmrtCollection as i, MAX_SMRT_WEB_DATA_QUERY_PAGE_LIMIT as j, MAX_SMRT_WEB_DATA_QUERY_FACET_VALUES as k, throwIfSmrtWebError as l, registerWebMcpBespokeTool as m, buildListQuery as n, getEngineCollection as o, registerViewIntent as p, createDefinitionFetchers as r, invalidateSmrtWebCollections as s, SmrtWebRequestError as t, unwrapItemResult as u, liveInvalidation as v, durableStoreNamespace as w, persistCollection as x, createSmrtWebQuery as y };
3032
3041
 
3033
- //# sourceMappingURL=src-D1ZtD6Bt.js.map
3042
+ //# sourceMappingURL=src-DHH8ptH8.js.map