@happyvertical/smrt-svelte 0.45.3 → 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 +31 -0
- package/README.md +6 -1
- package/dist/web/__tests__/data-surface-conformance.integration.svelte.test.js +1392 -0
- 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 +20 -11
|
@@ -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-web": "0.45.3",
|
|
128
|
-
"@happyvertical/smrt-types": "0.45.3",
|
|
129
|
-
"@happyvertical/smrt-ui": "0.45.3",
|
|
130
|
-
"@happyvertical/smrt-languages": "0.45.3"
|
|
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,16 +174,17 @@
|
|
|
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-scanner": "0.45.3",
|
|
171
|
-
"@happyvertical/smrt-core": "0.45.3"
|
|
177
|
+
"vitest": "4.1.10"
|
|
172
178
|
},
|
|
173
179
|
"scripts": {
|
|
174
180
|
"build": "svelte-package -i src --tsconfig tsconfig.svelte.json",
|
|
175
181
|
"dev": "svelte-package -i src --watch",
|
|
176
182
|
"check": "pnpm exec svelte-check --tsconfig ./tsconfig.svelte.json",
|
|
177
|
-
"test": "
|
|
178
|
-
"
|
|
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",
|
|
186
|
+
"typecheck:conformance": "tsc --noEmit -p ./tsconfig.conformance.json",
|
|
187
|
+
"typecheck": "tsc --noEmit && pnpm run typecheck:conformance && node ../../scripts/svelte-check-a11y.mjs --tsconfig ./tsconfig.svelte.json",
|
|
179
188
|
"verify:pack": "node ../../scripts/verify-package-types-exports.js ."
|
|
180
189
|
}
|
|
181
190
|
}
|