@happyvertical/smrt-svelte 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 +23 -0
- package/README.md +6 -1
- package/dist/web/__tests__/view-intent.svelte.test.js +44 -1
- package/dist/web/__tests__/webmcp-ui.test.js +120 -1
- package/dist/web/__tests__/webmcp.test.js +48 -0
- package/dist/web/view-intent.svelte.d.ts +1 -1
- package/dist/web/view-intent.svelte.d.ts.map +1 -1
- package/dist/web/view-intent.svelte.js +10 -2
- package/dist/web/webmcp-ui.d.ts.map +1 -1
- package/dist/web/webmcp-ui.js +36 -4
- package/dist/web/webmcp.svelte.d.ts +13 -1
- package/dist/web/webmcp.svelte.d.ts.map +1 -1
- package/dist/web/webmcp.svelte.js +39 -13
- package/package.json +15 -15
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
|
-
|
|
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 {
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
},
|
|
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;
|
|
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"}
|
package/dist/web/webmcp-ui.js
CHANGED
|
@@ -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
|
-
|
|
442
|
-
|
|
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
|
|
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;
|
|
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
|
|
77
|
-
//
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
3
|
+
"version": "0.47.0",
|
|
4
4
|
"smrtJsdoc": "strict",
|
|
5
5
|
"description": "Svelte 5 components for SMRT user management - auth, users, tenants, roles, permissions, groups",
|
|
6
6
|
"type": "module",
|
|
@@ -121,13 +121,13 @@
|
|
|
121
121
|
},
|
|
122
122
|
"dependencies": {
|
|
123
123
|
"@happyvertical/logger": "^0.89.4",
|
|
124
|
+
"@happyvertical/smrt-languages": "0.47.0",
|
|
125
|
+
"@happyvertical/smrt-types": "0.47.0",
|
|
126
|
+
"@happyvertical/smrt-ui": "0.47.0",
|
|
127
|
+
"@happyvertical/smrt-web": "0.47.0",
|
|
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.0",
|
|
159
|
+
"@happyvertical/smrt-chat": "0.47.0",
|
|
160
|
+
"@happyvertical/smrt-content": "0.47.0",
|
|
161
|
+
"@happyvertical/smrt-core": "0.47.0",
|
|
162
|
+
"@happyvertical/smrt-reports": "0.47.0",
|
|
163
|
+
"@happyvertical/smrt-scanner": "0.47.0",
|
|
164
|
+
"@happyvertical/smrt-tenancy": "0.47.0",
|
|
165
|
+
"@happyvertical/smrt-users": "0.47.0",
|
|
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,15 +174,7 @@
|
|
|
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",
|