@seed-ship/mcp-ui-solid 5.5.0 → 5.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/components/FormRenderer.cjs +13 -2
  3. package/dist/components/FormRenderer.cjs.map +1 -1
  4. package/dist/components/FormRenderer.d.ts.map +1 -1
  5. package/dist/components/FormRenderer.js +13 -2
  6. package/dist/components/FormRenderer.js.map +1 -1
  7. package/dist/components/GenerativeUIErrorBoundary.cjs +11 -0
  8. package/dist/components/GenerativeUIErrorBoundary.cjs.map +1 -1
  9. package/dist/components/GenerativeUIErrorBoundary.d.ts.map +1 -1
  10. package/dist/components/GenerativeUIErrorBoundary.js +11 -0
  11. package/dist/components/GenerativeUIErrorBoundary.js.map +1 -1
  12. package/dist/components/StreamingUIRenderer.cjs +49 -3
  13. package/dist/components/StreamingUIRenderer.cjs.map +1 -1
  14. package/dist/components/StreamingUIRenderer.d.ts.map +1 -1
  15. package/dist/components/StreamingUIRenderer.js +51 -5
  16. package/dist/components/StreamingUIRenderer.js.map +1 -1
  17. package/dist/components/UIResourceRenderer.cjs +62 -3
  18. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  19. package/dist/components/UIResourceRenderer.d.ts.map +1 -1
  20. package/dist/components/UIResourceRenderer.js +64 -5
  21. package/dist/components/UIResourceRenderer.js.map +1 -1
  22. package/dist/context/MCPUITelemetryContext.cjs +25 -0
  23. package/dist/context/MCPUITelemetryContext.cjs.map +1 -0
  24. package/dist/context/MCPUITelemetryContext.d.ts +36 -0
  25. package/dist/context/MCPUITelemetryContext.d.ts.map +1 -0
  26. package/dist/context/MCPUITelemetryContext.js +25 -0
  27. package/dist/context/MCPUITelemetryContext.js.map +1 -0
  28. package/dist/index.cjs +6 -0
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.d.cts +4 -0
  31. package/dist/index.d.ts +4 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +6 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/mcp-ui-spec/dist/schemas.cjs +16 -5
  36. package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -1
  37. package/dist/mcp-ui-spec/dist/schemas.js +16 -5
  38. package/dist/mcp-ui-spec/dist/schemas.js.map +1 -1
  39. package/dist/services/telemetry.cjs +56 -0
  40. package/dist/services/telemetry.cjs.map +1 -0
  41. package/dist/services/telemetry.d.ts +87 -0
  42. package/dist/services/telemetry.d.ts.map +1 -0
  43. package/dist/services/telemetry.js +56 -0
  44. package/dist/services/telemetry.js.map +1 -0
  45. package/dist/services/validation.cjs +28 -26
  46. package/dist/services/validation.cjs.map +1 -1
  47. package/dist/services/validation.d.ts.map +1 -1
  48. package/dist/services/validation.js +29 -27
  49. package/dist/services/validation.js.map +1 -1
  50. package/package.json +2 -2
  51. package/src/components/FormRenderer.tsx +14 -0
  52. package/src/components/GenerativeUIErrorBoundary.tsx +17 -1
  53. package/src/components/StreamingUIRenderer.tsx +55 -3
  54. package/src/components/UIResourceRenderer.tsx +79 -3
  55. package/src/context/MCPUITelemetryContext.test.tsx +119 -0
  56. package/src/context/MCPUITelemetryContext.tsx +71 -0
  57. package/src/index.ts +15 -0
  58. package/src/services/telemetry.test.ts +134 -0
  59. package/src/services/telemetry.ts +149 -0
  60. package/src/services/validation.test.ts +53 -3
  61. package/src/services/validation.ts +54 -44
  62. package/tsconfig.tsbuildinfo +1 -1
@@ -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
+ }
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { describe, it, expect, vi } from 'vitest'
9
- import { validateComponent, validateChartComponent, getIframeSandbox } from './validation'
9
+ import { validateComponent, validateChartComponent, getIframeSandbox, validateIframeDomain } from './validation'
10
10
  import type { UIComponent, ComponentType } from '../types'
11
11
 
12
12
  /** Helper to create a minimal valid UIComponent for testing */
@@ -22,13 +22,14 @@ function makeComponent(type: ComponentType, params: Record<string, any> = {}): U
22
22
  /** Types that have explicit validation cases in validateComponent */
23
23
  const VALIDATED_TYPES: ComponentType[] = [
24
24
  'chart', 'table', 'metric', 'text', 'iframe', 'image', 'link', 'action',
25
+ 'artifact',
25
26
  ]
26
27
 
27
28
  /** Types that hit the default case (no specific validation) */
28
29
  const PASSTHROUGH_TYPES: ComponentType[] = [
29
30
  'code', 'map', 'form', 'modal', 'action-group',
30
31
  'image-gallery', 'video', 'grid', 'carousel',
31
- 'artifact', 'footer',
32
+ 'footer',
32
33
  ]
33
34
 
34
35
  describe('validateComponent', () => {
@@ -47,7 +48,7 @@ describe('validateComponent', () => {
47
48
  describe('validated types still work', () => {
48
49
  it('validates a valid chart component', () => {
49
50
  const component = makeComponent('chart', {
50
- chartType: 'bar',
51
+ type: 'bar',
51
52
  data: { labels: ['A'], datasets: [{ data: [1] }] },
52
53
  })
53
54
  const result = validateComponent(component)
@@ -327,3 +328,52 @@ describe('getIframeSandbox — tiered sandbox', () => {
327
328
  expect(sandbox).toContain('allow-same-origin')
328
329
  })
329
330
  })
331
+
332
+ describe('validateIframeDomain — security regression (v5.5.1)', () => {
333
+ // Pre-v5.5.1 bug: the predicate was
334
+ // `domain === allowed || domain.endsWith(`.${allowed}`) || allowed === 'localhost'`
335
+ // The third clause `allowed === 'localhost'` was checking the WHITELIST
336
+ // ENTRY (not the domain) — once 'localhost' appeared in DEFAULT_IFRAME_DOMAINS,
337
+ // every URL was accepted. These tests lock the fixed behavior in place.
338
+
339
+ it('REJECTS a non-whitelisted external domain (this used to silently pass)', () => {
340
+ const result = validateIframeDomain('https://evil.example.com/x')
341
+ expect(result.valid).toBe(false)
342
+ expect(result.errors?.[0]?.code).toBe('DOMAIN_NOT_WHITELISTED')
343
+ })
344
+
345
+ it('REJECTS a typo-squat that is NOT a subdomain of any whitelisted entry', () => {
346
+ // youtube-evil.com is not youtube.com nor a subdomain of it
347
+ const result = validateIframeDomain('https://youtube-evil.com/embed/x')
348
+ expect(result.valid).toBe(false)
349
+ expect(result.errors?.[0]?.code).toBe('DOMAIN_NOT_WHITELISTED')
350
+ })
351
+
352
+ it('still accepts a whitelisted domain (quickchart.io)', () => {
353
+ expect(validateIframeDomain('https://quickchart.io/chart?c={}').valid).toBe(true)
354
+ })
355
+
356
+ it('still accepts subdomains of whitelisted entries (player.vimeo.com)', () => {
357
+ expect(validateIframeDomain('https://player.vimeo.com/video/123').valid).toBe(true)
358
+ })
359
+
360
+ it('still accepts localhost (dev convenience)', () => {
361
+ expect(validateIframeDomain('http://localhost:3000/x').valid).toBe(true)
362
+ })
363
+
364
+ it('still accepts 127.0.0.1 (loopback equivalent of localhost)', () => {
365
+ expect(validateIframeDomain('http://127.0.0.1:8080/x').valid).toBe(true)
366
+ })
367
+
368
+ it('respects allow-all policy bypass', () => {
369
+ expect(validateIframeDomain('https://anything.com', { policy: 'allow-all' }).valid).toBe(true)
370
+ })
371
+
372
+ it('extend policy adds custom domains', () => {
373
+ const result = validateIframeDomain('https://my-internal-tool.corp.com/x', {
374
+ policy: 'extend',
375
+ customDomains: ['my-internal-tool.corp.com'],
376
+ })
377
+ expect(result.valid).toBe(true)
378
+ })
379
+ })
@@ -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
  /**
@@ -568,9 +574,17 @@ export function validateIframeDomain(
568
574
  effectiveWhitelist = [...DEFAULT_IFRAME_DOMAINS, ...options.customDomains]
569
575
  }
570
576
 
571
- const isAllowed = effectiveWhitelist.some(
572
- (allowed) => domain === allowed || domain.endsWith(`.${allowed}`) || allowed === 'localhost'
573
- )
577
+ // SECURITY (v5.5.1) — pre-fix bug: predicate was `allowed === 'localhost'`
578
+ // which trivially returned true for every URL once the whitelist contained
579
+ // 'localhost' (an entry from DEFAULT_IFRAME_DOMAINS), making the entire
580
+ // domain whitelist inoperative. Fixed: only the URL's actual hostname
581
+ // being 'localhost' (or a 127.0.0.x loopback) bypasses the whitelist.
582
+ const isLoopback = domain === 'localhost' || /^127(\.\d{1,3}){3}$/.test(domain)
583
+ const isAllowed =
584
+ isLoopback ||
585
+ effectiveWhitelist.some(
586
+ (allowed) => allowed !== 'localhost' && (domain === allowed || domain.endsWith(`.${allowed}`))
587
+ )
574
588
 
575
589
  if (!isAllowed) {
576
590
  return {
@@ -669,35 +683,49 @@ export function validateComponent(
669
683
  errors.push(...(sizeResult.errors || []))
670
684
  }
671
685
 
672
- // Type-specific validation (B.1 — v5.5.0).
686
+ // Type-specific validation (B.1 — v5.5.0, expanded v5.6.0).
673
687
  //
674
- // 12 types delegate shape validation to Zod schemas in `mcp-ui-spec` via
675
- // SPEC_VALIDATORS. The 5 remaining types stay imperative because they
676
- // need cross-field consistency, resource limits, or backward-compat logic
677
- // that pure Zod can't express without `.refine()` (see SPEC_VALIDATORS docstring).
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).
678
692
  const specValidator = SPEC_VALIDATORS[component.type]
679
693
  if (specValidator) {
680
694
  const result = specValidator.schema.safeParse(component.params)
681
695
  if (!result.success) {
682
696
  errors.push(...mapZodIssuesToErrors(result.error.issues, specValidator.legacyCode))
683
697
  }
684
- // Iframe + video: chain the domain whitelist post-check ONLY when the
685
- // shape parse succeeded (i.e. url is present and a string). Skipping the
686
- // domain check when shape failed avoids cascading errors on the same field.
687
- if (result.success && (component.type === 'iframe' || component.type === 'video')) {
688
- const url = (component.params as { url?: string })?.url
689
- if (typeof url === 'string') {
690
- const domainResult = validateIframeDomain(url, {
691
- policy: options?.iframePolicy,
692
- customDomains: options?.customIframeDomains,
693
- })
694
- if (!domainResult.valid) {
695
- errors.push(...(domainResult.errors || []))
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
+ })
696
724
  }
697
725
  }
698
726
  }
699
727
  } else {
700
- // Imperative path for chart/table/form/map/modal/grid/footer/composite.
728
+ // Imperative path for chart/table/modal/grid/footer/composite.
701
729
  switch (component.type) {
702
730
  case 'chart': {
703
731
  const chartResult = validateChartComponent(component.params as ChartComponentParams, limits)
@@ -715,24 +743,6 @@ export function validateComponent(
715
743
  break
716
744
  }
717
745
 
718
- case 'form': {
719
- const formParams = component.params as { fields?: unknown[] }
720
- if (!Array.isArray(formParams.fields) || formParams.fields.length === 0) {
721
- errors.push({ path: 'params.fields', message: 'Form must have non-empty fields array', code: 'EMPTY_FORM' })
722
- }
723
- break
724
- }
725
-
726
- case 'map': {
727
- // Map can auto-detect center from markers, so center is not strictly required.
728
- // Spec MapComponentParamsSchema would be too strict (tuple-only center) — kept imperative.
729
- const mapParams = component.params as { center?: unknown; markers?: unknown[] }
730
- if (!mapParams.center && (!Array.isArray(mapParams.markers) || mapParams.markers.length === 0)) {
731
- errors.push({ path: 'params', message: 'Map must have center or markers', code: 'INVALID_MAP' })
732
- }
733
- break
734
- }
735
-
736
746
  case 'modal':
737
747
  // Modal is valid with minimal params (title optional, content can be children).
738
748
  break