@seed-ship/mcp-ui-solid 6.4.0 → 6.6.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/CHANGELOG.md +230 -0
- package/README.md +37 -0
- package/dist/adapters/connector.cjs +112 -0
- package/dist/adapters/connector.cjs.map +1 -0
- package/dist/adapters/connector.d.ts +71 -0
- package/dist/adapters/connector.d.ts.map +1 -0
- package/dist/adapters/connector.js +112 -0
- package/dist/adapters/connector.js.map +1 -0
- package/dist/adapters/index.d.ts +18 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters.cjs +6 -0
- package/dist/adapters.cjs.map +1 -0
- package/dist/adapters.d.cts +18 -0
- package/dist/adapters.d.ts +18 -0
- package/dist/adapters.js +6 -0
- package/dist/adapters.js.map +1 -0
- package/dist/components/ExpandableWrapper.cjs +24 -6
- package/dist/components/ExpandableWrapper.cjs.map +1 -1
- package/dist/components/ExpandableWrapper.d.ts.map +1 -1
- package/dist/components/ExpandableWrapper.js +24 -6
- package/dist/components/ExpandableWrapper.js.map +1 -1
- package/dist/components/FeedbackInline.cjs +6 -2
- package/dist/components/FeedbackInline.cjs.map +1 -1
- package/dist/components/FeedbackInline.d.ts +2 -2
- package/dist/components/FeedbackInline.d.ts.map +1 -1
- package/dist/components/FeedbackInline.js +7 -3
- package/dist/components/FeedbackInline.js.map +1 -1
- package/dist/components/PresentationFeedback.cjs +207 -0
- package/dist/components/PresentationFeedback.cjs.map +1 -0
- package/dist/components/PresentationFeedback.d.ts +113 -0
- package/dist/components/PresentationFeedback.d.ts.map +1 -0
- package/dist/components/PresentationFeedback.js +207 -0
- package/dist/components/PresentationFeedback.js.map +1 -0
- package/dist/components/StreamingUIRenderer.cjs +82 -195
- package/dist/components/StreamingUIRenderer.cjs.map +1 -1
- package/dist/components/StreamingUIRenderer.d.ts +25 -5
- package/dist/components/StreamingUIRenderer.d.ts.map +1 -1
- package/dist/components/StreamingUIRenderer.js +84 -197
- package/dist/components/StreamingUIRenderer.js.map +1 -1
- package/dist/components/UIResourceRenderer.cjs +40 -10
- package/dist/components/UIResourceRenderer.cjs.map +1 -1
- package/dist/components/UIResourceRenderer.d.ts +20 -0
- package/dist/components/UIResourceRenderer.d.ts.map +1 -1
- package/dist/components/UIResourceRenderer.js +42 -12
- package/dist/components/UIResourceRenderer.js.map +1 -1
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components.cjs +3 -0
- package/dist/components.cjs.map +1 -1
- package/dist/components.d.cts +2 -0
- package/dist/components.d.ts +2 -0
- package/dist/components.js +3 -0
- package/dist/components.js.map +1 -1
- package/dist/context/MCPUIStringsContext.cjs +38 -0
- package/dist/context/MCPUIStringsContext.cjs.map +1 -0
- package/dist/context/MCPUIStringsContext.d.ts +95 -0
- package/dist/context/MCPUIStringsContext.d.ts.map +1 -0
- package/dist/context/MCPUIStringsContext.js +38 -0
- package/dist/context/MCPUIStringsContext.js.map +1 -0
- package/dist/index.cjs +12 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.cjs +103 -0
- package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.js +103 -0
- package/dist/mcp-ui-spec/dist/schemas.js.map +1 -1
- package/dist/utils/duplicate-mount-registry.cjs +27 -0
- package/dist/utils/duplicate-mount-registry.cjs.map +1 -0
- package/dist/utils/duplicate-mount-registry.d.ts +84 -0
- package/dist/utils/duplicate-mount-registry.d.ts.map +1 -0
- package/dist/utils/duplicate-mount-registry.js +27 -0
- package/dist/utils/duplicate-mount-registry.js.map +1 -0
- package/dist/utils/stable-key.cjs +41 -0
- package/dist/utils/stable-key.cjs.map +1 -0
- package/dist/utils/stable-key.d.ts +33 -0
- package/dist/utils/stable-key.d.ts.map +1 -0
- package/dist/utils/stable-key.js +41 -0
- package/dist/utils/stable-key.js.map +1 -0
- package/docs/briefs/ROADMAP-opendata-macro-mcpui.md +912 -0
- package/package.json +17 -5
- package/src/adapters/connector.test.ts +165 -0
- package/src/adapters/connector.ts +234 -0
- package/src/adapters/index.ts +24 -0
- package/src/components/ExpandableWrapper.test.tsx +5 -2
- package/src/components/ExpandableWrapper.tsx +8 -6
- package/src/components/FeedbackInline.test.tsx +6 -3
- package/src/components/FeedbackInline.tsx +8 -6
- package/src/components/PresentationFeedback.test.tsx +163 -0
- package/src/components/PresentationFeedback.tsx +326 -0
- package/src/components/StreamingUIRenderer.parity.test.tsx +158 -0
- package/src/components/StreamingUIRenderer.tsx +42 -166
- package/src/components/UIResourceRenderer.identity.test.tsx +161 -0
- package/src/components/UIResourceRenderer.tsx +63 -2
- package/src/components/index.ts +10 -0
- package/src/context/MCPUIStringsContext.test.tsx +116 -0
- package/src/context/MCPUIStringsContext.tsx +128 -0
- package/src/index.ts +35 -0
- package/src/utils/duplicate-mount-registry.test.ts +82 -0
- package/src/utils/duplicate-mount-registry.ts +113 -0
- package/src/utils/stable-key.test.ts +96 -0
- package/src/utils/stable-key.ts +91 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vite.config.ts +1 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCPUIStringsContext — i18n for the library's own "chrome" strings.
|
|
3
|
+
*
|
|
4
|
+
* @since v6.6.0 (D2 / R4 of ROADMAP-opendata-macro-mcpui)
|
|
5
|
+
*
|
|
6
|
+
* ## Scope — chrome only, NOT content
|
|
7
|
+
*
|
|
8
|
+
* MCP-UI renders two kinds of text :
|
|
9
|
+
*
|
|
10
|
+
* - **Content** — table headers, chart titles, action labels, prompt
|
|
11
|
+
* questions. These come from the payload and are ALREADY localized by
|
|
12
|
+
* whoever produced the payload (the connector / MCP server). MCP-UI
|
|
13
|
+
* renders them verbatim and this context never touches them.
|
|
14
|
+
* - **Chrome** — the handful of strings the library itself hardcodes :
|
|
15
|
+
* the expand-button tooltip, the feedback acknowledgements, etc. THIS
|
|
16
|
+
* is what `MCPUIStrings` covers.
|
|
17
|
+
*
|
|
18
|
+
* There is deliberately no full i18n framework here : no per-renderer
|
|
19
|
+
* `locale` prop, no message catalogue, no ICU. A flat string map behind a
|
|
20
|
+
* context is enough — the chrome surface is small and static.
|
|
21
|
+
*
|
|
22
|
+
* ## Defaults are English
|
|
23
|
+
*
|
|
24
|
+
* `DEFAULT_MCPUI_STRINGS` is English. A published library should not ship
|
|
25
|
+
* hardcoded French. Consumers that want another language wrap their tree
|
|
26
|
+
* in `<MCPUIStringsProvider strings={...}>` with a partial override.
|
|
27
|
+
*
|
|
28
|
+
* Component props that already carry a label (e.g. `FeedbackInline`'s
|
|
29
|
+
* `positiveAck`, `ExpandableWrapper`'s `copyLabel`) keep priority over the
|
|
30
|
+
* provider — the provider only fills the gap when no explicit prop is set.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```tsx
|
|
34
|
+
* import { MCPUIStringsProvider } from '@seed-ship/mcp-ui-solid'
|
|
35
|
+
*
|
|
36
|
+
* <MCPUIStringsProvider strings={{ expand: 'Agrandir', feedbackUseful: 'Utile' }}>
|
|
37
|
+
* <App />
|
|
38
|
+
* </MCPUIStringsProvider>
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { createContext, useContext, type JSX } from 'solid-js'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The library's own chrome strings. Flat map, no interpolation.
|
|
46
|
+
*
|
|
47
|
+
* Marked exhaustive as of v6.6.0 ; new chrome strings added by later
|
|
48
|
+
* renderers extend this interface (the `MCPUIStringsProvider` merge keeps
|
|
49
|
+
* older consumers working — any unset key falls back to the EN default).
|
|
50
|
+
*/
|
|
51
|
+
export interface MCPUIStrings {
|
|
52
|
+
// ── ExpandableWrapper toolbar ──────────────────────────────
|
|
53
|
+
/** `title` of the expand-to-fullscreen button. */
|
|
54
|
+
expand: string
|
|
55
|
+
/** Heading + `aria-label` of the fullscreen modal when no title is given. */
|
|
56
|
+
expandedView: string
|
|
57
|
+
/** Default tooltip of the copy button (overridden by `copyLabel` prop). */
|
|
58
|
+
copyToClipboard: string
|
|
59
|
+
/** `aria-label` of the close button in the fullscreen modal. */
|
|
60
|
+
closeExpandedView: string
|
|
61
|
+
|
|
62
|
+
// ── FeedbackInline (response-quality feedback) ─────────────
|
|
63
|
+
/** `title` of the thumb-up button. */
|
|
64
|
+
feedbackUseful: string
|
|
65
|
+
/** `title` of the thumb-down button. */
|
|
66
|
+
feedbackNotUseful: string
|
|
67
|
+
/** Acknowledgement shown after a positive rating (overridden by `positiveAck`). */
|
|
68
|
+
feedbackPositiveAck: string
|
|
69
|
+
/** Acknowledgement shown after a negative rating (overridden by `negativeAck`). */
|
|
70
|
+
feedbackNegativeAck: string
|
|
71
|
+
|
|
72
|
+
// ── Generic chrome ────────────────────────────────────────
|
|
73
|
+
/** Label of the streaming retry button. */
|
|
74
|
+
retry: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* English defaults. A published library ships no hardcoded non-English
|
|
79
|
+
* chrome — consumers localize via `<MCPUIStringsProvider>`.
|
|
80
|
+
*/
|
|
81
|
+
export const DEFAULT_MCPUI_STRINGS: MCPUIStrings = {
|
|
82
|
+
expand: 'Expand',
|
|
83
|
+
expandedView: 'Expanded view',
|
|
84
|
+
copyToClipboard: 'Copy to clipboard',
|
|
85
|
+
closeExpandedView: 'Close expanded view',
|
|
86
|
+
feedbackUseful: 'Useful',
|
|
87
|
+
feedbackNotUseful: 'Not useful',
|
|
88
|
+
feedbackPositiveAck: 'Thanks!',
|
|
89
|
+
feedbackNegativeAck: "Noted — we'll improve",
|
|
90
|
+
retry: 'Retry',
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const MCPUIStringsContext = createContext<MCPUIStrings>(DEFAULT_MCPUI_STRINGS)
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Reads the active chrome strings. Returns `DEFAULT_MCPUI_STRINGS` when no
|
|
97
|
+
* `<MCPUIStringsProvider>` is mounted above — every renderer works
|
|
98
|
+
* standalone with English chrome.
|
|
99
|
+
*/
|
|
100
|
+
export function useMCPUIStrings(): MCPUIStrings {
|
|
101
|
+
return useContext(MCPUIStringsContext)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface MCPUIStringsProviderProps {
|
|
105
|
+
/**
|
|
106
|
+
* Partial override of the chrome strings. Any key left unset falls back
|
|
107
|
+
* to the English `DEFAULT_MCPUI_STRINGS` — so a consumer can localize
|
|
108
|
+
* just the strings they care about.
|
|
109
|
+
*/
|
|
110
|
+
strings?: Partial<MCPUIStrings>
|
|
111
|
+
children: JSX.Element
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Provides localized chrome strings to every MCP-UI renderer below it.
|
|
116
|
+
* Merges the partial `strings` override over the English defaults.
|
|
117
|
+
*/
|
|
118
|
+
export function MCPUIStringsProvider(props: MCPUIStringsProviderProps): JSX.Element {
|
|
119
|
+
// `props.strings` is read inside the getter so a reactive override
|
|
120
|
+
// (signal-backed) re-propagates ; for the common static case it is read
|
|
121
|
+
// once at mount.
|
|
122
|
+
const value = (): MCPUIStrings => ({ ...DEFAULT_MCPUI_STRINGS, ...props.strings })
|
|
123
|
+
return (
|
|
124
|
+
<MCPUIStringsContext.Provider value={value()}>
|
|
125
|
+
{props.children}
|
|
126
|
+
</MCPUIStringsContext.Provider>
|
|
127
|
+
)
|
|
128
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,21 @@ export { ComponentToolbar } from './components/ComponentToolbar'
|
|
|
40
40
|
export { FeedbackInline } from './components/FeedbackInline'
|
|
41
41
|
export type { FeedbackInlineProps, FeedbackInlineContext } from './components/FeedbackInline'
|
|
42
42
|
|
|
43
|
+
// Presentation feedback (v6.6.0 — R3 ; separate axis from FeedbackInline)
|
|
44
|
+
export {
|
|
45
|
+
PresentationFeedback,
|
|
46
|
+
DEFAULT_PRESENTATION_FEEDBACK_LABELS,
|
|
47
|
+
} from './components/PresentationFeedback'
|
|
48
|
+
export type {
|
|
49
|
+
PresentationFeedbackProps,
|
|
50
|
+
PresentationFeedbackLabels,
|
|
51
|
+
} from './components/PresentationFeedback'
|
|
52
|
+
export type {
|
|
53
|
+
ConnectorRenderFeedback,
|
|
54
|
+
ConnectorRenderProblem,
|
|
55
|
+
ConnectorPreferredLayout,
|
|
56
|
+
} from '@seed-ship/mcp-ui-spec'
|
|
57
|
+
|
|
43
58
|
// Chat Bus (v2.4.0 — @experimental)
|
|
44
59
|
export { ChatBusProvider, useChatBus } from './hooks/useChatBus'
|
|
45
60
|
export { ChatPrompt } from './components/ChatPrompt'
|
|
@@ -105,6 +120,26 @@ export { GraphRenderer, isG6Available, graphToMermaid, graphToJSON } from './com
|
|
|
105
120
|
export { setDebugMode, isDebugEnabled } from './utils/logger'
|
|
106
121
|
export { markRenderStart, markRenderEnd, PERF_PREFIX } from './utils/perf'
|
|
107
122
|
|
|
123
|
+
// Identity stability + opt-in observability (v6.5.0 — closes BRIEF-MCPUI-2026-05-10)
|
|
124
|
+
export { getUiResourceStableKey } from './utils/stable-key'
|
|
125
|
+
export { setDuplicateMountReporter } from './utils/duplicate-mount-registry'
|
|
126
|
+
export type {
|
|
127
|
+
DuplicateMountInfo,
|
|
128
|
+
DuplicateMountReporter,
|
|
129
|
+
} from './utils/duplicate-mount-registry'
|
|
130
|
+
|
|
131
|
+
// Chrome i18n — library's own strings, EN defaults (v6.6.0 — D2/R4)
|
|
132
|
+
export {
|
|
133
|
+
MCPUIStringsProvider,
|
|
134
|
+
MCPUIStringsContext,
|
|
135
|
+
useMCPUIStrings,
|
|
136
|
+
DEFAULT_MCPUI_STRINGS,
|
|
137
|
+
} from './context/MCPUIStringsContext'
|
|
138
|
+
export type {
|
|
139
|
+
MCPUIStrings,
|
|
140
|
+
MCPUIStringsProviderProps,
|
|
141
|
+
} from './context/MCPUIStringsContext'
|
|
142
|
+
|
|
108
143
|
// Telemetry sink (B.5 — v5.6.0)
|
|
109
144
|
export {
|
|
110
145
|
MCPUITelemetryProvider,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
setDuplicateMountReporter,
|
|
4
|
+
getDuplicateMountReporter,
|
|
5
|
+
_registerMount,
|
|
6
|
+
_unregisterMount,
|
|
7
|
+
_resetRegistry,
|
|
8
|
+
_getMountCount,
|
|
9
|
+
} from './duplicate-mount-registry'
|
|
10
|
+
|
|
11
|
+
describe('duplicate-mount-registry (v6.5.0)', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
_resetRegistry()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('first mount returns count=1, no duplicate', () => {
|
|
17
|
+
const info = _registerMount('alpha')
|
|
18
|
+
expect(info.key).toBe('alpha')
|
|
19
|
+
expect(info.count).toBe(1)
|
|
20
|
+
expect(typeof info.firstMountedAt).toBe('number')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('second concurrent mount returns count=2 (caller decides to warn)', () => {
|
|
24
|
+
_registerMount('beta')
|
|
25
|
+
const info = _registerMount('beta')
|
|
26
|
+
expect(info.count).toBe(2)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('unregister decrements and cleans up at 0', () => {
|
|
30
|
+
_registerMount('gamma')
|
|
31
|
+
_registerMount('gamma')
|
|
32
|
+
expect(_getMountCount('gamma')).toBe(2)
|
|
33
|
+
_unregisterMount('gamma')
|
|
34
|
+
expect(_getMountCount('gamma')).toBe(1)
|
|
35
|
+
_unregisterMount('gamma')
|
|
36
|
+
expect(_getMountCount('gamma')).toBe(0)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('unregister on unknown key is a no-op (no throw)', () => {
|
|
40
|
+
expect(() => _unregisterMount('does-not-exist')).not.toThrow()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('firstMountedAt is preserved across the lifetime of the entry', () => {
|
|
44
|
+
const a = _registerMount('delta')
|
|
45
|
+
const b = _registerMount('delta')
|
|
46
|
+
expect(b.firstMountedAt).toBe(a.firstMountedAt)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('firstMountedAt resets after a full cleanup cycle', async () => {
|
|
50
|
+
const first = _registerMount('epsilon')
|
|
51
|
+
_unregisterMount('epsilon')
|
|
52
|
+
// Tiny delay to guarantee a different Date.now() reading
|
|
53
|
+
await new Promise((r) => setTimeout(r, 2))
|
|
54
|
+
const second = _registerMount('epsilon')
|
|
55
|
+
expect(second.firstMountedAt).toBeGreaterThanOrEqual(first.firstMountedAt)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('module reporter starts unwired (null)', () => {
|
|
59
|
+
expect(getDuplicateMountReporter()).toBeNull()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('setDuplicateMountReporter wires + replaces + clears', () => {
|
|
63
|
+
const r1 = vi.fn()
|
|
64
|
+
setDuplicateMountReporter(r1)
|
|
65
|
+
expect(getDuplicateMountReporter()).toBe(r1)
|
|
66
|
+
|
|
67
|
+
const r2 = vi.fn()
|
|
68
|
+
setDuplicateMountReporter(r2)
|
|
69
|
+
expect(getDuplicateMountReporter()).toBe(r2)
|
|
70
|
+
|
|
71
|
+
setDuplicateMountReporter(null)
|
|
72
|
+
expect(getDuplicateMountReporter()).toBeNull()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('different keys live in independent slots', () => {
|
|
76
|
+
_registerMount('zeta')
|
|
77
|
+
_registerMount('eta')
|
|
78
|
+
_registerMount('zeta')
|
|
79
|
+
expect(_getMountCount('zeta')).toBe(2)
|
|
80
|
+
expect(_getMountCount('eta')).toBe(1)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in duplicate-mount registry (v6.5.0).
|
|
3
|
+
*
|
|
4
|
+
* Tracks how many times each `getUiResourceStableKey()` has been mounted
|
|
5
|
+
* concurrently across all `<UIResourceRenderer>` instances. When the same
|
|
6
|
+
* key is mounted more than once, registered reporters fire so consumers
|
|
7
|
+
* can detect double-render bugs in their parent framework.
|
|
8
|
+
*
|
|
9
|
+
* **Opt-in by design** : the registry is always populated (cheap), but
|
|
10
|
+
* notifications only fire when a consumer has wired one of the two opt-in
|
|
11
|
+
* paths :
|
|
12
|
+
* - module-level `setDuplicateMountReporter(fn)` (app-wide telemetry)
|
|
13
|
+
* - per-instance `<UIResourceRenderer onMountDuplicate={fn}>` prop
|
|
14
|
+
*
|
|
15
|
+
* **What this does NOT do** : visual deduplication. The renderer never
|
|
16
|
+
* hides or replaces a duplicate mount automatically — that would mask
|
|
17
|
+
* parent-framework bugs and could remove legitimate co-mounts (e.g. drawer
|
|
18
|
+
* + main panel showing the same card). Consumers who want dedup implement
|
|
19
|
+
* it on top of the reported events.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface DuplicateMountInfo {
|
|
23
|
+
/** Stable key from `getUiResourceStableKey(content)`. */
|
|
24
|
+
key: string
|
|
25
|
+
/**
|
|
26
|
+
* Current concurrent mount count. The reporter fires whenever this
|
|
27
|
+
* crosses 2 (i.e. on the 2nd, 3rd, etc. mount of the same key while
|
|
28
|
+
* earlier mounts are still alive).
|
|
29
|
+
*/
|
|
30
|
+
count: number
|
|
31
|
+
/** `Date.now()` of the FIRST mount of this key (telemetry, not identity). */
|
|
32
|
+
firstMountedAt: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type DuplicateMountReporter = (info: DuplicateMountInfo) => void
|
|
36
|
+
|
|
37
|
+
const registry = new Map<string, { count: number; firstMountedAt: number }>()
|
|
38
|
+
let moduleReporter: DuplicateMountReporter | null = null
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Wire a module-level reporter for duplicate mount events. Pass `null` to
|
|
42
|
+
* unwire. Only one module reporter at a time (replaces any previous one).
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* import { setDuplicateMountReporter } from '@seed-ship/mcp-ui-solid'
|
|
47
|
+
*
|
|
48
|
+
* setDuplicateMountReporter(({ key, count }) => {
|
|
49
|
+
* telemetry.warn('mcp-ui.duplicate-mount', { key, count })
|
|
50
|
+
* })
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function setDuplicateMountReporter(reporter: DuplicateMountReporter | null): void {
|
|
54
|
+
moduleReporter = reporter
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Internal — read by `<UIResourceRenderer>` to dispatch on mount. Not part
|
|
59
|
+
* of the public API.
|
|
60
|
+
*
|
|
61
|
+
* @internal
|
|
62
|
+
*/
|
|
63
|
+
export function getDuplicateMountReporter(): DuplicateMountReporter | null {
|
|
64
|
+
return moduleReporter
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Internal — registers a mount for `key` and returns the resulting state.
|
|
69
|
+
* The caller decides whether to surface a notification based on `count > 1`.
|
|
70
|
+
*
|
|
71
|
+
* @internal
|
|
72
|
+
*/
|
|
73
|
+
export function _registerMount(key: string): DuplicateMountInfo {
|
|
74
|
+
const entry = registry.get(key) ?? { count: 0, firstMountedAt: Date.now() }
|
|
75
|
+
entry.count += 1
|
|
76
|
+
registry.set(key, entry)
|
|
77
|
+
return { key, count: entry.count, firstMountedAt: entry.firstMountedAt }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Internal — undoes a prior `_registerMount(key)`. Removes the entry when
|
|
82
|
+
* the count reaches zero so the registry never leaks across mount/unmount
|
|
83
|
+
* cycles of unique keys.
|
|
84
|
+
*
|
|
85
|
+
* @internal
|
|
86
|
+
*/
|
|
87
|
+
export function _unregisterMount(key: string): void {
|
|
88
|
+
const entry = registry.get(key)
|
|
89
|
+
if (!entry) return
|
|
90
|
+
entry.count -= 1
|
|
91
|
+
if (entry.count <= 0) registry.delete(key)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Internal — clears the registry and unwires any module reporter. Used by
|
|
96
|
+
* tests to ensure isolation between cases.
|
|
97
|
+
*
|
|
98
|
+
* @internal
|
|
99
|
+
*/
|
|
100
|
+
export function _resetRegistry(): void {
|
|
101
|
+
registry.clear()
|
|
102
|
+
moduleReporter = null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Internal — read the current count for a key (0 if not mounted). Useful
|
|
107
|
+
* for tests and for consumers building their own debug overlays.
|
|
108
|
+
*
|
|
109
|
+
* @internal
|
|
110
|
+
*/
|
|
111
|
+
export function _getMountCount(key: string): number {
|
|
112
|
+
return registry.get(key)?.count ?? 0
|
|
113
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { getUiResourceStableKey } from './stable-key'
|
|
3
|
+
|
|
4
|
+
describe('getUiResourceStableKey (v6.5.0)', () => {
|
|
5
|
+
it('returns layout.id verbatim when present and non-empty', () => {
|
|
6
|
+
const layout = { id: 'dashboard-2024-Q3', components: [], grid: { columns: 12, gap: '1rem' } }
|
|
7
|
+
expect(getUiResourceStableKey(layout)).toBe('dashboard-2024-Q3')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('returns component.id verbatim when present and non-empty', () => {
|
|
11
|
+
const component = { id: 'chart-revenue', type: 'chart', params: {} }
|
|
12
|
+
expect(getUiResourceStableKey(component)).toBe('chart-revenue')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('falls back to a content hash when id is missing', () => {
|
|
16
|
+
const bare = { type: 'chart', params: { type: 'bar', data: { labels: ['a'], datasets: [] } } }
|
|
17
|
+
const key = getUiResourceStableKey(bare)
|
|
18
|
+
expect(key).toMatch(/^[a-z0-9]{7}$/)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('falls back to a content hash when id is empty string', () => {
|
|
22
|
+
const bare = { id: '', type: 'chart', params: {} }
|
|
23
|
+
const key = getUiResourceStableKey(bare)
|
|
24
|
+
expect(key).toMatch(/^[a-z0-9]{7}$/)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('produces the same key across calls for structurally identical payloads', () => {
|
|
28
|
+
const a = { type: 'chart', params: { foo: 1, bar: 2 } }
|
|
29
|
+
const b = { type: 'chart', params: { foo: 1, bar: 2 } }
|
|
30
|
+
expect(getUiResourceStableKey(a)).toBe(getUiResourceStableKey(b))
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('is independent of object key insertion order', () => {
|
|
34
|
+
const a = { type: 'chart', params: { foo: 1, bar: 2 } }
|
|
35
|
+
const b = { params: { bar: 2, foo: 1 }, type: 'chart' }
|
|
36
|
+
expect(getUiResourceStableKey(a)).toBe(getUiResourceStableKey(b))
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('produces different keys for different payloads', () => {
|
|
40
|
+
const a = { type: 'chart', params: { x: 1 } }
|
|
41
|
+
const b = { type: 'chart', params: { x: 2 } }
|
|
42
|
+
expect(getUiResourceStableKey(a)).not.toBe(getUiResourceStableKey(b))
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('ignores metadata.generatedAt (timestamp must not affect identity)', () => {
|
|
46
|
+
const a = { type: 'chart', params: { x: 1 }, metadata: { generatedAt: '2026-05-10T10:00:00Z', llmModel: 'opus' } }
|
|
47
|
+
const b = { type: 'chart', params: { x: 1 }, metadata: { generatedAt: '2026-05-10T11:00:00Z', llmModel: 'opus' } }
|
|
48
|
+
expect(getUiResourceStableKey(a)).toBe(getUiResourceStableKey(b))
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('still distinguishes payloads with different non-timestamp metadata', () => {
|
|
52
|
+
const a = { type: 'chart', params: { x: 1 }, metadata: { llmModel: 'opus' } }
|
|
53
|
+
const b = { type: 'chart', params: { x: 1 }, metadata: { llmModel: 'sonnet' } }
|
|
54
|
+
expect(getUiResourceStableKey(a)).not.toBe(getUiResourceStableKey(b))
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('skips undefined entries deterministically', () => {
|
|
58
|
+
const a = { type: 'chart', params: { x: 1, y: undefined } }
|
|
59
|
+
const b = { type: 'chart', params: { x: 1 } }
|
|
60
|
+
expect(getUiResourceStableKey(a)).toBe(getUiResourceStableKey(b))
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('handles nested arrays', () => {
|
|
64
|
+
const a = { type: 'composite', components: [{ type: 'metric' }, { type: 'chart' }] }
|
|
65
|
+
const b = { type: 'composite', components: [{ type: 'metric' }, { type: 'chart' }] }
|
|
66
|
+
expect(getUiResourceStableKey(a)).toBe(getUiResourceStableKey(b))
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('different array order yields different keys (order is semantic)', () => {
|
|
70
|
+
const a = { type: 'composite', components: [{ type: 'metric' }, { type: 'chart' }] }
|
|
71
|
+
const b = { type: 'composite', components: [{ type: 'chart' }, { type: 'metric' }] }
|
|
72
|
+
expect(getUiResourceStableKey(a)).not.toBe(getUiResourceStableKey(b))
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('handles primitives gracefully', () => {
|
|
76
|
+
expect(getUiResourceStableKey('a string')).toMatch(/^[a-z0-9]{7}$/)
|
|
77
|
+
expect(getUiResourceStableKey(42)).toMatch(/^[a-z0-9]{7}$/)
|
|
78
|
+
expect(getUiResourceStableKey(null)).toMatch(/^[a-z0-9]{7}$/)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('keeps the explicit id even if other fields would hash differently', () => {
|
|
82
|
+
const a = { id: 'fixed', type: 'chart', params: { x: 1 } }
|
|
83
|
+
const b = { id: 'fixed', type: 'chart', params: { x: 999 } }
|
|
84
|
+
expect(getUiResourceStableKey(a)).toBe('fixed')
|
|
85
|
+
expect(getUiResourceStableKey(b)).toBe('fixed')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('generated timestamp ids are NOT special-cased — passthrough is intentional', () => {
|
|
89
|
+
// If a consumer (incorrectly) injects `wrap-${Date.now()}` ids, they get
|
|
90
|
+
// unique keys per render. That's their responsibility — the helper only
|
|
91
|
+
// strips the `id` field when it's missing or empty.
|
|
92
|
+
const a = { id: 'wrap-1700000000000', type: 'chart', params: {} }
|
|
93
|
+
const b = { id: 'wrap-1700000000001', type: 'chart', params: {} }
|
|
94
|
+
expect(getUiResourceStableKey(a)).not.toBe(getUiResourceStableKey(b))
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable identity key for UIResource payloads (v6.5.0).
|
|
3
|
+
*
|
|
4
|
+
* Consumers need a way to derive a deterministic key from a layout/component
|
|
5
|
+
* payload — for `<For>` keys, dedup detection, telemetry correlation, etc.
|
|
6
|
+
*
|
|
7
|
+
* Spec semantics : `UILayout.id` and `UIComponent.id` are obligatoires for
|
|
8
|
+
* any well-formed payload. When they are present and non-empty, this helper
|
|
9
|
+
* returns them as-is. When they are missing (e.g. consumer passing a "bare"
|
|
10
|
+
* chart payload `{ type: 'chart', params: {...} }` without wrapping it in
|
|
11
|
+
* a layout), the helper derives a stable key from the *content* — never
|
|
12
|
+
* from a timestamp or counter.
|
|
13
|
+
*
|
|
14
|
+
* The hash is FNV-1a 32-bit on a deterministically stringified form of the
|
|
15
|
+
* payload (sorted keys, undefined entries skipped). This is intentionally
|
|
16
|
+
* synchronous and dependency-free so consumers can call it inside a Solid
|
|
17
|
+
* memo or render function without ceremony.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const FNV_OFFSET_BASIS = 0x811c9dc5
|
|
21
|
+
const FNV_PRIME = 0x01000193
|
|
22
|
+
|
|
23
|
+
function fnv1a(str: string): string {
|
|
24
|
+
let hash = FNV_OFFSET_BASIS
|
|
25
|
+
for (let i = 0; i < str.length; i++) {
|
|
26
|
+
hash ^= str.charCodeAt(i)
|
|
27
|
+
hash = Math.imul(hash, FNV_PRIME)
|
|
28
|
+
}
|
|
29
|
+
return (hash >>> 0).toString(36).padStart(7, '0')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deterministic JSON-like serialization. Object keys are sorted ; entries
|
|
34
|
+
* with `undefined` values are skipped (mirroring `JSON.stringify` semantics
|
|
35
|
+
* but with a stable order). Used as the input to `fnv1a()`.
|
|
36
|
+
*/
|
|
37
|
+
function stableStringify(value: unknown): string {
|
|
38
|
+
if (value === undefined) return 'undefined'
|
|
39
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
|
40
|
+
if (Array.isArray(value)) {
|
|
41
|
+
return '[' + value.map(stableStringify).join(',') + ']'
|
|
42
|
+
}
|
|
43
|
+
const obj = value as Record<string, unknown>
|
|
44
|
+
const keys = Object.keys(obj)
|
|
45
|
+
.sort()
|
|
46
|
+
.filter((k) => obj[k] !== undefined)
|
|
47
|
+
return (
|
|
48
|
+
'{' +
|
|
49
|
+
keys.map((k) => JSON.stringify(k) + ':' + stableStringify(obj[k])).join(',') +
|
|
50
|
+
'}'
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Strip fields that should NOT contribute to identity :
|
|
56
|
+
* - top-level `id` : we're computing the absent identity
|
|
57
|
+
* - `metadata.generatedAt` : timestamp of generation, not of identity
|
|
58
|
+
*/
|
|
59
|
+
function normalizeForHash(input: unknown): unknown {
|
|
60
|
+
if (!input || typeof input !== 'object') return input
|
|
61
|
+
const { id: _id, ...rest } = input as Record<string, unknown>
|
|
62
|
+
void _id
|
|
63
|
+
if (rest.metadata && typeof rest.metadata === 'object' && !Array.isArray(rest.metadata)) {
|
|
64
|
+
const meta = rest.metadata as Record<string, unknown>
|
|
65
|
+
const { generatedAt: _t, ...metaRest } = meta
|
|
66
|
+
void _t
|
|
67
|
+
rest.metadata = Object.keys(metaRest).length > 0 ? metaRest : undefined
|
|
68
|
+
}
|
|
69
|
+
return rest
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Returns a stable identity key for a UIResource payload.
|
|
74
|
+
*
|
|
75
|
+
* - If `input.id` is a non-empty string, returns it verbatim. This is the
|
|
76
|
+
* path taken by well-formed payloads (cf. spec §Identity).
|
|
77
|
+
* - Otherwise, returns a 7-char base36 FNV-1a hash of the normalized
|
|
78
|
+
* content. Stable across renders, identical for structurally identical
|
|
79
|
+
* payloads.
|
|
80
|
+
*
|
|
81
|
+
* The hash is NOT cryptographic ; it's a dedup/correlation key. Collisions
|
|
82
|
+
* are theoretically possible but vanishingly rare for the payload shapes
|
|
83
|
+
* MCP-UI emits in practice.
|
|
84
|
+
*/
|
|
85
|
+
export function getUiResourceStableKey(input: unknown): string {
|
|
86
|
+
if (input && typeof input === 'object') {
|
|
87
|
+
const id = (input as { id?: unknown }).id
|
|
88
|
+
if (typeof id === 'string' && id.length > 0) return id
|
|
89
|
+
}
|
|
90
|
+
return fnv1a(stableStringify(normalizeForHash(input)))
|
|
91
|
+
}
|