@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +86 -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 +102 -14
  18. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  19. package/dist/components/UIResourceRenderer.d.ts +36 -1
  20. package/dist/components/UIResourceRenderer.d.ts.map +1 -1
  21. package/dist/components/UIResourceRenderer.js +104 -16
  22. package/dist/components/UIResourceRenderer.js.map +1 -1
  23. package/dist/context/MCPUITelemetryContext.cjs +25 -0
  24. package/dist/context/MCPUITelemetryContext.cjs.map +1 -0
  25. package/dist/context/MCPUITelemetryContext.d.ts +36 -0
  26. package/dist/context/MCPUITelemetryContext.d.ts.map +1 -0
  27. package/dist/context/MCPUITelemetryContext.js +25 -0
  28. package/dist/context/MCPUITelemetryContext.js.map +1 -0
  29. package/dist/index.cjs +7 -0
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.d.cts +7 -0
  32. package/dist/index.d.ts +7 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +8 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/mcp-ui-spec/dist/schemas.cjs +25 -6
  37. package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -1
  38. package/dist/mcp-ui-spec/dist/schemas.js +25 -6
  39. package/dist/mcp-ui-spec/dist/schemas.js.map +1 -1
  40. package/dist/services/telemetry.cjs +56 -0
  41. package/dist/services/telemetry.cjs.map +1 -0
  42. package/dist/services/telemetry.d.ts +87 -0
  43. package/dist/services/telemetry.d.ts.map +1 -0
  44. package/dist/services/telemetry.js +56 -0
  45. package/dist/services/telemetry.js.map +1 -0
  46. package/dist/services/validation.cjs +25 -24
  47. package/dist/services/validation.cjs.map +1 -1
  48. package/dist/services/validation.d.ts.map +1 -1
  49. package/dist/services/validation.js +26 -25
  50. package/dist/services/validation.js.map +1 -1
  51. package/dist/types/index.d.ts +26 -0
  52. package/dist/types/index.d.ts.map +1 -1
  53. package/dist/types.d.cts +26 -0
  54. package/dist/types.d.ts +26 -0
  55. package/docs/briefs/BRIEF-citations-in-table-cells.md +365 -0
  56. package/package.json +3 -3
  57. package/src/components/FormRenderer.tsx +14 -0
  58. package/src/components/GenerativeUIErrorBoundary.tsx +17 -1
  59. package/src/components/StreamingUIRenderer.tsx +55 -3
  60. package/src/components/TableRenderer.citation.test.tsx +157 -0
  61. package/src/components/UIResourceRenderer.tsx +212 -15
  62. package/src/context/MCPUITelemetryContext.test.tsx +119 -0
  63. package/src/context/MCPUITelemetryContext.tsx +71 -0
  64. package/src/index.ts +20 -0
  65. package/src/services/telemetry.test.ts +134 -0
  66. package/src/services/telemetry.ts +149 -0
  67. package/src/services/validation.ts +43 -41
  68. package/src/types/index.ts +30 -0
  69. package/tsconfig.tsbuildinfo +1 -1
@@ -21,12 +21,13 @@
21
21
  * ```
22
22
  */
23
23
 
24
- import { Show, For, createSignal, onMount } from 'solid-js'
24
+ import { Show, For, createSignal, onMount, onCleanup } from 'solid-js'
25
25
  import { useStreamingUI, type UseStreamingUIOptions } from '../hooks/useStreamingUI'
26
26
  import type { UIComponent, RendererError } from '../types'
27
27
  import { validateComponent } from '../services/validation'
28
28
  import { GenerativeUIErrorBoundary } from './GenerativeUIErrorBoundary'
29
- import { markRenderStart, markRenderEnd } from '../utils/perf'
29
+ import { markRenderStart, markRenderEnd, PERF_PREFIX } from '../utils/perf'
30
+ import { useTelemetry } from '../context/MCPUITelemetryContext'
30
31
  import type { ValidationErrorMode } from './UIResourceRenderer'
31
32
 
32
33
  export interface StreamingUIRendererProps extends UseStreamingUIOptions {
@@ -53,7 +54,47 @@ function StreamingComponentRenderer(props: {
53
54
  }) {
54
55
  // Performance marks (v5.4.0) — see utils/perf.ts
55
56
  markRenderStart(props.component.id)
56
- onMount(() => markRenderEnd(props.component.id))
57
+
58
+ // Telemetry sink (B.5 — v5.6.0). Same wiring as ComponentRenderer in
59
+ // UIResourceRenderer.tsx — null when no Provider, no-op everywhere then.
60
+ const telemetry = useTelemetry()
61
+
62
+ onMount(() => {
63
+ markRenderEnd(props.component.id)
64
+ if (telemetry) {
65
+ const ts = Date.now()
66
+ telemetry.dispatch({
67
+ type: 'component:mounted',
68
+ id: props.component.id,
69
+ componentType: props.component.type,
70
+ ts,
71
+ })
72
+ if (typeof performance !== 'undefined' && typeof performance.getEntriesByName === 'function') {
73
+ const entries = performance.getEntriesByName(`${PERF_PREFIX}${props.component.id}:render`, 'measure')
74
+ const last = entries[entries.length - 1]
75
+ if (last) {
76
+ telemetry.dispatch({
77
+ type: 'component:rendered',
78
+ id: props.component.id,
79
+ componentType: props.component.type,
80
+ durationMs: last.duration,
81
+ ts,
82
+ })
83
+ }
84
+ }
85
+ }
86
+ })
87
+
88
+ onCleanup(() => {
89
+ if (telemetry) {
90
+ telemetry.dispatch({
91
+ type: 'component:unmounted',
92
+ id: props.component.id,
93
+ componentType: props.component.type,
94
+ ts: Date.now(),
95
+ })
96
+ }
97
+ })
57
98
 
58
99
  // Validate component before rendering
59
100
  const validation = validateComponent(props.component)
@@ -65,6 +106,17 @@ function StreamingComponentRenderer(props: {
65
106
  details: validation.errors,
66
107
  })
67
108
 
109
+ if (telemetry) {
110
+ telemetry.dispatch({
111
+ type: 'validation:failed',
112
+ id: props.component.id,
113
+ componentType: props.component.type,
114
+ errorCount: validation.errors?.length ?? 0,
115
+ firstErrorCode: validation.errors?.[0]?.code ?? null,
116
+ ts: Date.now(),
117
+ })
118
+ }
119
+
68
120
  const mode: ValidationErrorMode = props.errorMode ?? 'block'
69
121
  const firstError = validation.errors?.[0]?.message || 'Unknown validation error'
70
122
 
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Citation chip rendering inside `<TableRenderer>` cells (v5.7.0).
3
+ *
4
+ * Spec: `mcp-ui-solid/docs/briefs/BRIEF-citations-in-table-cells.md`.
5
+ *
6
+ * Tests are split between the pure `renderCellValue(value, citationCtx)`
7
+ * helper (fast, no DOM) and a couple of integration assertions on a real
8
+ * `<UIResourceRenderer>` mount to catch wiring bugs.
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach } from 'vitest'
12
+ import { render, cleanup } from '@solidjs/testing-library'
13
+ import { renderCellValue, UIResourceRenderer } from './UIResourceRenderer'
14
+ import type { CitationCtx } from './UIResourceRenderer'
15
+ import type { UIComponent, TableComponentParams } from '../types'
16
+
17
+ const baseMap: CitationCtx['map'] = {
18
+ '1': { page: 5, file: 'A.pdf' },
19
+ '2': { page: 12, file: 'B.pdf' },
20
+ }
21
+
22
+ describe('renderCellValue — citation transform (v5.7.0)', () => {
23
+ it('NO citationCtx → cell text is unchanged (regression)', () => {
24
+ expect(renderCellValue('[1] ; [4]')).toBe('[1] ; [4]')
25
+ })
26
+
27
+ it('citationCtx with mapped id → cell HTML carries data-citation-page + data-citation-doc', () => {
28
+ const html = renderCellValue('[1]', { map: baseMap })
29
+ expect(html).toContain('data-citation-page="5"')
30
+ expect(html).toContain('data-citation-doc="A.pdf"')
31
+ expect(html).toContain('data-citation-verified="true"')
32
+ expect(html).toContain('class="citation-ref')
33
+ })
34
+
35
+ it('multi-citation cell → multiple chips emitted', () => {
36
+ const html = renderCellValue('[1] ; [2]', { map: baseMap })
37
+ const matches = html.match(/data-citation-page="(\d+)"/g) ?? []
38
+ expect(matches).toHaveLength(2)
39
+ expect(html).toContain('data-citation-page="5"')
40
+ expect(html).toContain('data-citation-page="12"')
41
+ })
42
+
43
+ it('unresolved id with NON-EMPTY map → marker dropped silently (likely hallucination)', () => {
44
+ const html = renderCellValue('[99]', { map: baseMap })
45
+ expect(html).not.toContain('99')
46
+ expect(html).not.toContain('citation-ref')
47
+ expect(html).not.toContain('réf')
48
+ })
49
+
50
+ it('unresolved id with EMPTY map → human-visible `[réf. N]` placeholder', () => {
51
+ const html = renderCellValue('[99]', { map: {} })
52
+ expect(html).toContain('[réf. 99]')
53
+ })
54
+
55
+ it('citationRender override → wins over default chip shape', () => {
56
+ const html = renderCellValue('[1]', {
57
+ map: baseMap,
58
+ render: (id, mapping) => `<a class="custom-chip" data-id="${id}">${mapping?.file ?? '?'}</a>`,
59
+ })
60
+ expect(html).toContain('class="custom-chip"')
61
+ expect(html).toContain('data-id="1"')
62
+ expect(html).not.toContain('data-citation-verified')
63
+ })
64
+
65
+ it('`[p.5]` page form → NOT touched (negative lookbehind)', () => {
66
+ const html = renderCellValue('See [p.5]', { map: baseMap })
67
+ expect(html).toContain('[p.5]')
68
+ expect(html).not.toContain('data-citation-page')
69
+ })
70
+
71
+ it('`[text](url)` markdown link → NOT touched (citation regex skips parens)', () => {
72
+ const html = renderCellValue('[click](https://example.com)', { map: baseMap })
73
+ expect(html).toContain('href="https://example.com"')
74
+ expect(html).not.toContain('data-citation-page')
75
+ })
76
+
77
+ it('mixed `**bold** [1]` → bold becomes <strong> AND chip is rendered (compose)', () => {
78
+ const html = renderCellValue('**MSP** [1]', { map: baseMap })
79
+ expect(html).toContain('<strong>MSP</strong>')
80
+ expect(html).toContain('data-citation-page="5"')
81
+ })
82
+
83
+ it('canonical `[📄 CITATION 1]` marker → chip emitted directly (no normalize step needed)', () => {
84
+ const html = renderCellValue('[📄 CITATION 1]', { map: baseMap })
85
+ expect(html).toContain('data-citation-page="5"')
86
+ })
87
+
88
+ it('all 3 data-citation-* attrs survive DOMPurify (whitelist intact)', () => {
89
+ const html = renderCellValue('[1]', { map: baseMap })
90
+ expect(html).toContain('data-citation-page')
91
+ expect(html).toContain('data-citation-doc')
92
+ expect(html).toContain('data-citation-verified')
93
+ })
94
+
95
+ it('chip emits a button element (host click delegation target)', () => {
96
+ const html = renderCellValue('[1]', { map: baseMap })
97
+ expect(html).toContain('<button')
98
+ expect(html).toMatch(/<button[^>]*data-citation-page="5"/)
99
+ })
100
+ })
101
+
102
+ describe('<TableRenderer> — citationMap wiring (v5.7.0)', () => {
103
+ beforeEach(() => {
104
+ cleanup()
105
+ })
106
+
107
+ function tableComponent(params: Partial<TableComponentParams>): UIComponent {
108
+ return {
109
+ id: 'tbl-cit',
110
+ type: 'table',
111
+ position: { colStart: 1, colSpan: 12 },
112
+ params: {
113
+ columns: [
114
+ { key: 'name', label: 'Name' },
115
+ { key: 'cites', label: 'Citations' },
116
+ ],
117
+ rows: [
118
+ { name: 'MSP', cites: '[1] ; [2]' },
119
+ { name: 'Other', cites: '[1]' },
120
+ ],
121
+ ...params,
122
+ } as TableComponentParams,
123
+ }
124
+ }
125
+
126
+ it('NO citationMap → cells render plain text (regression)', () => {
127
+ const { container } = render(() => (
128
+ <UIResourceRenderer content={tableComponent({})} />
129
+ ))
130
+ const buttons = container.querySelectorAll('[data-citation-page]')
131
+ expect(buttons.length).toBe(0)
132
+ expect(container.textContent).toContain('[1] ; [2]')
133
+ })
134
+
135
+ it('with citationMap → DOM has clickable chips per resolved marker', () => {
136
+ const { container } = render(() => (
137
+ <UIResourceRenderer content={tableComponent({ citationMap: baseMap })} />
138
+ ))
139
+ const buttons = container.querySelectorAll('button[data-citation-page]')
140
+ // Row 1 has 2 markers, row 2 has 1 → 3 chips total
141
+ expect(buttons.length).toBe(3)
142
+ const pages = Array.from(buttons).map((b) => b.getAttribute('data-citation-page'))
143
+ expect(pages.sort()).toEqual(['12', '5', '5'])
144
+ })
145
+
146
+ it('with citationRender override → custom chips replace defaults', () => {
147
+ const customRender = (id: number) => `<a class="my-chip" data-id="${id}">x</a>`
148
+ const { container } = render(() => (
149
+ <UIResourceRenderer
150
+ content={tableComponent({ citationMap: baseMap, citationRender: customRender })}
151
+ />
152
+ ))
153
+ const customs = container.querySelectorAll('a.my-chip')
154
+ expect(customs.length).toBe(3)
155
+ expect(container.querySelector('[data-citation-page]')).toBeNull()
156
+ })
157
+ })
@@ -4,12 +4,13 @@
4
4
  */
5
5
 
6
6
  import DOMPurify from 'dompurify'
7
- import { Component, createSignal, Show, For, createMemo, createEffect, onMount } from 'solid-js'
7
+ import { Component, createSignal, Show, For, createMemo, createEffect, onMount, onCleanup } from 'solid-js'
8
8
  import { isServer } from 'solid-js/web'
9
9
  import type { UIComponent, UILayout, RendererError, TableVirtualizeOptions } from '../types'
10
10
  import { validateComponent, DEFAULT_RESOURCE_LIMITS, getIframeSandbox } from '../services/validation'
11
11
  import { GenerativeUIErrorBoundary } from './GenerativeUIErrorBoundary'
12
- import { markRenderStart, markRenderEnd } from '../utils/perf'
12
+ import { markRenderStart, markRenderEnd, PERF_PREFIX } from '../utils/perf'
13
+ import { useTelemetry } from '../context/MCPUITelemetryContext'
13
14
 
14
15
  /**
15
16
  * How `<UIResourceRenderer>` reacts when `validateComponent()` rejects a
@@ -286,7 +287,98 @@ export function highlightQuery(html: string, query: string): string {
286
287
  })
287
288
  }
288
289
 
289
- export function renderCellValue(value: any): string {
290
+ /**
291
+ * Citation context — opt-in input to `renderCellValue` (v5.7.0).
292
+ *
293
+ * When passed, `[N]`, `Citation [N]`, `[CITATION N]` and `[📄 CITATION N]`
294
+ * markers in the cell text are normalized then replaced with chip HTML.
295
+ * Chips carry `data-citation-page`, `data-citation-doc`, and
296
+ * `data-citation-verified` attributes (already in the DOMPurify whitelist
297
+ * since v5.6.0) so a host's `target.closest('[data-citation-page]')`
298
+ * delegated click handler routes the click to the source-doc panel.
299
+ *
300
+ * See `mcp-ui-solid/docs/briefs/BRIEF-citations-in-table-cells.md`.
301
+ */
302
+ export interface CitationCtx {
303
+ /**
304
+ * `Record<id, mapping>` keyed by the citation marker number (string-keyed
305
+ * because JSON serialization always produces strings; the runtime call
306
+ * sites accept either number or string ids and normalize internally).
307
+ */
308
+ map: Record<string | number, { page: number | string; file?: string; file_id?: number | string }>
309
+ /**
310
+ * Optional override returning sanitized chip HTML for one marker. Wins
311
+ * over the default `defaultCitationChip` shape. Function inputs are
312
+ * intentionally `any`-loose so consumers can swap shapes (e.g. web
313
+ * citations vs doc citations) without subtyping the entry shape here.
314
+ */
315
+ render?: (
316
+ id: number,
317
+ mapping: { page: number | string; file?: string; file_id?: number | string } | undefined
318
+ ) => string
319
+ }
320
+
321
+ /**
322
+ * Default chip HTML emitted by `transformCellCitations` when no
323
+ * `citationRender` override is supplied. Neutral Tailwind classes — hosts
324
+ * can override visual styling via the `.citation-ref` CSS class without
325
+ * passing a render override.
326
+ */
327
+ function defaultCitationChip(
328
+ pageNum: number | string,
329
+ fileName: string,
330
+ verified = true
331
+ ): string {
332
+ const safeDocName = encodeURIComponent(fileName || '')
333
+ const label = fileName ? `${fileName} - ${pageNum}` : `${pageNum}`
334
+ if (!verified) {
335
+ return `<span class="citation-ref inline-flex items-center gap-0.5 align-middle opacity-60"><span class="text-gray-500 line-through">[${label}]</span></span>`
336
+ }
337
+ return [
338
+ '<span class="citation-ref inline-flex items-center gap-0.5 align-middle">',
339
+ `<span class="text-gray-500">[${label}]</span>`,
340
+ '<button class="inline-flex items-center ml-0.5 px-1 py-0.5 text-xs bg-gray-800 hover:bg-gray-700 border border-gray-600 hover:border-teal-500 rounded cursor-pointer transition-colors align-middle"',
341
+ ` data-citation-page="${pageNum}"`,
342
+ ` data-citation-doc="${safeDocName}"`,
343
+ ' data-citation-verified="true"',
344
+ ` title="View source - ${label}">`,
345
+ '<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
346
+ '</button>',
347
+ '</span>',
348
+ ].join('')
349
+ }
350
+
351
+ /**
352
+ * Normalize bare `[N]`, `Citation [N]`, `[CITATION N]` markers to canonical
353
+ * `[📄 CITATION N]` then replace each canonical marker with chip HTML.
354
+ *
355
+ * Negative lookbehind `(?<![p.])` skips `[p.5]` (page form). Negative
356
+ * lookahead `(?!\()` skips `[text](url)` markdown links.
357
+ */
358
+ function transformCellCitations(text: string, ctx: CitationCtx): string {
359
+ // 1. normalize bare [N] / Citation [N] / [CITATION N] → [📄 CITATION N]
360
+ let out = text.replace(/(?<![p.])\[(\d{1,2})\](?!\()/g, '[📄 CITATION $1]')
361
+ out = out.replace(/\bCitations?\s*\[(\d+)\]/gi, '[📄 CITATION $1]')
362
+ out = out.replace(/\[CITATION\s+(\d+)\]/gi, '[📄 CITATION $1]')
363
+
364
+ // 2. replace each canonical marker with chip HTML
365
+ return out.replace(
366
+ /[【[]\s*📄\s*CITATION\s*(\d+)\s*[】\]]/gi,
367
+ (_m, idStr) => {
368
+ const id = parseInt(idStr, 10)
369
+ const mapping = ctx.map[id] ?? ctx.map[String(id)]
370
+ if (ctx.render) return ctx.render(id, mapping)
371
+ if (mapping) return defaultCitationChip(mapping.page, mapping.file ?? '', true)
372
+ // Unresolved id: when the map is non-empty (consumer claims to know
373
+ // the citations), drop silently — it's likely an LLM hallucination.
374
+ // When the map is empty (consumer didn't supply one), preserve a
375
+ // human-visible placeholder so the marker isn't lost.
376
+ return Object.keys(ctx.map).length > 0 ? '' : `[réf. ${id}]`
377
+ }
378
+ )
379
+ }
380
+
381
+ export function renderCellValue(value: any, citationCtx?: CitationCtx): string {
290
382
  // Handle null/undefined
291
383
  if (value === null || value === undefined) {
292
384
  return '-'
@@ -330,7 +422,9 @@ export function renderCellValue(value: any): string {
330
422
  return '-'
331
423
  }
332
424
 
333
- // Detect and convert markdown links: [text](url)
425
+ // Detect and convert markdown links: [text](url) — runs FIRST because
426
+ // the citation transform's negative lookahead `(?!\()` would also skip
427
+ // these, but handling them here keeps the existing return path.
334
428
  const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g
335
429
  if (markdownLinkRegex.test(strValue)) {
336
430
  // Replace all markdown links with HTML links
@@ -341,8 +435,36 @@ export function renderCellValue(value: any): string {
341
435
  return DOMPurify.sanitize(htmlValue, { ADD_ATTR: ['target', 'rel'] })
342
436
  }
343
437
 
438
+ // v5.7.0 — citation transform (opt-in). Replaces `[N]` style markers
439
+ // with chip HTML carrying `data-citation-*` attrs. Runs BEFORE the
440
+ // hasHtml / hasMarkdown branches so the resulting string flows through
441
+ // them naturally (chips are inline HTML; surviving markdown like
442
+ // **bold** is preserved by marked.parse since marked passes inline HTML
443
+ // through unchanged).
444
+ if (citationCtx) {
445
+ strValue = transformCellCitations(strValue, citationCtx)
446
+ }
447
+
448
+ // Markdown markers WITHOUT square brackets — `[` and `]` were excluded
449
+ // because chip labels (`[Doc - 5]`) and unresolved-marker fallbacks
450
+ // (`[réf. 12]`) would otherwise force a marked.parse for cells that
451
+ // have no actual markdown. The hasMarkdown check ALSO runs before
452
+ // hasHtml so that mixed cells (`**bold** [1]` → `**bold** <chip>`)
453
+ // get marked first; marked preserves the inline chip HTML, then
454
+ // DOMPurify keeps the citation attrs via the extended whitelist.
455
+ const hasMarkdown = /[*_`#]/.test(strValue)
456
+ if (hasMarkdown) {
457
+ const parsed = marked.parse(strValue, { async: false }) as string
458
+ return DOMPurify.sanitize(parsed, {
459
+ ALLOWED_TAGS: ['a', 'strong', 'em', 'b', 'i', 'code', 'span', 'br', 'button', 'svg', 'path', 'p', 'ul', 'ol', 'li', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
460
+ ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'data-citation-page', 'data-citation-source', 'data-citation-doc', 'data-citation-verified', 'title', 'fill', 'stroke', 'viewBox', 'stroke-linecap', 'stroke-linejoin', 'stroke-width', 'd'],
461
+ ADD_ATTR: ['target', 'rel'],
462
+ })
463
+ }
464
+
344
465
  // Detect raw HTML in cell values (e.g. <a href="..." data-citation-page="5">text</a>)
345
466
  // This handles cases where cell data comes from innerHTML extraction
467
+ // OR where the citation transform above injected chip HTML.
346
468
  const hasHtml = /<[a-z][\s\S]*>/i.test(strValue)
347
469
  if (hasHtml) {
348
470
  return DOMPurify.sanitize(strValue, {
@@ -352,14 +474,6 @@ export function renderCellValue(value: any): string {
352
474
  })
353
475
  }
354
476
 
355
- // Check if value contains markdown formatting (bold, italic, code, etc.)
356
- const hasMarkdown = /[*_`[\]#]/.test(strValue)
357
- if (hasMarkdown) {
358
- // Parse with marked and sanitize
359
- const parsed = marked.parse(strValue, { async: false }) as string
360
- return DOMPurify.sanitize(parsed, { ADD_ATTR: ['target', 'rel'] })
361
- }
362
-
363
477
  // Plain text — sanitize to prevent XSS via innerHTML
364
478
  return DOMPurify.sanitize(strValue)
365
479
  }
@@ -375,6 +489,14 @@ function TableRenderer(props: {
375
489
  const tableParams = props.component.params as any
376
490
  let scrollContainerRef: HTMLDivElement | undefined
377
491
 
492
+ // v5.7.0 — opt-in citation chip rendering inside cells. When `citationMap`
493
+ // is present in params, build a CitationCtx once and thread it through
494
+ // every `renderCellValue` call below. Absent → undefined → cells render
495
+ // as before (regression-safe).
496
+ const citationCtx: CitationCtx | undefined = tableParams.citationMap
497
+ ? { map: tableParams.citationMap, render: tableParams.citationRender }
498
+ : undefined
499
+
378
500
  // ─── Client-side sorting (v4.0.5) ────────────────────────
379
501
  const allRows = () => tableParams.rows || []
380
502
  const columns = () => tableParams.columns || []
@@ -637,7 +759,7 @@ function TableRenderer(props: {
637
759
  <For each={tableParams.columns}>
638
760
  {(column: any) => (
639
761
  <td class="px-6 py-4 text-sm text-gray-700 dark:text-gray-200 whitespace-normal break-words leading-relaxed first:pl-6 last:pr-6">
640
- <div innerHTML={highlightQuery(renderCellValue(row[column.key]), debouncedQuery())} />
762
+ <div innerHTML={highlightQuery(renderCellValue(row[column.key], citationCtx), debouncedQuery())} />
641
763
  </td>
642
764
  )}
643
765
  </For>
@@ -676,7 +798,7 @@ function TableRenderer(props: {
676
798
  <For each={tableParams.columns}>
677
799
  {(column: any) => (
678
800
  <td class="px-6 py-4 text-sm text-gray-700 dark:text-gray-200 whitespace-normal break-words leading-relaxed first:pl-6 last:pr-6">
679
- <div innerHTML={highlightQuery(renderCellValue(row[column.key]), debouncedQuery())} />
801
+ <div innerHTML={highlightQuery(renderCellValue(row[column.key], citationCtx), debouncedQuery())} />
680
802
  </td>
681
803
  )}
682
804
  </For>
@@ -1133,7 +1255,51 @@ function ComponentRenderer(props: {
1133
1255
  // Performance marks — visible in Chrome DevTools "Performance" panel under
1134
1256
  // user timings. Always-on, SSR-safe (see utils/perf.ts).
1135
1257
  markRenderStart(props.component.id)
1136
- onMount(() => markRenderEnd(props.component.id))
1258
+
1259
+ // Telemetry sink (B.5 — v5.6.0). null when no MCPUITelemetryProvider is
1260
+ // mounted above; null-checked at every dispatch site so apps that don't
1261
+ // opt in see zero behavior change.
1262
+ const telemetry = useTelemetry()
1263
+
1264
+ onMount(() => {
1265
+ markRenderEnd(props.component.id)
1266
+ if (telemetry) {
1267
+ const ts = Date.now()
1268
+ telemetry.dispatch({
1269
+ type: 'component:mounted',
1270
+ id: props.component.id,
1271
+ componentType: props.component.type,
1272
+ ts,
1273
+ })
1274
+ // Read the perf measure we just emitted to surface durationMs without
1275
+ // double-measuring. The measure may be missing if `performance` is
1276
+ // unavailable (SSR) — skip rendered event in that case.
1277
+ if (typeof performance !== 'undefined' && typeof performance.getEntriesByName === 'function') {
1278
+ const entries = performance.getEntriesByName(`${PERF_PREFIX}${props.component.id}:render`, 'measure')
1279
+ const last = entries[entries.length - 1]
1280
+ if (last) {
1281
+ telemetry.dispatch({
1282
+ type: 'component:rendered',
1283
+ id: props.component.id,
1284
+ componentType: props.component.type,
1285
+ durationMs: last.duration,
1286
+ ts,
1287
+ })
1288
+ }
1289
+ }
1290
+ }
1291
+ })
1292
+
1293
+ onCleanup(() => {
1294
+ if (telemetry) {
1295
+ telemetry.dispatch({
1296
+ type: 'component:unmounted',
1297
+ id: props.component.id,
1298
+ componentType: props.component.type,
1299
+ ts: Date.now(),
1300
+ })
1301
+ }
1302
+ })
1137
1303
 
1138
1304
  // Validate component before rendering
1139
1305
  const validation = validateComponent(props.component)
@@ -1145,6 +1311,19 @@ function ComponentRenderer(props: {
1145
1311
  details: validation.errors,
1146
1312
  })
1147
1313
 
1314
+ // Privacy: only counts + first error code, NO error messages or paths
1315
+ // (which could leak payload data — §M.6.2 hard rule).
1316
+ if (telemetry) {
1317
+ telemetry.dispatch({
1318
+ type: 'validation:failed',
1319
+ id: props.component.id,
1320
+ componentType: props.component.type,
1321
+ errorCount: validation.errors?.length ?? 0,
1322
+ firstErrorCode: validation.errors?.[0]?.code ?? null,
1323
+ ts: Date.now(),
1324
+ })
1325
+ }
1326
+
1148
1327
  const mode: ValidationErrorMode = props.errorMode ?? 'block'
1149
1328
  const firstError = validation.errors?.[0]?.message || 'Unknown validation error'
1150
1329
 
@@ -1264,9 +1443,27 @@ function ComponentRenderer(props: {
1264
1443
  function ActionRenderer(props: { component: UIComponent }) {
1265
1444
  const params = props.component.params as any
1266
1445
  const { execute, isExecuting } = useAction()
1446
+ const telemetry = useTelemetry()
1447
+
1448
+ // Telemetry: action:dispatched on click (B.5 — v5.6.0). Fires for every
1449
+ // click attempt (tool-call or link), regardless of execute success.
1450
+ // Privacy: actionName is `toolName` (tool-call) or the action kind
1451
+ // (link/submit) — NO `params.params` payload, NO URL.
1452
+ function dispatchTelemetry() {
1453
+ if (!telemetry) return
1454
+ const actionName: string = params.toolName ?? params.action ?? 'unknown'
1455
+ telemetry.dispatch({
1456
+ type: 'action:dispatched',
1457
+ id: props.component.id,
1458
+ componentType: 'action',
1459
+ actionName,
1460
+ ts: Date.now(),
1461
+ })
1462
+ }
1267
1463
 
1268
1464
  // Handle click to execute tool via Context (falls back to CustomEvent if no provider)
1269
1465
  const handleClick = async (e: MouseEvent) => {
1466
+ dispatchTelemetry()
1270
1467
  if (params.action === 'tool-call' && params.toolName) {
1271
1468
  e.preventDefault()
1272
1469
  await execute(params.toolName, params.params || {})
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Integration tests for MCPUITelemetryProvider + dispatch wiring (B.5 — v5.6.0)
3
+ *
4
+ * Verifies that the Provider correctly wraps a dispatcher and that dispatches
5
+ * from real renderer components arrive at the consumer sink — without the
6
+ * Provider, no events should fire (existing tests prove this implicitly,
7
+ * but we lock it explicitly here).
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
11
+ import { render, cleanup } from '@solidjs/testing-library'
12
+ import { UIResourceRenderer } from '../components/UIResourceRenderer'
13
+ import { MCPUITelemetryProvider } from './MCPUITelemetryContext'
14
+ import type { TelemetryEvent, TelemetrySink } from '../services/telemetry'
15
+ import type { UIComponent } from '../types'
16
+
17
+ const validMetric: UIComponent = {
18
+ id: 'metric-1',
19
+ type: 'metric',
20
+ position: { colStart: 1, colSpan: 6 },
21
+ params: { title: 'OK', value: 42 },
22
+ }
23
+
24
+ const invalidMetric: UIComponent = {
25
+ id: 'metric-bad',
26
+ type: 'metric',
27
+ position: { colStart: 1, colSpan: 6 },
28
+ params: {} as any, // missing title + value → validation:failed
29
+ }
30
+
31
+ describe('MCPUITelemetryProvider — integration with renderers (v5.6.0)', () => {
32
+ beforeEach(() => {
33
+ cleanup()
34
+ })
35
+
36
+ it('NO Provider = no events dispatched (renderer works as before)', () => {
37
+ // Render without Provider — should not throw, no events anywhere
38
+ expect(() => render(() => <UIResourceRenderer content={validMetric} />)).not.toThrow()
39
+ // No way to assert "no dispatch" without a sink; but the absence of
40
+ // throws + Provider's null-check semantics is what we lock here.
41
+ })
42
+
43
+ it('Provider receives component:mounted event for a valid component', async () => {
44
+ const events: TelemetryEvent[] = []
45
+ const sink: TelemetrySink = (batch) => {
46
+ events.push(...batch)
47
+ }
48
+
49
+ render(() => (
50
+ <MCPUITelemetryProvider sink={sink} options={{ bufferMs: 0 }}>
51
+ <UIResourceRenderer content={validMetric} />
52
+ </MCPUITelemetryProvider>
53
+ ))
54
+
55
+ const mounted = events.find((e) => e.type === 'component:mounted')
56
+ expect(mounted).toBeDefined()
57
+ expect(mounted?.id).toBe('metric-1')
58
+ expect(mounted?.componentType).toBe('metric')
59
+ expect(typeof mounted?.ts).toBe('number')
60
+ })
61
+
62
+ it('Provider receives validation:failed event with errorCount + firstErrorCode (NO error message)', async () => {
63
+ const events: TelemetryEvent[] = []
64
+ const sink: TelemetrySink = (batch) => {
65
+ events.push(...batch)
66
+ }
67
+
68
+ render(() => (
69
+ <MCPUITelemetryProvider sink={sink} options={{ bufferMs: 0 }}>
70
+ <UIResourceRenderer content={invalidMetric} errorMode="silent" />
71
+ </MCPUITelemetryProvider>
72
+ ))
73
+
74
+ const failed = events.find((e) => e.type === 'validation:failed')
75
+ expect(failed).toBeDefined()
76
+ expect(failed).toMatchObject({
77
+ type: 'validation:failed',
78
+ id: 'metric-bad',
79
+ componentType: 'metric',
80
+ firstErrorCode: 'INVALID_METRIC',
81
+ })
82
+ if (failed?.type === 'validation:failed') {
83
+ expect(failed.errorCount).toBeGreaterThan(0)
84
+ }
85
+
86
+ // Privacy hard rule: NO `errorMessage`, NO `errors` array, NO payload
87
+ // fields anywhere on the validation:failed event.
88
+ const failedKeys = failed ? Object.keys(failed) : []
89
+ expect(failedKeys.sort()).toEqual(
90
+ ['componentType', 'errorCount', 'firstErrorCode', 'id', 'ts', 'type'].sort()
91
+ )
92
+ })
93
+
94
+ it('FAIL-OPEN — sink that throws does NOT crash the renderer', () => {
95
+ const sink: TelemetrySink = () => {
96
+ throw new Error('sink down hard')
97
+ }
98
+
99
+ expect(() =>
100
+ render(() => (
101
+ <MCPUITelemetryProvider sink={sink} options={{ bufferMs: 0 }}>
102
+ <UIResourceRenderer content={validMetric} />
103
+ </MCPUITelemetryProvider>
104
+ ))
105
+ ).not.toThrow()
106
+ })
107
+
108
+ it('options.sampleRate=0 results in no events at the sink (everything dropped)', () => {
109
+ const sink = vi.fn<TelemetrySink>()
110
+
111
+ render(() => (
112
+ <MCPUITelemetryProvider sink={sink} options={{ sampleRate: 0, bufferMs: 0 }}>
113
+ <UIResourceRenderer content={validMetric} />
114
+ </MCPUITelemetryProvider>
115
+ ))
116
+
117
+ expect(sink).not.toHaveBeenCalled()
118
+ })
119
+ })