@happyvertical/smrt-svelte 0.46.0 → 0.47.1

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
@@ -117,6 +117,15 @@ share an application registry. The adapter resolves registry state at execution
117
117
  time, never reads the DOM, never accepts agent confirmation, and removes the
118
118
  entire tool set with one abort signal. A document may have only one active
119
119
  adapter for a prefix; configure distinct prefixes for intentional coexistence.
120
+ The six derived names are also reserved through smrt-web's document-global
121
+ tool-name lock (`@happyvertical/smrt-web/webmcp-tool-names`, #2613), so a
122
+ generated model tool, a view intent, or a `useWebMcpTool` tool that would
123
+ occupy one of them under the configured prefix fails at registration with the
124
+ owner named instead of losing a tool at the host. Reserve before the first
125
+ `registerTool` and release on dispose. Both locks here — the per-document
126
+ prefix set and the shared name table — are stamped with the `modelContext`
127
+ they were taken against and reset when a host installs a new one; keep them in
128
+ step, or a prefix stays refused against a registry that never held its tools.
120
129
 
121
130
  ## Hooks
122
131
 
@@ -143,6 +152,20 @@ for a data-surface one — for exactly the component's lifetime.
143
152
  compilation to a browser tool live in `@happyvertical/smrt-web` with no
144
153
  Svelte dependency. This binding is the thin part; a second framework is a
145
154
  binding, not a rewrite. Do not add intent semantics to this package.
155
+ - **`useViewIntent` reserves as `intent`, not `bespoke`.** It reuses
156
+ `useWebMcpTool` rather than `registerViewIntent`, so it passes
157
+ `owner: 'intent'` explicitly; drop that and every intent collision blames a
158
+ `useWebMcpTool` call that does not exist. Diagnostic label only.
159
+ - **A tool-name collision (#2613) reports, it does not throw at the caller.**
160
+ `registerWebMcpBespokeTool` throws synchronously, but both bindings here run
161
+ it after a lazy import inside an effect, so neither can hand the exception
162
+ back to a component. `<Provider>` catches the UI and generated registrars
163
+ into `logger.warn`; `useWebMcpTool` catches only
164
+ `WebMcpToolNameCollisionError` into its existing
165
+ `console.warn('[smrt-svelte] bespoke WebMCP tool registration failed', …)`
166
+ path — its `register()` is fire-and-forget, so anything it does not catch
167
+ becomes a page-level unhandled rejection. Every other synchronous throw (an
168
+ invalid `effects` policy) still propagates: that is an author bug.
146
169
  - **Built on `useWebMcpTool`.** Do not write a second WebMCP lifecycle: the
147
170
  synchronous Provider policy read, the `options.effects` fallback, serialized
148
171
  same-name re-registration across effect reruns, and the lazy
package/README.md CHANGED
@@ -145,7 +145,12 @@ should be discoverable:
145
145
  ```
146
146
 
147
147
  The default prefix is `smrt_ui_`. Configure `ui.prefix` when multiple Providers
148
- must coexist in one document; the same prefix cannot be registered twice.
148
+ must coexist in one document; the same prefix cannot be registered twice. The
149
+ six derived names are reserved through the document-global tool-name lock, so
150
+ a generated model tool, a view intent, or a `useWebMcpTool` tool that would
151
+ take one of them under your prefix fails at registration with a message naming
152
+ the tool and its current owner, rather than being silently dropped by the
153
+ browser.
149
154
  `ui: false` disables only the fixed UI adapter while leaving generated model
150
155
  tools enabled. For compatibility, an object config that omits `ui` continues to
151
156
  enable only generated model tools; use `webmcp={true}` or provide `ui: {}` to
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { createDataSurfaceRegistry } from '@happyvertical/smrt-ui/data';
11
11
  import { createControlInteractionRegistry } from '@happyvertical/smrt-ui/forms';
12
+ import { WebMcpToolNameCollisionError, webMcpToolNameOwner, } from '@happyvertical/smrt-web';
12
13
  import { render } from '@testing-library/svelte';
13
14
  import { afterEach, describe, expect, it, vi } from 'vitest';
14
15
  const registerBespokeSpy = vi.hoisted(() => vi.fn());
@@ -118,7 +119,12 @@ describe('useViewIntent', () => {
118
119
  },
119
120
  });
120
121
  await vi.waitFor(() => expect(registerBespokeSpy).toHaveBeenCalledTimes(1));
121
- expect(registerBespokeSpy.mock.calls[0][1]).toEqual({ effects: ['read'] });
122
+ // `owner: 'intent'` rides along on every intent registration so the
123
+ // tool-name lock blames the intent, not a bespoke tool (#2613).
124
+ expect(registerBespokeSpy.mock.calls[0][1]).toEqual({
125
+ effects: ['read'],
126
+ owner: 'intent',
127
+ });
122
128
  // The six fixed `smrt_ui_*` tools register alongside it; only the intent
123
129
  // must be absent.
124
130
  expect(toolNamed(registered, 'fixture_clear_notes')).toBeUndefined();
@@ -188,6 +194,43 @@ describe('useViewIntent', () => {
188
194
  expect(registered).toHaveLength(0);
189
195
  expect(registerBespokeSpy).not.toHaveBeenCalled();
190
196
  });
197
+ it('reserves its tool name as `intent`, not `bespoke` (#2613)', async () => {
198
+ // This binding is the only shipped intent path — it reuses
199
+ // `useWebMcpTool` rather than calling `registerViewIntent`, because the
200
+ // WebMCP lifecycle must not be duplicated. A collision against a mounted
201
+ // intent must still blame the INTENT, or an author goes looking for a
202
+ // `useWebMcpTool` call that does not exist.
203
+ const registered = installModelContext();
204
+ const view = render(Fixture, {
205
+ props: {
206
+ intent: revealNotesIntent,
207
+ identity: { formId: 'order-form', controlId: 'notes' },
208
+ controlRegistry: mountedControlRegistry(),
209
+ },
210
+ });
211
+ await vi.waitFor(() => expect(registered).toHaveLength(1));
212
+ expect(registered[0].name).toBe('fixture_reveal_notes');
213
+ expect(webMcpToolNameOwner('fixture_reveal_notes')).toBe('intent');
214
+ let thrown;
215
+ try {
216
+ // What a generated model tool named `fixture_reveal_notes` would hit.
217
+ const { registerWebMcpBespokeTool } = await import('@happyvertical/smrt-web');
218
+ registerWebMcpBespokeTool({
219
+ name: 'fixture_reveal_notes',
220
+ description: 'x',
221
+ inputSchema: { type: 'object' },
222
+ annotations: { readOnlyHint: true },
223
+ execute: () => 'ok',
224
+ });
225
+ }
226
+ catch (error) {
227
+ thrown = error;
228
+ }
229
+ expect(thrown).toBeInstanceOf(WebMcpToolNameCollisionError);
230
+ expect(thrown.owner).toBe('intent');
231
+ view.unmount();
232
+ expect(webMcpToolNameOwner('fixture_reveal_notes')).toBeUndefined();
233
+ });
191
234
  it('rejects an identity that does not match the declared target registry', () => {
192
235
  installModelContext();
193
236
  expect(() => render(Fixture, {
@@ -1,6 +1,7 @@
1
1
  import { createDataSurfaceRegistry, } from '@happyvertical/smrt-ui/data';
2
2
  import { createControlInteractionRegistry, } from '@happyvertical/smrt-ui/forms';
3
- import { describe, expect, it, vi } from 'vitest';
3
+ import { registerWebMcpBespokeTool, WebMcpToolNameCollisionError, } from '@happyvertical/smrt-web';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
5
  import { registerWebMcpUiTools } from '../webmcp-ui.js';
5
6
  function modelContext() {
6
7
  const registered = [];
@@ -584,3 +585,121 @@ describe('registerWebMcpUiTools', () => {
584
585
  expect(() => registerWebMcpUiTools({ ...registries, document })).not.toThrow();
585
586
  });
586
587
  });
588
+ /**
589
+ * The fixed UI tools and a hand-written `useWebMcpTool` tool are derived on
590
+ * different paths and neither can see the other's runtime prefix, so before
591
+ * #2613 a bespoke name matching a custom-prefixed UI tool reached the host and
592
+ * one of the two was silently lost. Both paths now reserve through the
593
+ * document-global tool-name lock. These use the real jsdom `document` because
594
+ * that is the object both registrars key their reservations on in a page —
595
+ * `registerWebMcpBespokeTool` reads `globalThis.document` and has no injection
596
+ * seam.
597
+ */
598
+ describe('cross-path tool-name lock (#2613)', () => {
599
+ const registries = () => ({
600
+ controlRegistry: createControlInteractionRegistry(),
601
+ dataSurfaceRegistry: createDataSurfaceRegistry(),
602
+ });
603
+ function installPageModelContext() {
604
+ const registered = [];
605
+ document.modelContext = {
606
+ async registerTool(tool) {
607
+ registered.push(tool);
608
+ },
609
+ };
610
+ return registered;
611
+ }
612
+ function bespoke(name) {
613
+ return {
614
+ name,
615
+ description: 'A component-owned tool',
616
+ inputSchema: { type: 'object', properties: {} },
617
+ annotations: {
618
+ readOnlyHint: true,
619
+ idempotentHint: true,
620
+ openWorldHint: false,
621
+ },
622
+ execute: () => 'ok',
623
+ };
624
+ }
625
+ afterEach(() => {
626
+ document.modelContext = undefined;
627
+ });
628
+ it('refuses a bespoke tool named for a UI tool under a custom prefix', () => {
629
+ const registered = installPageModelContext();
630
+ const dispose = registerWebMcpUiTools({
631
+ ...registries(),
632
+ prefix: 'agent_ui_',
633
+ });
634
+ expect(registered).toHaveLength(6);
635
+ let thrown;
636
+ try {
637
+ registerWebMcpBespokeTool(bespoke('agent_ui_execute_form_control'));
638
+ }
639
+ catch (error) {
640
+ thrown = error;
641
+ }
642
+ expect(thrown).toBeInstanceOf(WebMcpToolNameCollisionError);
643
+ const collision = thrown;
644
+ expect(collision.toolName).toBe('agent_ui_execute_form_control');
645
+ expect(collision.owner).toBe('ui');
646
+ expect(collision.requestedBy).toBe('bespoke');
647
+ expect(registered).toHaveLength(6);
648
+ // The default prefix was never mounted, so the same suffix is free there.
649
+ const other = registerWebMcpBespokeTool(bespoke('smrt_ui_execute_form_control'));
650
+ expect(registered).toHaveLength(7);
651
+ other();
652
+ dispose();
653
+ });
654
+ it('refuses a UI prefix a live bespoke tool already occupies', () => {
655
+ const registered = installPageModelContext();
656
+ const held = registerWebMcpBespokeTool(bespoke('agent_ui_inspect_form_control'));
657
+ expect(registered).toHaveLength(1);
658
+ let thrown;
659
+ try {
660
+ registerWebMcpUiTools({ ...registries(), prefix: 'agent_ui_' });
661
+ }
662
+ catch (error) {
663
+ thrown = error;
664
+ }
665
+ expect(thrown).toBeInstanceOf(WebMcpToolNameCollisionError);
666
+ expect(thrown.owner).toBe('bespoke');
667
+ // Atomic: not one of the six reached the host.
668
+ expect(registered).toHaveLength(1);
669
+ // And the refused mount released its own prefix lock, so the same prefix
670
+ // works once the bespoke tool goes away.
671
+ held();
672
+ const dispose = registerWebMcpUiTools({
673
+ ...registries(),
674
+ prefix: 'agent_ui_',
675
+ });
676
+ expect(registered).toHaveLength(7);
677
+ dispose();
678
+ });
679
+ it('frees the prefix and the names when the host swaps its model context', () => {
680
+ const registered = installPageModelContext();
681
+ // Deliberately never disposed: the adapter's six tools live in the old
682
+ // registry, which the host is about to throw away. Both the prefix lock
683
+ // and the name reservations must let go with it, or the same prefix stays
684
+ // permanently refused against a registry that never held it.
685
+ registerWebMcpUiTools({ ...registries(), prefix: 'agent_ui_' });
686
+ expect(registered).toHaveLength(6);
687
+ const rebound = installPageModelContext();
688
+ const dispose = registerWebMcpUiTools({
689
+ ...registries(),
690
+ prefix: 'agent_ui_',
691
+ });
692
+ expect(rebound).toHaveLength(6);
693
+ dispose();
694
+ });
695
+ it('frees the six names on dispose so a remount succeeds', () => {
696
+ const registered = installPageModelContext();
697
+ for (let cycle = 0; cycle < 3; cycle += 1) {
698
+ registerWebMcpUiTools({ ...registries(), prefix: 'agent_ui_' })();
699
+ }
700
+ expect(registered).toHaveLength(18);
701
+ const bespokeDisposer = registerWebMcpBespokeTool(bespoke('agent_ui_execute_form_control'));
702
+ expect(registered).toHaveLength(19);
703
+ bespokeDisposer();
704
+ });
705
+ });
@@ -1,3 +1,4 @@
1
+ import { registerWebMcpBespokeTool as registerBespokeDirect, reserveWebMcpToolNames, WebMcpToolNameCollisionError, webMcpToolNameOwner, } from '@happyvertical/smrt-web';
1
2
  import { render } from '@testing-library/svelte';
2
3
  import { tick } from 'svelte';
3
4
  import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -149,6 +150,53 @@ describe('useWebMcpTool', () => {
149
150
  view.unmount();
150
151
  warnSpy.mockRestore();
151
152
  });
153
+ it('reports a tool-name collision through its warn path, not as an unhandled rejection (#2613)', async () => {
154
+ const registered = installModelContext();
155
+ // Stand in for whatever already holds the name — a generated model tool,
156
+ // a fixed `smrt_ui_*` tool under a custom prefix, or another mounted
157
+ // component. `registerWebMcpBespokeTool` now throws SYNCHRONOUSLY here,
158
+ // inside this hook's fire-and-forget `register()`, so without the
159
+ // narrowed catch the page would see a global unhandled rejection instead
160
+ // of a diagnostic naming the tool and its owner.
161
+ const held = reserveWebMcpToolNames(['harness_tool_1'], 'generated');
162
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
163
+ const unhandled = vi.fn();
164
+ window.addEventListener('unhandledrejection', unhandled);
165
+ const view = render(Harness, { props: { version: 1 } });
166
+ await vi.waitFor(() => expect(warn).toHaveBeenCalled());
167
+ // Two macrotask turns is enough for an unhandled rejection to surface.
168
+ await new Promise((resolve) => setTimeout(resolve, 0));
169
+ await new Promise((resolve) => setTimeout(resolve, 0));
170
+ expect(unhandled).not.toHaveBeenCalled();
171
+ expect(registered).toHaveLength(0);
172
+ const [message, name, error] = warn.mock.calls[0];
173
+ expect(message).toContain('bespoke WebMCP tool registration failed');
174
+ expect(name).toBe('harness_tool_1');
175
+ expect(error).toBeInstanceOf(WebMcpToolNameCollisionError);
176
+ expect(error.owner).toBe('generated');
177
+ // The losing registrant strands nothing: the holder keeps the name, and
178
+ // unmounting the component leaves it exactly as it was.
179
+ expect(webMcpToolNameOwner('harness_tool_1')).toBe('generated');
180
+ view.unmount();
181
+ expect(webMcpToolNameOwner('harness_tool_1')).toBe('generated');
182
+ held.release();
183
+ expect(webMcpToolNameOwner('harness_tool_1')).toBeUndefined();
184
+ window.removeEventListener('unhandledrejection', unhandled);
185
+ warn.mockRestore();
186
+ });
187
+ it('still propagates a non-collision synchronous registrar throw', async () => {
188
+ installModelContext();
189
+ // The narrowed catch above must not swallow an authoring error. An
190
+ // invalid `effects` policy is the synchronous throw that already existed.
191
+ expect(() => registerBespokeDirect({
192
+ name: 'harness_tool_1',
193
+ description: 'x',
194
+ inputSchema: { type: 'object' },
195
+ annotations: { readOnlyHint: true },
196
+ execute: () => 'ok',
197
+ }, { effects: ['nonsense'] })).toThrow(/Invalid WebMCP effect/);
198
+ expect(webMcpToolNameOwner('harness_tool_1')).toBeUndefined();
199
+ });
152
200
  it('registers a previously-excluded bespoke tool when a Provider policy loosens to permit it (#2599)', async () => {
153
201
  const registered = installModelContext();
154
202
  const view = render(EffectsReactivity, {
@@ -41,7 +41,7 @@ export interface UseViewIntentOptions {
41
41
  *
42
42
  * Registration therefore goes through `registerWebMcpBespokeTool`, so an
43
43
  * intent is subject to the same fail-closed exposure policy as a generated
44
- * model tool. Execution dispatches exactly one registry command with
44
+ * model tool, and reserves its tool name as `intent` rather than `bespoke`. Execution dispatches exactly one registry command with
45
45
  * `source: 'agent'` — `StagedControlReview` stays on the path, agent-staged
46
46
  * values remain proposals, and there is no path to REST.
47
47
  *
@@ -1 +1 @@
1
- {"version":3,"file":"view-intent.svelte.d.ts","sourceRoot":"","sources":["../../src/web/view-intent.svelte.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AACvE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,8BAA8B,CAAC;AAC/E,OAAO,KAAK,EACV,UAAU,EAEV,yBAAyB,EACzB,6BAA6B,EAC7B,gBAAgB,EACjB,MAAM,yBAAyB,CAAC;AASjC;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,GAC1B,yBAAyB,GACzB,6BAA6B,CAAC;AAElC,MAAM,WAAW,oBAAoB;IACnC;;;;;OAKG;IACH,QAAQ,EAAE,kBAAkB,CAAC;IAC7B;;;;OAIG;IACH,OAAO,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;IACtC,sEAAsE;IACtE,eAAe,CAAC,EAAE,0BAA0B,CAAC;IAC7C,qDAAqD;IACrD,mBAAmB,CAAC,EAAE,mBAAmB,CAAC;CAC3C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,UAAU,EAClB,OAAO,EAAE,oBAAoB,GAC5B,IAAI,CA8BN"}
1
+ {"version":3,"file":"view-intent.svelte.d.ts","sourceRoot":"","sources":["../../src/web/view-intent.svelte.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AACvE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,8BAA8B,CAAC;AAC/E,OAAO,KAAK,EACV,UAAU,EAEV,yBAAyB,EACzB,6BAA6B,EAC7B,gBAAgB,EACjB,MAAM,yBAAyB,CAAC;AASjC;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,GAC1B,yBAAyB,GACzB,6BAA6B,CAAC;AAElC,MAAM,WAAW,oBAAoB;IACnC;;;;;OAKG;IACH,QAAQ,EAAE,kBAAkB,CAAC;IAC7B;;;;OAIG;IACH,OAAO,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;IACtC,sEAAsE;IACtE,eAAe,CAAC,EAAE,0BAA0B,CAAC;IAC7C,qDAAqD;IACrD,mBAAmB,CAAC,EAAE,mBAAmB,CAAC;CAC3C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,UAAU,EAClB,OAAO,EAAE,oBAAoB,GAC5B,IAAI,CAsCN"}
@@ -20,7 +20,7 @@ import { tryGetWebMcpUiContext } from './webmcp-ui-context.js';
20
20
  *
21
21
  * Registration therefore goes through `registerWebMcpBespokeTool`, so an
22
22
  * intent is subject to the same fail-closed exposure policy as a generated
23
- * model tool. Execution dispatches exactly one registry command with
23
+ * model tool, and reserves its tool name as `intent` rather than `bespoke`. Execution dispatches exactly one registry command with
24
24
  * `source: 'agent'` — `StagedControlReview` stays on the path, agent-staged
25
25
  * values remain proposals, and there is no path to REST.
26
26
  *
@@ -57,7 +57,15 @@ export function useViewIntent(intent, options) {
57
57
  (uiContext?.enabled ? uiContext.dataSurfaceRegistry : undefined);
58
58
  const binding = resolveBinding(intent, options.identity, controlRegistry, dataSurfaceRegistry);
59
59
  return binding ? compileViewIntentToolSpec(intent, binding) : null;
60
- }, options.effects ? { effects: options.effects } : {});
60
+ }, {
61
+ // This hook, not `registerViewIntent`, is the shipped intent path — the
62
+ // WebMCP lifecycle lives in `useWebMcpTool` and must not be duplicated
63
+ // here. Label the tool-name reservation `intent` anyway (#2613), or a
64
+ // collision against a mounted intent would report `bespoke` and send an
65
+ // author looking for a `useWebMcpTool` call that does not exist.
66
+ owner: 'intent',
67
+ ...(options.effects ? { effects: options.effects } : {}),
68
+ });
61
69
  }
62
70
  function assertIdentityShape(intent, identity) {
63
71
  if (intent.target.registry === 'control' && !('formId' in identity)) {
@@ -1 +1 @@
1
- {"version":3,"file":"webmcp-ui.d.ts","sourceRoot":"","sources":["../../src/web/webmcp-ui.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,mBAAmB,EAGzB,MAAM,6BAA6B,CAAC;AACrC,OAAO,KAAK,EAKV,0BAA0B,EAE3B,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AA8CzD,MAAM,WAAW,4BAA4B;IAC3C,eAAe,EAAE,0BAA0B,CAAC;IAC5C,mBAAmB,EAAE,mBAAmB,CAAC;IACzC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,QAAQ,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,sBAAsB,CAAA;KAAE,CAAC;CACtD;AAED,UAAU,sBAAsB;IAC9B,YAAY,CACV,IAAI,EAAE,cAAc,EACpB,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,GACjC,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACzB;AA6fD,4EAA4E;AAC5E,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,4BAA4B,GACpC,MAAM,IAAI,CAoDZ"}
1
+ {"version":3,"file":"webmcp-ui.d.ts","sourceRoot":"","sources":["../../src/web/webmcp-ui.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,mBAAmB,EAGzB,MAAM,6BAA6B,CAAC;AACrC,OAAO,KAAK,EAKV,0BAA0B,EAE3B,MAAM,8BAA8B,CAAC;AAKtC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AA0DzD,MAAM,WAAW,4BAA4B;IAC3C,eAAe,EAAE,0BAA0B,CAAC;IAC5C,mBAAmB,EAAE,mBAAmB,CAAC;IACzC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,QAAQ,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,sBAAsB,CAAA;KAAE,CAAC;CACtD;AAED,UAAU,sBAAsB;IAC9B,YAAY,CACV,IAAI,EAAE,cAAc,EACpB,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,GACjC,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACzB;AA6fD,4EAA4E;AAC5E,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,4BAA4B,GACpC,MAAM,IAAI,CA6EZ"}
@@ -1,4 +1,5 @@
1
1
  import { DATA_SURFACE_IDENTIFIER_MAX_LENGTH, DATA_SURFACE_MAX_REQUEST_BYTES, } from '@happyvertical/smrt-ui/data';
2
+ import { reserveWebMcpToolNames, } from '@happyvertical/smrt-web/webmcp-tool-names';
2
3
  const CONTROL_ACTIONS = new Set([
3
4
  'focus',
4
5
  'reveal',
@@ -28,6 +29,15 @@ const PUBLIC_CONTROL_RESULT_REASONS = new Set([
28
29
  'nothing_to_undo',
29
30
  'denied',
30
31
  ]);
32
+ /**
33
+ * Active UI prefixes per document, stamped with the model context they were
34
+ * taken against. The stamp matters for the same reason smrt-web's tool-name
35
+ * table carries one (#2613): when a host installs a new `modelContext`, every
36
+ * tool the old registry held is gone, so a prefix must not stay locked against
37
+ * the new one. Without it the two locks in `registerWebMcpUiTools` would
38
+ * disagree — the names would be free while the prefix stayed permanently
39
+ * refused.
40
+ */
31
41
  const documentLocks = new WeakMap();
32
42
  class PublicToolError extends Error {
33
43
  reason;
@@ -438,15 +448,36 @@ export function registerWebMcpUiTools(options) {
438
448
  if (!modelContext || typeof modelContext.registerTool !== 'function') {
439
449
  return () => { };
440
450
  }
441
- let locks = documentLocks.get(documentLike);
442
- if (!locks) {
451
+ const entry = documentLocks.get(documentLike);
452
+ let locks;
453
+ if (entry && entry.modelContext === modelContext) {
454
+ locks = entry.prefixes;
455
+ }
456
+ else {
443
457
  locks = new Set();
444
- documentLocks.set(documentLike, locks);
458
+ documentLocks.set(documentLike, { modelContext, prefixes: locks });
445
459
  }
446
460
  if (locks.has(prefix)) {
447
461
  throw new Error(`WebMCP UI prefix is already registered: ${prefix}`);
448
462
  }
449
463
  locks.add(prefix);
464
+ const specs = tools(prefix, options.controlRegistry, options.dataSurfaceRegistry);
465
+ // The six fixed names go through the document-global tool-name lock (#2613)
466
+ // so a generated model tool, a declared view intent, or a bespoke
467
+ // `useWebMcpTool` name cannot silently take one of them from us — or lose to
468
+ // one of ours — at the host. Under a custom `prefix` these names are not
469
+ // knowable from any declaration, which is exactly why the lock exists.
470
+ // Reserved before the first `registerTool`, and released by `dispose`; the
471
+ // prefix lock above stays because it names the more common mistake (two
472
+ // Providers, one prefix) more precisely.
473
+ let reservation;
474
+ try {
475
+ reservation = reserveWebMcpToolNames(specs.map((spec) => spec.name), 'ui', { document: documentLike });
476
+ }
477
+ catch (error) {
478
+ locks.delete(prefix);
479
+ throw error;
480
+ }
450
481
  const controller = new AbortController();
451
482
  let disposed = false;
452
483
  const dispose = () => {
@@ -455,9 +486,10 @@ export function registerWebMcpUiTools(options) {
455
486
  disposed = true;
456
487
  controller.abort();
457
488
  locks.delete(prefix);
489
+ reservation.release();
458
490
  };
459
491
  try {
460
- for (const tool of tools(prefix, options.controlRegistry, options.dataSurfaceRegistry)) {
492
+ for (const tool of specs) {
461
493
  const registration = modelContext.registerTool(tool, {
462
494
  signal: controller.signal,
463
495
  });
@@ -1,4 +1,4 @@
1
- import type { WebMcpBespokeToolSpec, WebMcpToolEffect } from '@happyvertical/smrt-web';
1
+ import type { RegisterWebMcpBespokeToolOptions, WebMcpBespokeToolSpec, WebMcpToolEffect } from '@happyvertical/smrt-web';
2
2
  /** A tool exposed to the browser's WebMCP model context. */
3
3
  export type WebMcpToolSpec = WebMcpBespokeToolSpec;
4
4
  export interface UseWebMcpToolOptions {
@@ -11,6 +11,15 @@ export interface UseWebMcpToolOptions {
11
11
  * registrar's own read-only default as the no-Provider fallback.
12
12
  */
13
13
  effects?: readonly WebMcpToolEffect[];
14
+ /**
15
+ * Which path this tool belongs to, for the tool-name lock's collision
16
+ * diagnostic only (#2613). Defaults to `bespoke`, which is right for every
17
+ * hand-written component tool. `useViewIntent` passes `intent` because it
18
+ * routes a declared view intent through this same hook rather than
19
+ * duplicating the WebMCP lifecycle — see
20
+ * `RegisterWebMcpBespokeToolOptions['owner']`. It grants nothing.
21
+ */
22
+ owner?: RegisterWebMcpBespokeToolOptions['owner'];
14
23
  }
15
24
  declare global {
16
25
  interface Document {
@@ -42,6 +51,9 @@ declare global {
42
51
  *
43
52
  * @param options.effects a fallback exposure policy applied only when no
44
53
  * Provider ancestor declares one — see {@link UseWebMcpToolOptions}.
54
+ * @param options.owner the tool-name lock's diagnostic label for this
55
+ * registration (#2613); `useViewIntent` passes `intent`, everything else
56
+ * leaves the `bespoke` default.
45
57
  */
46
58
  export declare function useWebMcpTool(factory: () => WebMcpToolSpec | null | undefined, options?: UseWebMcpToolOptions): void;
47
59
  //# sourceMappingURL=webmcp.svelte.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"webmcp.svelte.d.ts","sourceRoot":"","sources":["../../src/web/webmcp.svelte.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,qBAAqB,EAErB,gBAAgB,EACjB,MAAM,yBAAyB,CAAC;AAGjC,4DAA4D;AAC5D,MAAM,MAAM,cAAc,GAAG,qBAAqB,CAAC;AAEnD,MAAM,WAAW,oBAAoB;IACnC;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;CACvC;AAID,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,QAAQ;QAChB,YAAY,CAAC,EAAE,kBAAkB,CAAC;KACnC;IAED,UAAU,kBAAkB;QAC1B,YAAY,CACV,IAAI,EAAE,cAAc,EACpB,OAAO,CAAC,EAAE;YAAE,MAAM,CAAC,EAAE,WAAW,CAAA;SAAE,GACjC,OAAO,CAAC,IAAI,CAAC,CAAC;KAClB;CACF;AAYD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,cAAc,GAAG,IAAI,GAAG,SAAS,EAChD,OAAO,GAAE,oBAAyB,GACjC,IAAI,CAuFN"}
1
+ {"version":3,"file":"webmcp.svelte.d.ts","sourceRoot":"","sources":["../../src/web/webmcp.svelte.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,gCAAgC,EAChC,qBAAqB,EAErB,gBAAgB,EACjB,MAAM,yBAAyB,CAAC;AAGjC,4DAA4D;AAC5D,MAAM,MAAM,cAAc,GAAG,qBAAqB,CAAC;AAEnD,MAAM,WAAW,oBAAoB;IACnC;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;IACtC;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,gCAAgC,CAAC,OAAO,CAAC,CAAC;CACnD;AAID,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,QAAQ;QAChB,YAAY,CAAC,EAAE,kBAAkB,CAAC;KACnC;IAED,UAAU,kBAAkB;QAC1B,YAAY,CACV,IAAI,EAAE,cAAc,EACpB,OAAO,CAAC,EAAE;YAAE,MAAM,CAAC,EAAE,WAAW,CAAA;SAAE,GACjC,OAAO,CAAC,IAAI,CAAC,CAAC;KAClB;CACF;AAYD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,cAAc,GAAG,IAAI,GAAG,SAAS,EAChD,OAAO,GAAE,oBAAyB,GACjC,IAAI,CA2GN"}
@@ -28,6 +28,9 @@ function getModelContext() {
28
28
  *
29
29
  * @param options.effects a fallback exposure policy applied only when no
30
30
  * Provider ancestor declares one — see {@link UseWebMcpToolOptions}.
31
+ * @param options.owner the tool-name lock's diagnostic label for this
32
+ * registration (#2613); `useViewIntent` passes `intent`, everything else
33
+ * leaves the `bespoke` default.
31
34
  */
32
35
  export function useWebMcpTool(factory, options = {}) {
33
36
  const bespokeContext = tryGetWebMcpBespokeContext();
@@ -69,18 +72,20 @@ export function useWebMcpTool(factory, options = {}) {
69
72
  const priorReady = previousDisposer?.ready;
70
73
  let cancelled = false;
71
74
  let dispose = () => { };
75
+ function reportRegistrationFailure(error) {
76
+ if (cancelled)
77
+ return;
78
+ console.warn('[smrt-svelte] bespoke WebMCP tool registration failed', spec.name, error);
79
+ }
72
80
  async function register() {
73
81
  // The dynamic import failing to load is expected in bundle profiles
74
82
  // without `@happyvertical/smrt-web` — treat it like WebMCP being
75
83
  // unavailable and no-op, silently. Only THIS statement's failure is
76
- // caught: a synchronous throw from `registerWebMcpBespokeTool` below
77
- // (e.g. an invalid `effects` policy) happens outside this try block
78
- // and is deliberately left to propagate as an unhandled rejection of
79
- // `register()`'s own promise, rather than being swallowed alongside
80
- // the load-failure case.
81
- let registerWebMcpBespokeTool;
84
+ // caught here; a synchronous throw from `registerWebMcpBespokeTool`
85
+ // below is handled by its own narrower catch.
86
+ let smrtWeb;
82
87
  try {
83
- ({ registerWebMcpBespokeTool } = await import('@happyvertical/smrt-web'));
88
+ smrtWeb = await import('@happyvertical/smrt-web');
84
89
  }
85
90
  catch {
86
91
  return;
@@ -94,14 +99,35 @@ export function useWebMcpTool(factory, options = {}) {
94
99
  if (cancelled)
95
100
  return;
96
101
  }
97
- const registration = registerWebMcpBespokeTool(spec, { effects });
102
+ let registration;
103
+ try {
104
+ registration = smrtWeb.registerWebMcpBespokeTool(spec, {
105
+ effects,
106
+ ...(options.owner ? { owner: options.owner } : {}),
107
+ });
108
+ }
109
+ catch (error) {
110
+ // A tool-name collision (#2613) is a RUNTIME condition — a generated
111
+ // model tool, a fixed `smrt_ui_*` tool, or another live component
112
+ // already holds this name — not an authoring error in this call, and
113
+ // it is exactly what the lock exists to report. Route it to the same
114
+ // warning a host rejection of `ready` produces, so a losing bespoke
115
+ // registrant gets a diagnostic naming the tool and its owner instead
116
+ // of a global unhandled rejection: `register()` is fire-and-forget
117
+ // (`void register()` below), so nothing observes this promise. Both
118
+ // Provider-owned paths already catch their registrar's throw the same
119
+ // way. Every other synchronous throw — an invalid `effects` policy is
120
+ // the one that exists — still propagates, because that is an author
121
+ // bug the page should surface loudly.
122
+ if (error instanceof smrtWeb.WebMcpToolNameCollisionError) {
123
+ reportRegistrationFailure(error);
124
+ return;
125
+ }
126
+ throw error;
127
+ }
98
128
  dispose = registration;
99
129
  previousDisposer = registration;
100
- void registration.ready.catch((error) => {
101
- if (!cancelled) {
102
- console.warn('[smrt-svelte] bespoke WebMCP tool registration failed', spec.name, error);
103
- }
104
- });
130
+ void registration.ready.catch(reportRegistrationFailure);
105
131
  }
106
132
  void register();
107
133
  return () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-svelte",
3
- "version": "0.46.0",
3
+ "version": "0.47.1",
4
4
  "smrtJsdoc": "strict",
5
5
  "description": "Svelte 5 components for SMRT user management - auth, users, tenants, roles, permissions, groups",
6
6
  "type": "module",
@@ -120,14 +120,14 @@
120
120
  "access": "public"
121
121
  },
122
122
  "dependencies": {
123
- "@happyvertical/logger": "^0.89.4",
123
+ "@happyvertical/logger": "^0.89.6",
124
+ "@happyvertical/smrt-languages": "0.47.1",
125
+ "@happyvertical/smrt-types": "0.47.1",
126
+ "@happyvertical/smrt-ui": "0.47.1",
127
+ "@happyvertical/smrt-web": "0.47.1",
124
128
  "@tanstack/db": "^0.6.14",
125
129
  "@tanstack/svelte-db": "^0.1.91",
126
- "esm-env": "^1.2.2",
127
- "@happyvertical/smrt-languages": "0.46.0",
128
- "@happyvertical/smrt-ui": "0.46.0",
129
- "@happyvertical/smrt-types": "0.46.0",
130
- "@happyvertical/smrt-web": "0.46.0"
130
+ "esm-env": "^1.2.2"
131
131
  },
132
132
  "peerDependencies": {
133
133
  "@huggingface/transformers": ">=3.8.1",
@@ -155,6 +155,14 @@
155
155
  }
156
156
  },
157
157
  "devDependencies": {
158
+ "@happyvertical/smrt-agents": "0.47.1",
159
+ "@happyvertical/smrt-chat": "0.47.1",
160
+ "@happyvertical/smrt-content": "0.47.1",
161
+ "@happyvertical/smrt-core": "0.47.1",
162
+ "@happyvertical/smrt-reports": "0.47.1",
163
+ "@happyvertical/smrt-scanner": "0.47.1",
164
+ "@happyvertical/smrt-tenancy": "0.47.1",
165
+ "@happyvertical/smrt-users": "0.47.1",
158
166
  "@sveltejs/package": "^2.5.8",
159
167
  "@sveltejs/vite-plugin-svelte": "^7.1.2",
160
168
  "@testing-library/jest-dom": "^6.9.1",
@@ -166,23 +174,15 @@
166
174
  "svelte-check": "^4.7.1",
167
175
  "typescript": "5.9.3",
168
176
  "vite": "8.1.4",
169
- "vitest": "4.1.10",
170
- "@happyvertical/smrt-agents": "0.46.0",
171
- "@happyvertical/smrt-chat": "0.46.0",
172
- "@happyvertical/smrt-content": "0.46.0",
173
- "@happyvertical/smrt-core": "0.46.0",
174
- "@happyvertical/smrt-reports": "0.46.0",
175
- "@happyvertical/smrt-tenancy": "0.46.0",
176
- "@happyvertical/smrt-users": "0.46.0",
177
- "@happyvertical/smrt-scanner": "0.46.0"
177
+ "vitest": "4.1.10"
178
178
  },
179
179
  "scripts": {
180
180
  "build": "svelte-package -i src --tsconfig tsconfig.svelte.json",
181
181
  "dev": "svelte-package -i src --watch",
182
182
  "check": "pnpm exec svelte-check --tsconfig ./tsconfig.svelte.json",
183
183
  "test": "pnpm run test:unit && pnpm run test:conformance",
184
- "test:unit": "vitest run --exclude src/web/__tests__/data-surface-conformance.integration.svelte.test.ts",
185
- "test:conformance": "pnpm --filter @happyvertical/smrt-svelte... build && SMRT_SVELTE_PUBLISHED_ENTRY=1 vitest run src/web/__tests__/data-surface-conformance.integration.svelte.test.ts",
184
+ "test:unit": "vitest run --exclude src/web/__tests__/data-surface-conformance.integration.svelte.test.ts && node --test scripts/ensure-conformance-build.test.mjs",
185
+ "test:conformance": "node scripts/ensure-conformance-build.mjs && SMRT_SVELTE_PUBLISHED_ENTRY=1 vitest run src/web/__tests__/data-surface-conformance.integration.svelte.test.ts",
186
186
  "typecheck:conformance": "tsc --noEmit -p ./tsconfig.conformance.json",
187
187
  "typecheck": "tsc --noEmit && pnpm run typecheck:conformance && node ../../scripts/svelte-check-a11y.mjs --tsconfig ./tsconfig.svelte.json",
188
188
  "verify:pack": "node ../../scripts/verify-package-types-exports.js ."