@seed-ship/mcp-ui-solid 5.5.1 → 5.7.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 +86 -0
- package/dist/components/FormRenderer.cjs +13 -2
- package/dist/components/FormRenderer.cjs.map +1 -1
- package/dist/components/FormRenderer.d.ts.map +1 -1
- package/dist/components/FormRenderer.js +13 -2
- package/dist/components/FormRenderer.js.map +1 -1
- package/dist/components/GenerativeUIErrorBoundary.cjs +11 -0
- package/dist/components/GenerativeUIErrorBoundary.cjs.map +1 -1
- package/dist/components/GenerativeUIErrorBoundary.d.ts.map +1 -1
- package/dist/components/GenerativeUIErrorBoundary.js +11 -0
- package/dist/components/GenerativeUIErrorBoundary.js.map +1 -1
- package/dist/components/StreamingUIRenderer.cjs +49 -3
- package/dist/components/StreamingUIRenderer.cjs.map +1 -1
- package/dist/components/StreamingUIRenderer.d.ts.map +1 -1
- package/dist/components/StreamingUIRenderer.js +51 -5
- package/dist/components/StreamingUIRenderer.js.map +1 -1
- package/dist/components/UIResourceRenderer.cjs +102 -14
- package/dist/components/UIResourceRenderer.cjs.map +1 -1
- package/dist/components/UIResourceRenderer.d.ts +36 -1
- package/dist/components/UIResourceRenderer.d.ts.map +1 -1
- package/dist/components/UIResourceRenderer.js +104 -16
- package/dist/components/UIResourceRenderer.js.map +1 -1
- package/dist/context/MCPUITelemetryContext.cjs +25 -0
- package/dist/context/MCPUITelemetryContext.cjs.map +1 -0
- package/dist/context/MCPUITelemetryContext.d.ts +36 -0
- package/dist/context/MCPUITelemetryContext.d.ts.map +1 -0
- package/dist/context/MCPUITelemetryContext.js +25 -0
- package/dist/context/MCPUITelemetryContext.js.map +1 -0
- package/dist/index.cjs +7 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.cjs +25 -6
- package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.js +25 -6
- package/dist/mcp-ui-spec/dist/schemas.js.map +1 -1
- package/dist/services/telemetry.cjs +56 -0
- package/dist/services/telemetry.cjs.map +1 -0
- package/dist/services/telemetry.d.ts +87 -0
- package/dist/services/telemetry.d.ts.map +1 -0
- package/dist/services/telemetry.js +56 -0
- package/dist/services/telemetry.js.map +1 -0
- package/dist/services/validation.cjs +25 -24
- package/dist/services/validation.cjs.map +1 -1
- package/dist/services/validation.d.ts.map +1 -1
- package/dist/services/validation.js +26 -25
- package/dist/services/validation.js.map +1 -1
- package/dist/types/index.d.ts +26 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types.d.cts +26 -0
- package/dist/types.d.ts +26 -0
- package/docs/briefs/BRIEF-citations-in-table-cells.md +365 -0
- package/package.json +3 -3
- package/src/components/FormRenderer.tsx +14 -0
- package/src/components/GenerativeUIErrorBoundary.tsx +17 -1
- package/src/components/StreamingUIRenderer.tsx +55 -3
- package/src/components/TableRenderer.citation.test.tsx +157 -0
- package/src/components/UIResourceRenderer.tsx +212 -15
- package/src/context/MCPUITelemetryContext.test.tsx +119 -0
- package/src/context/MCPUITelemetryContext.tsx +71 -0
- package/src/index.ts +20 -0
- package/src/services/telemetry.test.ts +134 -0
- package/src/services/telemetry.ts +149 -0
- package/src/services/validation.ts +43 -41
- package/src/types/index.ts +30 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCPUITelemetryProvider + useTelemetry hook (B.5 — v5.6.0)
|
|
3
|
+
*
|
|
4
|
+
* SolidJS Context wrapper around `createTelemetryDispatcher`. Optional —
|
|
5
|
+
* when no Provider is present, `useTelemetry()` returns `null` and dispatch
|
|
6
|
+
* sites no-op (zero behavior change for apps that don't opt in).
|
|
7
|
+
*
|
|
8
|
+
* See `services/telemetry.ts` for the dispatcher contract and
|
|
9
|
+
* `MCP-UI-AUDIT-2026-04-26.md` §M.6 for the full specification.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
createContext,
|
|
14
|
+
useContext,
|
|
15
|
+
onCleanup,
|
|
16
|
+
type Component,
|
|
17
|
+
type JSX,
|
|
18
|
+
} from 'solid-js'
|
|
19
|
+
import {
|
|
20
|
+
createTelemetryDispatcher,
|
|
21
|
+
type TelemetryDispatcher,
|
|
22
|
+
type TelemetrySink,
|
|
23
|
+
type TelemetryOptions,
|
|
24
|
+
} from '../services/telemetry'
|
|
25
|
+
|
|
26
|
+
export const MCPUITelemetryContext = createContext<TelemetryDispatcher | null>(null)
|
|
27
|
+
|
|
28
|
+
export interface MCPUITelemetryProviderProps {
|
|
29
|
+
/** Consumer-supplied sink. Receives a batch of events. Fail-open. */
|
|
30
|
+
sink: TelemetrySink
|
|
31
|
+
/** Sampling + buffering knobs (defaults: 100% / 100ms / max 50). */
|
|
32
|
+
options?: TelemetryOptions
|
|
33
|
+
children: JSX.Element
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const MCPUITelemetryProvider: Component<MCPUITelemetryProviderProps> = (props) => {
|
|
37
|
+
// Dispatcher is created once per Provider mount. `props.sink` and
|
|
38
|
+
// `props.options` are captured at that moment — consumers should treat
|
|
39
|
+
// them as effectively immutable for the Provider's lifetime (re-mount the
|
|
40
|
+
// Provider to swap them).
|
|
41
|
+
const dispatcher = createTelemetryDispatcher(props.sink, props.options)
|
|
42
|
+
|
|
43
|
+
// Force-flush on unmount so any buffered events from the last bufferMs
|
|
44
|
+
// window aren't lost (e.g. tab close, route change with cleanup).
|
|
45
|
+
onCleanup(() => {
|
|
46
|
+
dispatcher.flush()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<MCPUITelemetryContext.Provider value={dispatcher}>
|
|
51
|
+
{props.children}
|
|
52
|
+
</MCPUITelemetryContext.Provider>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Returns the current telemetry dispatcher, or `null` when no Provider is
|
|
58
|
+
* mounted in the tree above. Dispatch sites should null-check and no-op
|
|
59
|
+
* when null — telemetry is OPT-IN and must not impose a Provider on apps.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```tsx
|
|
63
|
+
* const telemetry = useTelemetry()
|
|
64
|
+
* onMount(() => {
|
|
65
|
+
* telemetry?.dispatch({ type: 'component:mounted', id, componentType, ts: Date.now() })
|
|
66
|
+
* })
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export function useTelemetry(): TelemetryDispatcher | null {
|
|
70
|
+
return useContext(MCPUITelemetryContext)
|
|
71
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -92,10 +92,30 @@ export type {
|
|
|
92
92
|
// Validation error mode (v5.4.0)
|
|
93
93
|
export type { ValidationErrorMode } from './components/UIResourceRenderer'
|
|
94
94
|
|
|
95
|
+
// Citation chips in table cells (v5.7.0 — brief: BRIEF-citations-in-table-cells.md)
|
|
96
|
+
export { renderCellValue } from './components/UIResourceRenderer'
|
|
97
|
+
export type { CitationCtx } from './components/UIResourceRenderer'
|
|
98
|
+
export type { CitationEntry } from './types'
|
|
99
|
+
|
|
95
100
|
// Runtime debug mode + perf marks (v5.4.0)
|
|
96
101
|
export { setDebugMode, isDebugEnabled } from './utils/logger'
|
|
97
102
|
export { markRenderStart, markRenderEnd, PERF_PREFIX } from './utils/perf'
|
|
98
103
|
|
|
104
|
+
// Telemetry sink (B.5 — v5.6.0)
|
|
105
|
+
export {
|
|
106
|
+
MCPUITelemetryProvider,
|
|
107
|
+
MCPUITelemetryContext,
|
|
108
|
+
useTelemetry,
|
|
109
|
+
} from './context/MCPUITelemetryContext'
|
|
110
|
+
export type { MCPUITelemetryProviderProps } from './context/MCPUITelemetryContext'
|
|
111
|
+
export { createTelemetryDispatcher } from './services/telemetry'
|
|
112
|
+
export type {
|
|
113
|
+
TelemetryEvent,
|
|
114
|
+
TelemetrySink,
|
|
115
|
+
TelemetryOptions,
|
|
116
|
+
TelemetryDispatcher,
|
|
117
|
+
} from './services/telemetry'
|
|
118
|
+
|
|
99
119
|
export type { DraggableGridItemProps } from './components/DraggableGridItem'
|
|
100
120
|
export type { ResizeHandleProps as ResizeHandleComponentProps } from './components/ResizeHandle'
|
|
101
121
|
export type { EditableUIResourceRendererProps } from './components/EditableUIResourceRenderer'
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for createTelemetryDispatcher (B.5 — v5.6.0).
|
|
3
|
+
* Spec: MCP-UI-AUDIT-2026-04-26.md §M.6
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
7
|
+
import { createTelemetryDispatcher, type TelemetryEvent, type TelemetrySink } from './telemetry'
|
|
8
|
+
|
|
9
|
+
function evt(type: TelemetryEvent['type'], id = 'cmp-1'): TelemetryEvent {
|
|
10
|
+
const base = { id, componentType: 'metric' as const, ts: 1_700_000_000_000 }
|
|
11
|
+
switch (type) {
|
|
12
|
+
case 'component:rendered':
|
|
13
|
+
return { type, durationMs: 4, ...base }
|
|
14
|
+
case 'validation:failed':
|
|
15
|
+
return { type, errorCount: 1, firstErrorCode: 'INVALID_METRIC', ...base }
|
|
16
|
+
case 'render:error':
|
|
17
|
+
return { type, errorMessage: 'boom', ...base }
|
|
18
|
+
case 'action:dispatched':
|
|
19
|
+
return { type, actionName: 'submit', ...base }
|
|
20
|
+
default:
|
|
21
|
+
return { type, ...base }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('createTelemetryDispatcher (v5.6.0 — B.5)', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.useFakeTimers()
|
|
28
|
+
})
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.useRealTimers()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('delivers events as a BATCH (array), even with bufferMs=0', () => {
|
|
34
|
+
const sink = vi.fn<TelemetrySink>()
|
|
35
|
+
const d = createTelemetryDispatcher(sink, { bufferMs: 0 })
|
|
36
|
+
d.dispatch(evt('component:mounted'))
|
|
37
|
+
expect(sink).toHaveBeenCalledTimes(1)
|
|
38
|
+
expect(sink.mock.calls[0][0]).toHaveLength(1)
|
|
39
|
+
expect(Array.isArray(sink.mock.calls[0][0])).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('buffers and flushes after bufferMs', () => {
|
|
43
|
+
const sink = vi.fn<TelemetrySink>()
|
|
44
|
+
const d = createTelemetryDispatcher(sink, { bufferMs: 100 })
|
|
45
|
+
d.dispatch(evt('component:mounted', 'a'))
|
|
46
|
+
d.dispatch(evt('component:mounted', 'b'))
|
|
47
|
+
d.dispatch(evt('component:mounted', 'c'))
|
|
48
|
+
expect(sink).not.toHaveBeenCalled()
|
|
49
|
+
|
|
50
|
+
vi.advanceTimersByTime(100)
|
|
51
|
+
|
|
52
|
+
expect(sink).toHaveBeenCalledTimes(1)
|
|
53
|
+
expect(sink.mock.calls[0][0]).toHaveLength(3)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('forces flush when bufferMax reached', () => {
|
|
57
|
+
const sink = vi.fn<TelemetrySink>()
|
|
58
|
+
const d = createTelemetryDispatcher(sink, { bufferMs: 5_000, bufferMax: 3 })
|
|
59
|
+
d.dispatch(evt('component:mounted', 'a'))
|
|
60
|
+
d.dispatch(evt('component:mounted', 'b'))
|
|
61
|
+
expect(sink).not.toHaveBeenCalled()
|
|
62
|
+
d.dispatch(evt('component:mounted', 'c')) // hits bufferMax
|
|
63
|
+
expect(sink).toHaveBeenCalledTimes(1)
|
|
64
|
+
expect(sink.mock.calls[0][0]).toHaveLength(3)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('flush() is idempotent on empty buffer (no sink call)', () => {
|
|
68
|
+
const sink = vi.fn<TelemetrySink>()
|
|
69
|
+
const d = createTelemetryDispatcher(sink, { bufferMs: 1_000 })
|
|
70
|
+
d.flush()
|
|
71
|
+
d.flush()
|
|
72
|
+
expect(sink).not.toHaveBeenCalled()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('manual flush() clears the pending bufferMs timer', () => {
|
|
76
|
+
const sink = vi.fn<TelemetrySink>()
|
|
77
|
+
const d = createTelemetryDispatcher(sink, { bufferMs: 1_000 })
|
|
78
|
+
d.dispatch(evt('component:mounted'))
|
|
79
|
+
d.flush()
|
|
80
|
+
expect(sink).toHaveBeenCalledTimes(1)
|
|
81
|
+
vi.advanceTimersByTime(2_000)
|
|
82
|
+
// timer was cleared — no second call
|
|
83
|
+
expect(sink).toHaveBeenCalledTimes(1)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('FAIL-OPEN — sink throw does NOT propagate', () => {
|
|
87
|
+
const sink = vi.fn<TelemetrySink>(() => {
|
|
88
|
+
throw new Error('sink down')
|
|
89
|
+
})
|
|
90
|
+
const d = createTelemetryDispatcher(sink, { bufferMs: 0 })
|
|
91
|
+
expect(() => d.dispatch(evt('component:mounted'))).not.toThrow()
|
|
92
|
+
expect(sink).toHaveBeenCalledTimes(1)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('FAIL-OPEN — sink rejected promise does NOT throw at dispatch site', async () => {
|
|
96
|
+
const sink = vi.fn<TelemetrySink>(async () => {
|
|
97
|
+
throw new Error('async sink down')
|
|
98
|
+
})
|
|
99
|
+
const d = createTelemetryDispatcher(sink, { bufferMs: 0 })
|
|
100
|
+
expect(() => d.dispatch(evt('component:mounted'))).not.toThrow()
|
|
101
|
+
// Microtask flush — should NOT bubble unhandled rejection
|
|
102
|
+
await Promise.resolve()
|
|
103
|
+
await Promise.resolve()
|
|
104
|
+
expect(sink).toHaveBeenCalledTimes(1)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('sampleRate 0 drops every event', () => {
|
|
108
|
+
const sink = vi.fn<TelemetrySink>()
|
|
109
|
+
const d = createTelemetryDispatcher(sink, { sampleRate: 0, bufferMs: 0 })
|
|
110
|
+
for (let i = 0; i < 100; i++) d.dispatch(evt('component:mounted'))
|
|
111
|
+
expect(sink).not.toHaveBeenCalled()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('sampleRate 1 keeps every event', () => {
|
|
115
|
+
const sink = vi.fn<TelemetrySink>()
|
|
116
|
+
const d = createTelemetryDispatcher(sink, { sampleRate: 1, bufferMs: 0 })
|
|
117
|
+
for (let i = 0; i < 5; i++) d.dispatch(evt('component:mounted'))
|
|
118
|
+
expect(sink).toHaveBeenCalledTimes(5)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('sampleByType overrides sampleRate per event type', () => {
|
|
122
|
+
const sink = vi.fn<TelemetrySink>()
|
|
123
|
+
const d = createTelemetryDispatcher(sink, {
|
|
124
|
+
sampleRate: 0, // would drop everything
|
|
125
|
+
sampleByType: { 'render:error': 1.0 }, // ...except errors
|
|
126
|
+
bufferMs: 0,
|
|
127
|
+
})
|
|
128
|
+
d.dispatch(evt('component:mounted')) // dropped
|
|
129
|
+
d.dispatch(evt('render:error')) // kept
|
|
130
|
+
d.dispatch(evt('validation:failed')) // dropped
|
|
131
|
+
expect(sink).toHaveBeenCalledTimes(1)
|
|
132
|
+
expect(sink.mock.calls[0][0][0].type).toBe('render:error')
|
|
133
|
+
})
|
|
134
|
+
})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI telemetry sink (B.5 — v5.6.0)
|
|
3
|
+
*
|
|
4
|
+
* Minimal OpenTelemetry-like Provider that lets a consumer (e.g. deposium
|
|
5
|
+
* `/admin/ui-telemetry`) collect lifecycle + error + action events from
|
|
6
|
+
* mcp-ui components without imposing any API change on apps that don't use
|
|
7
|
+
* it. Spec'd in `MCP-UI-AUDIT-2026-04-26.md` §M.6.
|
|
8
|
+
*
|
|
9
|
+
* Three hard rules:
|
|
10
|
+
* 1. Provider is OPTIONAL. When absent, `useTelemetry()` returns `null`
|
|
11
|
+
* and dispatch sites no-op. Existing apps see zero behavior change.
|
|
12
|
+
* 2. Sink is FAIL-OPEN. A `sink()` throw or rejected promise is caught
|
|
13
|
+
* silently — telemetry never crashes the renderer.
|
|
14
|
+
* 3. Events carry NO payload data. Only meta (type + id + counts + timing)
|
|
15
|
+
* to avoid PII / data leaks in centralized logs.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ComponentType } from '../types'
|
|
19
|
+
|
|
20
|
+
interface TelemetryEventBase {
|
|
21
|
+
/** Component instance id (from `UIComponent.id` or auto-generated). */
|
|
22
|
+
id: string
|
|
23
|
+
/** ComponentType, e.g. 'chart' / 'metric' / 'iframe'. */
|
|
24
|
+
componentType: ComponentType
|
|
25
|
+
/** Wall-clock timestamp (ms epoch) for cross-stack correlation. */
|
|
26
|
+
ts: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Discriminated union of all telemetry events emitted by mcp-ui.
|
|
31
|
+
* See §M.6.2 for field semantics + privacy rules.
|
|
32
|
+
*/
|
|
33
|
+
export type TelemetryEvent =
|
|
34
|
+
| ({ type: 'component:mounted' } & TelemetryEventBase)
|
|
35
|
+
| ({ type: 'component:rendered'; durationMs: number } & TelemetryEventBase)
|
|
36
|
+
| ({ type: 'component:unmounted' } & TelemetryEventBase)
|
|
37
|
+
| ({
|
|
38
|
+
type: 'validation:failed'
|
|
39
|
+
errorCount: number
|
|
40
|
+
firstErrorCode: string | null
|
|
41
|
+
} & TelemetryEventBase)
|
|
42
|
+
| ({ type: 'render:error'; errorMessage: string } & TelemetryEventBase)
|
|
43
|
+
| ({ type: 'action:dispatched'; actionName: string } & TelemetryEventBase)
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Consumer-supplied sink. Receives a batch of events (always an array,
|
|
47
|
+
* even for `bufferMs: 0` — single-element array in that case). Errors
|
|
48
|
+
* and rejected promises are caught silently by the dispatcher.
|
|
49
|
+
*/
|
|
50
|
+
export interface TelemetrySink {
|
|
51
|
+
(events: TelemetryEvent[]): void | Promise<void>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TelemetryOptions {
|
|
55
|
+
/** Per-event base sampling rate, 0..1, default 1.0 (all events). */
|
|
56
|
+
sampleRate?: number
|
|
57
|
+
/** Buffer events and flush after N ms (default 100). 0 = no buffer. */
|
|
58
|
+
bufferMs?: number
|
|
59
|
+
/** Max buffered events before forced flush (default 50). */
|
|
60
|
+
bufferMax?: number
|
|
61
|
+
/** Per-event-type override on sampling (high-volume types can be lower). */
|
|
62
|
+
sampleByType?: Partial<Record<TelemetryEvent['type'], number>>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Dispatcher returned by `createTelemetryDispatcher`. Used internally by
|
|
67
|
+
* the Provider, exposed only so tests can drive it without React/Solid.
|
|
68
|
+
*/
|
|
69
|
+
export interface TelemetryDispatcher {
|
|
70
|
+
/**
|
|
71
|
+
* Push an event. Sampling + buffering applied transparently. Never throws.
|
|
72
|
+
*/
|
|
73
|
+
dispatch(event: TelemetryEvent): void
|
|
74
|
+
/**
|
|
75
|
+
* Force-flush the buffer. Useful on tab-hidden / unload, or for tests.
|
|
76
|
+
* Never throws.
|
|
77
|
+
*/
|
|
78
|
+
flush(): void
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const DEFAULT_BUFFER_MS = 100
|
|
82
|
+
const DEFAULT_BUFFER_MAX = 50
|
|
83
|
+
|
|
84
|
+
function shouldSample(
|
|
85
|
+
eventType: TelemetryEvent['type'],
|
|
86
|
+
options: TelemetryOptions | undefined
|
|
87
|
+
): boolean {
|
|
88
|
+
const perTypeRate = options?.sampleByType?.[eventType]
|
|
89
|
+
const rate = perTypeRate !== undefined ? perTypeRate : (options?.sampleRate ?? 1.0)
|
|
90
|
+
if (rate >= 1) return true
|
|
91
|
+
if (rate <= 0) return false
|
|
92
|
+
return Math.random() < rate
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Create a telemetry dispatcher. Pure function, no Solid context — exists
|
|
97
|
+
* separately from the Provider so it can be unit-tested in isolation.
|
|
98
|
+
*/
|
|
99
|
+
export function createTelemetryDispatcher(
|
|
100
|
+
sink: TelemetrySink,
|
|
101
|
+
options?: TelemetryOptions
|
|
102
|
+
): TelemetryDispatcher {
|
|
103
|
+
const buffer: TelemetryEvent[] = []
|
|
104
|
+
const bufferMs = options?.bufferMs ?? DEFAULT_BUFFER_MS
|
|
105
|
+
const bufferMax = options?.bufferMax ?? DEFAULT_BUFFER_MAX
|
|
106
|
+
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
|
107
|
+
|
|
108
|
+
function deliver(batch: TelemetryEvent[]): void {
|
|
109
|
+
try {
|
|
110
|
+
const result = sink(batch)
|
|
111
|
+
// Promise rejections are silenced too (fail-open, §M.6.1).
|
|
112
|
+
if (result && typeof (result as Promise<void>).then === 'function') {
|
|
113
|
+
;(result as Promise<void>).catch(() => {
|
|
114
|
+
/* silent */
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
/* silent */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function flush(): void {
|
|
123
|
+
if (flushTimer !== undefined) {
|
|
124
|
+
clearTimeout(flushTimer)
|
|
125
|
+
flushTimer = undefined
|
|
126
|
+
}
|
|
127
|
+
if (buffer.length === 0) return
|
|
128
|
+
const batch = buffer.splice(0, buffer.length)
|
|
129
|
+
deliver(batch)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function dispatch(event: TelemetryEvent): void {
|
|
133
|
+
if (!shouldSample(event.type, options)) return
|
|
134
|
+
buffer.push(event)
|
|
135
|
+
if (buffer.length >= bufferMax) {
|
|
136
|
+
flush()
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
if (bufferMs <= 0) {
|
|
140
|
+
flush()
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
if (flushTimer === undefined) {
|
|
144
|
+
flushTimer = setTimeout(flush, bufferMs)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { dispatch, flush }
|
|
149
|
+
}
|
|
@@ -22,6 +22,9 @@ import {
|
|
|
22
22
|
ImageGalleryParamsSchema,
|
|
23
23
|
ActionGroupParamsSchema,
|
|
24
24
|
CodeComponentParamsSchema,
|
|
25
|
+
// v5.6.0 — added after spec@5.0.2 relaxations (deposium audit §M)
|
|
26
|
+
MapComponentParamsSchema,
|
|
27
|
+
FormComponentParamsSchema,
|
|
25
28
|
} from '@seed-ship/mcp-ui-spec'
|
|
26
29
|
import type {
|
|
27
30
|
UIComponent,
|
|
@@ -47,7 +50,7 @@ const KNOWN_COMPONENT_TYPES: Set<string> = new Set<ComponentType>([
|
|
|
47
50
|
])
|
|
48
51
|
|
|
49
52
|
/**
|
|
50
|
-
* Spec-driven validation dispatch table (B.1 — v5.5.0).
|
|
53
|
+
* Spec-driven validation dispatch table (B.1 — v5.5.0, expanded in v5.6.0).
|
|
51
54
|
*
|
|
52
55
|
* For each ComponentType where we delegate shape validation to a Zod schema
|
|
53
56
|
* from `@seed-ship/mcp-ui-spec`, this table maps:
|
|
@@ -56,13 +59,13 @@ const KNOWN_COMPONENT_TYPES: Set<string> = new Set<ComponentType>([
|
|
|
56
59
|
* pre-v5.5.0 `errors[].code` API contract — see MCP-UI-AUDIT-2026-04-26.md
|
|
57
60
|
* §I.3.a + §J.1)
|
|
58
61
|
*
|
|
62
|
+
* **v5.6.0** : `map` and `form` joined the dispatch after spec@5.0.2 relaxed
|
|
63
|
+
* their schemas (LatLngPoint union for map.center, regex relax for
|
|
64
|
+
* field.name) per deposium audit §L answers. Closed B.1 to **14/17 types**.
|
|
65
|
+
*
|
|
59
66
|
* Types deliberately omitted (kept on the imperative path):
|
|
60
67
|
* - `chart`, `table` — have rich imperative validators with their own
|
|
61
68
|
* codes (MISSING_DATA, DATA_LENGTH_MISMATCH, RESOURCE_LIMIT_EXCEEDED, …)
|
|
62
|
-
* - `form` — spec FormFieldSchema has strict regex on field
|
|
63
|
-
* names that could reject LLM-generated payloads. Conservative.
|
|
64
|
-
* - `map` — spec center is `tuple([number, number])`; production
|
|
65
|
-
* payloads use `{lat, lng}` objects. Avoid backward-compat regression.
|
|
66
69
|
* - `modal` — all params are optional; nothing to enforce.
|
|
67
70
|
* - `grid`, `footer`, `composite` — pass-through, validated elsewhere.
|
|
68
71
|
*/
|
|
@@ -79,6 +82,9 @@ const SPEC_VALIDATORS: Partial<Record<ComponentType, { schema: ZodSchema; legacy
|
|
|
79
82
|
'action-group': { schema: ActionGroupParamsSchema, legacyCode: 'EMPTY_ACTION_GROUP' },
|
|
80
83
|
code: { schema: CodeComponentParamsSchema, legacyCode: 'INVALID_CODE' },
|
|
81
84
|
artifact: { schema: ArtifactComponentParamsSchema, legacyCode: 'INVALID_ARTIFACT' },
|
|
85
|
+
// v5.6.0 additions
|
|
86
|
+
form: { schema: FormComponentParamsSchema, legacyCode: 'EMPTY_FORM' },
|
|
87
|
+
map: { schema: MapComponentParamsSchema, legacyCode: 'INVALID_MAP' },
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
/**
|
|
@@ -677,35 +683,49 @@ export function validateComponent(
|
|
|
677
683
|
errors.push(...(sizeResult.errors || []))
|
|
678
684
|
}
|
|
679
685
|
|
|
680
|
-
// Type-specific validation (B.1 — v5.5.0).
|
|
686
|
+
// Type-specific validation (B.1 — v5.5.0, expanded v5.6.0).
|
|
681
687
|
//
|
|
682
|
-
//
|
|
683
|
-
// SPEC_VALIDATORS. The
|
|
684
|
-
// need cross-field consistency, resource limits, or
|
|
685
|
-
//
|
|
688
|
+
// 14 types delegate shape validation to Zod schemas in `mcp-ui-spec` via
|
|
689
|
+
// SPEC_VALIDATORS. The 3 remaining types stay imperative because they
|
|
690
|
+
// need cross-field consistency, resource limits, or have nothing to validate
|
|
691
|
+
// (see SPEC_VALIDATORS docstring).
|
|
686
692
|
const specValidator = SPEC_VALIDATORS[component.type]
|
|
687
693
|
if (specValidator) {
|
|
688
694
|
const result = specValidator.schema.safeParse(component.params)
|
|
689
695
|
if (!result.success) {
|
|
690
696
|
errors.push(...mapZodIssuesToErrors(result.error.issues, specValidator.legacyCode))
|
|
691
697
|
}
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
698
|
+
// Post-spec chained checks. Skipped when the shape parse failed to avoid
|
|
699
|
+
// cascading errors on already-broken payloads.
|
|
700
|
+
if (result.success) {
|
|
701
|
+
// Iframe + video: domain whitelist
|
|
702
|
+
if (component.type === 'iframe' || component.type === 'video') {
|
|
703
|
+
const url = (component.params as { url?: string })?.url
|
|
704
|
+
if (typeof url === 'string') {
|
|
705
|
+
const domainResult = validateIframeDomain(url, {
|
|
706
|
+
policy: options?.iframePolicy,
|
|
707
|
+
customDomains: options?.customIframeDomains,
|
|
708
|
+
})
|
|
709
|
+
if (!domainResult.valid) {
|
|
710
|
+
errors.push(...(domainResult.errors || []))
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
// Map (v5.6.0): center OR markers required. Spec has both .optional()
|
|
715
|
+
// since auto-center from markers is supported, but we need ONE of them.
|
|
716
|
+
if (component.type === 'map') {
|
|
717
|
+
const mapParams = component.params as { center?: unknown; markers?: unknown[] }
|
|
718
|
+
if (!mapParams.center && (!Array.isArray(mapParams.markers) || mapParams.markers.length === 0)) {
|
|
719
|
+
errors.push({
|
|
720
|
+
path: 'params',
|
|
721
|
+
message: 'Map must have center or markers',
|
|
722
|
+
code: 'INVALID_MAP',
|
|
723
|
+
})
|
|
704
724
|
}
|
|
705
725
|
}
|
|
706
726
|
}
|
|
707
727
|
} else {
|
|
708
|
-
// Imperative path for chart/table/
|
|
728
|
+
// Imperative path for chart/table/modal/grid/footer/composite.
|
|
709
729
|
switch (component.type) {
|
|
710
730
|
case 'chart': {
|
|
711
731
|
const chartResult = validateChartComponent(component.params as ChartComponentParams, limits)
|
|
@@ -723,24 +743,6 @@ export function validateComponent(
|
|
|
723
743
|
break
|
|
724
744
|
}
|
|
725
745
|
|
|
726
|
-
case 'form': {
|
|
727
|
-
const formParams = component.params as { fields?: unknown[] }
|
|
728
|
-
if (!Array.isArray(formParams.fields) || formParams.fields.length === 0) {
|
|
729
|
-
errors.push({ path: 'params.fields', message: 'Form must have non-empty fields array', code: 'EMPTY_FORM' })
|
|
730
|
-
}
|
|
731
|
-
break
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
case 'map': {
|
|
735
|
-
// Map can auto-detect center from markers, so center is not strictly required.
|
|
736
|
-
// Spec MapComponentParamsSchema would be too strict (tuple-only center) — kept imperative.
|
|
737
|
-
const mapParams = component.params as { center?: unknown; markers?: unknown[] }
|
|
738
|
-
if (!mapParams.center && (!Array.isArray(mapParams.markers) || mapParams.markers.length === 0)) {
|
|
739
|
-
errors.push({ path: 'params', message: 'Map must have center or markers', code: 'INVALID_MAP' })
|
|
740
|
-
}
|
|
741
|
-
break
|
|
742
|
-
}
|
|
743
|
-
|
|
744
746
|
case 'modal':
|
|
745
747
|
// Modal is valid with minimal params (title optional, content can be children).
|
|
746
748
|
break
|
package/src/types/index.ts
CHANGED
|
@@ -173,9 +173,21 @@ export interface TableVirtualizeOptions {
|
|
|
173
173
|
threshold?: number
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Citation map entry — source of a `[N]` citation marker rendered inline
|
|
178
|
+
* inside table cells (v5.7.0). See
|
|
179
|
+
* `mcp-ui-solid/docs/briefs/BRIEF-citations-in-table-cells.md`.
|
|
180
|
+
*/
|
|
181
|
+
export interface CitationEntry {
|
|
182
|
+
page: number | string
|
|
183
|
+
file?: string
|
|
184
|
+
file_id?: number | string
|
|
185
|
+
}
|
|
186
|
+
|
|
176
187
|
/**
|
|
177
188
|
* Table component parameters
|
|
178
189
|
* Updated Sprint Ultimate U.3: Added virtualization support
|
|
190
|
+
* Updated v5.7.0: Optional citationMap + citationRender for chip rendering
|
|
179
191
|
*/
|
|
180
192
|
export interface TableComponentParams {
|
|
181
193
|
title?: string
|
|
@@ -211,6 +223,24 @@ export interface TableComponentParams {
|
|
|
211
223
|
* Custom CSS class (Sprint 7)
|
|
212
224
|
*/
|
|
213
225
|
className?: string
|
|
226
|
+
/**
|
|
227
|
+
* Opt-in citation chip rendering (v5.7.0). Maps marker id (e.g. `1` from
|
|
228
|
+
* `[1]` or `[📄 CITATION 1]` in cell text) to its source. When set,
|
|
229
|
+
* `<TableRenderer>` replaces markers in cell strings with clickable
|
|
230
|
+
* chips carrying `data-citation-page` / `data-citation-doc` /
|
|
231
|
+
* `data-citation-verified` attributes that a host's delegated click
|
|
232
|
+
* handler can route. JSON-serializable — safe to send from MCP servers.
|
|
233
|
+
*/
|
|
234
|
+
citationMap?: Record<string | number, CitationEntry>
|
|
235
|
+
/**
|
|
236
|
+
* Optional override for the chip HTML (v5.7.0). When supplied, wins over
|
|
237
|
+
* the default chip shape. NOT JSON-serializable — must be wired by the
|
|
238
|
+
* consumer at render time, not from a server payload.
|
|
239
|
+
*/
|
|
240
|
+
citationRender?: (
|
|
241
|
+
id: number,
|
|
242
|
+
mapping: CitationEntry | undefined
|
|
243
|
+
) => string
|
|
214
244
|
}
|
|
215
245
|
|
|
216
246
|
/**
|