@seed-ship/mcp-ui-solid 6.13.0 → 6.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seed-ship/mcp-ui-solid",
3
- "version": "6.13.0",
3
+ "version": "6.15.0",
4
4
  "description": "SolidJS components for rendering MCP-generated UI resources",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -0,0 +1,66 @@
1
+ /**
2
+ * v6.14.0 — audit P1.7: the quickchart.io fallback must be an explicit host
3
+ * opt-in, never an implicit external network call.
4
+ *
5
+ * We drive the decision with `renderer: 'iframe'` (an explicit request for the
6
+ * external image path) — that branch is gated purely on `allowQuickchartFallback`
7
+ * and is independent of whether the `chart.js` peer happens to be installed in
8
+ * the test env. Default (no opt-in) → no quickchart URL, degrades to a local
9
+ * table; with opt-in → the quickchart.io image URL is produced.
10
+ */
11
+ import { describe, it, expect, afterEach } from 'vitest'
12
+ import { render, cleanup, waitFor } from '@solidjs/testing-library'
13
+ import { UIResourceRenderer } from './UIResourceRenderer'
14
+ import type { UIComponent } from '../types'
15
+
16
+ afterEach(cleanup)
17
+
18
+ const chart: UIComponent = {
19
+ id: 'c1',
20
+ type: 'chart',
21
+ position: { colStart: 1, colSpan: 12 },
22
+ params: {
23
+ type: 'bar',
24
+ title: 'Sensitive revenue',
25
+ renderer: 'iframe', // explicit external render request
26
+ data: {
27
+ labels: ['Q1', 'Q2'],
28
+ datasets: [{ label: 'Revenue', data: [100, 200] }],
29
+ },
30
+ } as any,
31
+ }
32
+
33
+ function quickchartImg(container: HTMLElement): HTMLImageElement | null {
34
+ return (
35
+ (Array.from(container.querySelectorAll('img')).find((img) =>
36
+ (img.getAttribute('src') ?? '').includes('quickchart.io')
37
+ ) as HTMLImageElement | undefined) ?? null
38
+ )
39
+ }
40
+
41
+ describe('Chart quickchart fallback is opt-in (P1.7)', () => {
42
+ it('does NOT call quickchart.io by default — degrades instead', async () => {
43
+ const { container } = render(() => <UIResourceRenderer content={chart} />)
44
+
45
+ await waitFor(() => {
46
+ expect(container.textContent ?? '').toContain('Interactive chart unavailable')
47
+ })
48
+
49
+ expect(quickchartImg(container)).toBeNull()
50
+ // The degraded table surfaces the underlying series data.
51
+ expect(container.textContent).toContain('Revenue')
52
+ })
53
+
54
+ it('uses quickchart.io only when the host opts in', async () => {
55
+ const { container } = render(() => (
56
+ <UIResourceRenderer content={chart} allowQuickchartFallback />
57
+ ))
58
+
59
+ await waitFor(() => {
60
+ expect(quickchartImg(container)).not.toBeNull()
61
+ })
62
+
63
+ const src = quickchartImg(container)?.getAttribute('src') ?? ''
64
+ expect(src).toContain('https://quickchart.io/chart?c=')
65
+ })
66
+ })
@@ -43,6 +43,8 @@ import { FormRenderer } from './FormRenderer'
43
43
  import { ModalRenderer } from './ModalRenderer'
44
44
  import { ActionGroupRenderer } from './ActionGroupRenderer'
45
45
  import { ChartJSRenderer, isChartJSAvailable } from './ChartJSRenderer'
46
+ import { DegradedFallback } from './DegradedFallback'
47
+ import { chartToDegradedTable } from '../utils/degraded-projections'
46
48
  import { ImageGalleryRenderer } from './ImageGalleryRenderer'
47
49
  import { VideoRenderer } from './VideoRenderer'
48
50
  import { CodeBlockRenderer } from './CodeBlockRenderer'
@@ -144,6 +146,26 @@ export interface UIResourceRendererProps {
144
146
  */
145
147
  toolbarVariant?: 'hover' | 'always-visible'
146
148
 
149
+ /**
150
+ * Allow the chart renderer to fall back to the **quickchart.io** image API
151
+ * when the native `chart.js` peer is unavailable (v6.14.0, audit P1.7).
152
+ *
153
+ * The quickchart fallback sends the **entire chart config** (labels + data)
154
+ * to an external service inside an image URL — an implicit network call that
155
+ * can leak potentially sensitive data and behaves differently offline. For a
156
+ * public, LLM/connector-driven package the safe default is **off**: when
157
+ * Chart.js is missing (or `renderer: 'iframe'` is requested) the chart
158
+ * **degrades to a local data table** and emits a `render:error` telemetry
159
+ * signal instead of silently calling out.
160
+ *
161
+ * This is a **host-level** trust decision, deliberately NOT a payload field
162
+ * (a payload could otherwise opt itself in). Set it to `true` only when the
163
+ * data is non-sensitive and an external image render is acceptable.
164
+ *
165
+ * @default false
166
+ */
167
+ allowQuickchartFallback?: boolean
168
+
147
169
  /**
148
170
  * Per-instance hook fired when this renderer mounts a content key that
149
171
  * is already mounted elsewhere in the document (v6.5.0 — closes Demande 2
@@ -177,14 +199,36 @@ function ChartRenderer(props: {
177
199
  component: UIComponent
178
200
  onError?: (error: RendererError) => void
179
201
  toolbarVariant?: 'hover' | 'always-visible'
202
+ /** Host opt-in for the external quickchart.io fallback (audit P1.7). */
203
+ allowQuickchartFallback?: boolean
180
204
  }) {
181
205
  const [useNative, setUseNative] = createSignal(false)
182
206
  const [iframeUrl, setIframeUrl] = createSignal<string>()
183
207
  const [isLoading, setIsLoading] = createSignal(true)
184
208
  const [error, setError] = createSignal<string>()
209
+ // Set when Chart.js is unavailable AND the host has not opted into the
210
+ // external quickchart fallback — we then degrade to a local data table
211
+ // rather than calling out to quickchart.io (audit P1.7).
212
+ const [degraded, setDegraded] = createSignal(false)
213
+ const telemetry = useTelemetry()
185
214
 
186
215
  const params = () => props.component.params as any
187
216
  const rendererPref = () => params()?.renderer || 'auto'
217
+ const allowQuickchart = () => props.allowQuickchartFallback === true
218
+
219
+ // Emit a clear, observable signal whenever we decline the external fallback.
220
+ const signalBlockedFallback = (reason: string) => {
221
+ setDegraded(true)
222
+ setIsLoading(false)
223
+ const message = `Chart degraded to a data table: ${reason}`
224
+ telemetry?.dispatch({
225
+ type: 'render:error',
226
+ errorMessage: message,
227
+ id: props.component.id ?? '',
228
+ componentType: 'chart',
229
+ ts: Date.now(),
230
+ })
231
+ }
188
232
 
189
233
  // Guard: if data or datasets missing, show error instead of crashing Chart.js
190
234
  if (!params()?.data?.datasets) {
@@ -200,9 +244,17 @@ function ChartRenderer(props: {
200
244
  const pref = rendererPref()
201
245
 
202
246
  if (pref === 'iframe') {
203
- // Force iframe mode
247
+ // Explicit external render requested. Honor it only when the host has
248
+ // opted in (audit P1.7) — otherwise degrade to a local table.
204
249
  setUseNative(false)
205
- buildIframeUrl()
250
+ if (allowQuickchart()) {
251
+ buildIframeUrl()
252
+ } else {
253
+ signalBlockedFallback(
254
+ "renderer: 'iframe' requires the external quickchart.io service; " +
255
+ 'set allowQuickchartFallback on the host to enable it.'
256
+ )
257
+ }
206
258
  } else if (pref === 'native') {
207
259
  // Force native mode - will show error if Chart.js not available
208
260
  const available = await isChartJSAvailable()
@@ -214,14 +266,19 @@ function ChartRenderer(props: {
214
266
  setIsLoading(false)
215
267
  }
216
268
  } else {
217
- // Auto mode - use native if available, otherwise iframe
269
+ // Auto mode - use native if available. When Chart.js is missing, only
270
+ // call out to quickchart.io if the host opted in; otherwise degrade to a
271
+ // local data table instead of an implicit external network call (P1.7).
218
272
  const available = await isChartJSAvailable()
219
273
  if (available) {
220
274
  setUseNative(true)
221
275
  setIsLoading(false)
222
- } else {
276
+ } else if (allowQuickchart()) {
223
277
  setUseNative(false)
224
278
  buildIframeUrl()
279
+ } else {
280
+ setUseNative(false)
281
+ signalBlockedFallback('Chart.js peer is not installed.')
225
282
  }
226
283
  }
227
284
  })
@@ -256,6 +313,19 @@ function ChartRenderer(props: {
256
313
  when={useNative()}
257
314
  fallback={
258
315
  <div class="relative w-full h-full min-h-[300px] bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
316
+ {/* P1.7 — Chart.js missing and quickchart fallback not allowed:
317
+ show the chart data as a local table instead of an implicit
318
+ call to quickchart.io. */}
319
+ <Show when={degraded()}>
320
+ <div class="p-3">
321
+ <DegradedFallback
322
+ message="Interactive chart unavailable — install the chart.js peer dependency, or set allowQuickchartFallback to use the external quickchart.io renderer."
323
+ caption="Showing the chart data as a table."
324
+ {...chartToDegradedTable(params() ?? {})}
325
+ />
326
+ </div>
327
+ </Show>
328
+
259
329
  <Show when={isLoading()}>
260
330
  <div class="absolute inset-0 flex items-center justify-center">
261
331
  <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
@@ -1330,6 +1400,8 @@ function ComponentRenderer(props: {
1330
1400
  onError?: (error: RendererError) => void
1331
1401
  errorMode?: ValidationErrorMode
1332
1402
  toolbarVariant?: 'hover' | 'always-visible'
1403
+ /** Host opt-in for the external quickchart.io chart fallback (audit P1.7). */
1404
+ allowQuickchartFallback?: boolean
1333
1405
  }) {
1334
1406
  // Performance marks — visible in Chrome DevTools "Performance" panel under
1335
1407
  // user timings. Always-on, SSR-safe (see utils/perf.ts).
@@ -1406,6 +1478,14 @@ function ComponentRenderer(props: {
1406
1478
  const mode: ValidationErrorMode = props.errorMode ?? 'block'
1407
1479
  const firstError = validation.errors?.[0]?.message || 'Unknown validation error'
1408
1480
 
1481
+ // P1.6 — an UNKNOWN component type must never produce a silent blank,
1482
+ // whatever the errorMode. The renderer has no branch for it, so even
1483
+ // `silent` would otherwise render nothing. Always surface a visible
1484
+ // "Unsupported component type" notice + a render:error telemetry signal.
1485
+ if (validation.errors?.some((e) => e.code === 'UNKNOWN_COMPONENT_TYPE')) {
1486
+ return <UnsupportedComponentFallback component={props.component} />
1487
+ }
1488
+
1409
1489
  if (mode === 'silent') {
1410
1490
  return null
1411
1491
  }
@@ -1458,7 +1538,7 @@ function ComponentRenderer(props: {
1458
1538
  allowRetry={true}
1459
1539
  >
1460
1540
  <Show when={props.component.type === 'chart'}>
1461
- <ChartRenderer component={props.component} onError={props.onError} toolbarVariant={props.toolbarVariant} />
1541
+ <ChartRenderer component={props.component} onError={props.onError} toolbarVariant={props.toolbarVariant} allowQuickchartFallback={props.allowQuickchartFallback} />
1462
1542
  </Show>
1463
1543
  <Show when={props.component.type === 'table'}>
1464
1544
  <TableRenderer component={props.component} onError={props.onError} toolbarVariant={props.toolbarVariant} />
@@ -1514,10 +1594,37 @@ function ComponentRenderer(props: {
1514
1594
  <Show when={props.component.type === 'graph'}>
1515
1595
  <GraphRenderer component={props.component} toolbarVariant={props.toolbarVariant} />
1516
1596
  </Show>
1597
+ {/* P1.6 — `footer` is a valid type with a real renderer but had no
1598
+ dispatch branch, so a standalone footer component rendered a silent
1599
+ blank (it was only auto-injected at the layout level). */}
1600
+ <Show when={props.component.type === 'footer'}>
1601
+ <FooterRenderer params={props.component.params as any} />
1602
+ </Show>
1517
1603
  </GenerativeUIErrorBoundary>
1518
1604
  )
1519
1605
  }
1520
1606
 
1607
+ /**
1608
+ * Visible fallback for a component whose `type` is not recognized (audit P1.6).
1609
+ *
1610
+ * An unknown type has no renderer branch, so without this it would render a
1611
+ * **silent blank** — even under `errorMode: 'silent'`. The validation gate
1612
+ * routes unknown types here regardless of mode, so the user always sees an
1613
+ * "Unsupported component type: X" notice. The telemetry signal is emitted by
1614
+ * the gate itself (`validation:failed` with `firstErrorCode:
1615
+ * 'UNKNOWN_COMPONENT_TYPE'`), so this component stays purely presentational.
1616
+ */
1617
+ function UnsupportedComponentFallback(props: { component: UIComponent }) {
1618
+ return (
1619
+ <div
1620
+ role="alert"
1621
+ class="w-full rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/20 dark:text-amber-200"
1622
+ >
1623
+ Unsupported component type: <code class="font-mono">{props.component.type}</code>
1624
+ </div>
1625
+ )
1626
+ }
1627
+
1521
1628
  /**
1522
1629
  * Render an action component (button or link)
1523
1630
  * Refactored in Phase 5.0 to use useAction hook for Context-based execution
@@ -1834,7 +1941,7 @@ export const UIResourceRenderer: Component<UIResourceRendererProps> = (props) =>
1834
1941
 
1835
1942
  // Wrapper function for RenderContext (breaks circular dependency)
1836
1943
  const renderComponent = (component: UIComponent, onError?: (error: RendererError) => void) => (
1837
- <ComponentRenderer component={component} onError={onError} errorMode={props.errorMode} toolbarVariant={props.toolbarVariant} />
1944
+ <ComponentRenderer component={component} onError={onError} errorMode={props.errorMode} toolbarVariant={props.toolbarVariant} allowQuickchartFallback={props.allowQuickchartFallback} />
1838
1945
  )
1839
1946
 
1840
1947
  return (
@@ -1852,7 +1959,7 @@ export const UIResourceRenderer: Component<UIResourceRendererProps> = (props) =>
1852
1959
  style={getGridStyleString(component)}
1853
1960
  data-mcp-ui-component-id={getUiResourceStableKey(component)}
1854
1961
  >
1855
- <ComponentRenderer component={component} onError={props.onError} errorMode={props.errorMode} toolbarVariant={props.toolbarVariant} />
1962
+ <ComponentRenderer component={component} onError={props.onError} errorMode={props.errorMode} toolbarVariant={props.toolbarVariant} allowQuickchartFallback={props.allowQuickchartFallback} />
1856
1963
  </div>
1857
1964
  )}
1858
1965
  </For>
@@ -0,0 +1,77 @@
1
+ /**
2
+ * v6.15.0 — audit P1.6: a component that reaches the renderer with a type that
3
+ * has no render branch must never produce a silent blank.
4
+ *
5
+ * `errorMode: 'silent'` lets a payload past the validation gate without an
6
+ * error card, so a bogus / known-but-unrendered type would otherwise render
7
+ * nothing. We assert the visible "Unsupported component type" notice instead,
8
+ * and that a real type (`footer`, previously missing a branch) now renders.
9
+ */
10
+ import { describe, it, expect, afterEach } from 'vitest'
11
+ import { render, cleanup, waitFor } from '@solidjs/testing-library'
12
+ import { UIResourceRenderer } from './UIResourceRenderer'
13
+ import { MCPUITelemetryProvider } from '../context/MCPUITelemetryContext'
14
+ import type { UIComponent } from '../types'
15
+
16
+ afterEach(cleanup)
17
+
18
+ const bogus = {
19
+ id: 'x1',
20
+ type: 'totally-made-up' as any,
21
+ position: { colStart: 1, colSpan: 12 },
22
+ params: {},
23
+ } satisfies UIComponent
24
+
25
+ describe('Unsupported component never renders blank (P1.6)', () => {
26
+ it('shows a visible "Unsupported component type" notice', async () => {
27
+ // errorMode: 'silent' skips the validation error card, so the bogus type
28
+ // reaches the render dispatch — the catch-all must still surface it.
29
+ const { container } = render(() => (
30
+ <UIResourceRenderer content={bogus} errorMode="silent" />
31
+ ))
32
+
33
+ await waitFor(() => {
34
+ expect(container.textContent ?? '').toContain('Unsupported component type')
35
+ })
36
+ expect(container.textContent).toContain('totally-made-up')
37
+ })
38
+
39
+ it('emits a validation:failed telemetry signal for the unsupported type', async () => {
40
+ const events: Array<{ type: string; errorMessage?: string }> = []
41
+ render(() => (
42
+ <MCPUITelemetryProvider
43
+ sink={(batch) => events.push(...batch)}
44
+ options={{ bufferMs: 0 }}
45
+ >
46
+ <UIResourceRenderer content={bogus} errorMode="silent" />
47
+ </MCPUITelemetryProvider>
48
+ ))
49
+
50
+ // The validation gate emits `validation:failed` with the precise
51
+ // `UNKNOWN_COMPONENT_TYPE` code — the privacy-safe drift signal hosts watch.
52
+ await waitFor(() => {
53
+ expect(
54
+ events.some(
55
+ (e: any) =>
56
+ e.type === 'validation:failed' && e.firstErrorCode === 'UNKNOWN_COMPONENT_TYPE'
57
+ )
58
+ ).toBe(true)
59
+ })
60
+ })
61
+
62
+ it('renders a standalone footer component (previously a silent blank)', async () => {
63
+ const footer: UIComponent = {
64
+ id: 'f1',
65
+ type: 'footer',
66
+ position: { colStart: 1, colSpan: 12 },
67
+ params: { poweredBy: 'Deposium', sourceCount: 3 } as any,
68
+ }
69
+ const { container } = render(() => <UIResourceRenderer content={footer} />)
70
+
71
+ await waitFor(() => {
72
+ expect(container.textContent ?? '').toContain('Deposium')
73
+ })
74
+ // Not the unsupported notice — footer is a real renderer now.
75
+ expect(container.textContent).not.toContain('Unsupported component type')
76
+ })
77
+ })