@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/CHANGELOG.md +46 -0
- package/dist/components/UIResourceRenderer.cjs +369 -298
- package/dist/components/UIResourceRenderer.cjs.map +1 -1
- package/dist/components/UIResourceRenderer.d.ts +19 -0
- package/dist/components/UIResourceRenderer.d.ts.map +1 -1
- package/dist/components/UIResourceRenderer.js +369 -298
- package/dist/components/UIResourceRenderer.js.map +1 -1
- package/package.json +1 -1
- package/src/components/ChartRenderer.quickchart.test.tsx +66 -0
- package/src/components/UIResourceRenderer.tsx +114 -7
- package/src/components/UnsupportedComponent.test.tsx +77 -0
- package/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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,
|
|
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
|
+
})
|