@seed-ship/mcp-ui-solid 5.4.0 → 5.5.1
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 +81 -0
- package/dist/mcp-ui-spec/dist/schemas.cjs +493 -0
- package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -0
- package/dist/mcp-ui-spec/dist/schemas.js +493 -0
- package/dist/mcp-ui-spec/dist/schemas.js.map +1 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/ZodError.cjs +118 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/ZodError.cjs.map +1 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/ZodError.js +118 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/ZodError.js.map +1 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/errors.cjs +10 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/errors.cjs.map +1 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/errors.js +10 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/errors.js.map +1 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/errorUtil.cjs +8 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/errorUtil.cjs.map +1 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/errorUtil.js +9 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/errorUtil.js.map +1 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/parseUtil.cjs +122 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/parseUtil.cjs.map +1 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/parseUtil.js +122 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/parseUtil.js.map +1 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/util.cjs +137 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/util.cjs.map +1 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/util.js +139 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/util.js.map +1 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/locales/en.cjs +105 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/locales/en.cjs.map +1 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/locales/en.js +106 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/locales/en.js.map +1 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/types.cjs +3229 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/types.cjs.map +1 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/types.js +3230 -0
- package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/types.js.map +1 -0
- package/dist/services/validation.cjs +73 -154
- package/dist/services/validation.cjs.map +1 -1
- package/dist/services/validation.d.ts.map +1 -1
- package/dist/services/validation.js +73 -154
- package/dist/services/validation.js.map +1 -1
- package/package.json +3 -2
- package/src/services/validation.spec-migration.test.ts +207 -0
- package/src/services/validation.test.ts +53 -3
- package/src/services/validation.ts +143 -181
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests focused on the v5.5.0 spec-driven validation refactor (B.1 PR2).
|
|
3
|
+
*
|
|
4
|
+
* Complement to `validation.test.ts` (which is preserved untouched and
|
|
5
|
+
* verifies legacy behavior + codes are unchanged) — this file covers what's
|
|
6
|
+
* NEW in v5.5.0:
|
|
7
|
+
* 1. ZodIssue → ValidationError mapper preserves legacy `code` per type
|
|
8
|
+
* 2. Path translation: ZodIssue path → `params.<joined>`
|
|
9
|
+
* 3. Artifact validation drift FIX (url + filename + mimeType, not `content`)
|
|
10
|
+
* 4. Iframe + video: spec parse first, then chained domain whitelist
|
|
11
|
+
* 5. Imperative passthrough types (chart, table, form, map, modal) still
|
|
12
|
+
* use the legacy validators with their rich codes
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect } from 'vitest'
|
|
16
|
+
import { validateComponent } from './validation'
|
|
17
|
+
import type { UIComponent, ComponentType } from '../types'
|
|
18
|
+
|
|
19
|
+
function makeComponent(type: ComponentType, params: Record<string, unknown> = {}): UIComponent {
|
|
20
|
+
return {
|
|
21
|
+
id: `test-${type}`,
|
|
22
|
+
type,
|
|
23
|
+
position: { colStart: 1, colSpan: 12 },
|
|
24
|
+
params: params as any,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('v5.5.0 — ZodIssue → ValidationError mapper', () => {
|
|
29
|
+
it('emits the legacy code per ComponentType when shape parsing fails', () => {
|
|
30
|
+
const cases: Array<{ type: ComponentType; legacyCode: string }> = [
|
|
31
|
+
{ type: 'metric', legacyCode: 'INVALID_METRIC' },
|
|
32
|
+
{ type: 'text', legacyCode: 'INVALID_TEXT' },
|
|
33
|
+
{ type: 'iframe', legacyCode: 'INVALID_IFRAME' },
|
|
34
|
+
{ type: 'image', legacyCode: 'INVALID_IMAGE' },
|
|
35
|
+
{ type: 'link', legacyCode: 'INVALID_LINK' },
|
|
36
|
+
{ type: 'action', legacyCode: 'INVALID_ACTION' },
|
|
37
|
+
{ type: 'video', legacyCode: 'INVALID_VIDEO' },
|
|
38
|
+
{ type: 'carousel', legacyCode: 'EMPTY_CAROUSEL' },
|
|
39
|
+
{ type: 'image-gallery', legacyCode: 'EMPTY_GALLERY' },
|
|
40
|
+
{ type: 'action-group', legacyCode: 'EMPTY_ACTION_GROUP' },
|
|
41
|
+
{ type: 'code', legacyCode: 'INVALID_CODE' },
|
|
42
|
+
{ type: 'artifact', legacyCode: 'INVALID_ARTIFACT' },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
for (const { type, legacyCode } of cases) {
|
|
46
|
+
const result = validateComponent(makeComponent(type, {}))
|
|
47
|
+
expect(result.valid, `${type} should fail validation with empty params`).toBe(false)
|
|
48
|
+
const codes = result.errors?.map((e) => e.code) ?? []
|
|
49
|
+
expect(codes, `${type} errors should include ${legacyCode}`).toContain(legacyCode)
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('maps ZodIssue.path to `params.<joined>` shape', () => {
|
|
54
|
+
// metric is missing both `title` and `value` → 2 errors with paths
|
|
55
|
+
// `params.title` and `params.value`
|
|
56
|
+
const result = validateComponent(makeComponent('metric', {}))
|
|
57
|
+
expect(result.valid).toBe(false)
|
|
58
|
+
|
|
59
|
+
const paths = (result.errors ?? []).map((e) => e.path)
|
|
60
|
+
expect(paths).toContain('params.title')
|
|
61
|
+
expect(paths).toContain('params.value')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('emits a top-level `params` path when the failure is at the root (e.g. params is wrong type)', () => {
|
|
65
|
+
// value=null fails the union(string, number); path is `params.value`
|
|
66
|
+
const result = validateComponent(makeComponent('metric', { title: 'X', value: null }))
|
|
67
|
+
expect(result.valid).toBe(false)
|
|
68
|
+
const paths = (result.errors ?? []).map((e) => e.path)
|
|
69
|
+
expect(paths.some((p) => p.startsWith('params.'))).toBe(true)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('v5.5.0 — Artifact validation drift fix', () => {
|
|
74
|
+
// Pre-v5.5.0 BUG: validation.ts checked `params.content` but ArtifactRenderer
|
|
75
|
+
// expects `url + filename + mimeType`. Any valid artifact (per renderer)
|
|
76
|
+
// would fail validation; any "valid" artifact (per old check) couldn't
|
|
77
|
+
// render. Fixed as a side-effect of the spec migration.
|
|
78
|
+
|
|
79
|
+
it('accepts a valid artifact with url + filename + mimeType', () => {
|
|
80
|
+
const result = validateComponent(
|
|
81
|
+
makeComponent('artifact', {
|
|
82
|
+
url: 'https://artifacts.example.com/file.csv',
|
|
83
|
+
filename: 'export.csv',
|
|
84
|
+
mimeType: 'text/csv',
|
|
85
|
+
})
|
|
86
|
+
)
|
|
87
|
+
expect(result.valid).toBe(true)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('rejects an artifact with only `content` (the pre-v5.5.0 expected shape — was a bug)', () => {
|
|
91
|
+
const result = validateComponent(makeComponent('artifact', { content: 'data' }))
|
|
92
|
+
expect(result.valid).toBe(false)
|
|
93
|
+
expect(result.errors?.some((e) => e.code === 'INVALID_ARTIFACT')).toBe(true)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('rejects an artifact missing filename', () => {
|
|
97
|
+
const result = validateComponent(
|
|
98
|
+
makeComponent('artifact', { url: 'https://x', mimeType: 'application/pdf' })
|
|
99
|
+
)
|
|
100
|
+
expect(result.valid).toBe(false)
|
|
101
|
+
const filenameError = result.errors?.find((e) => e.path === 'params.filename')
|
|
102
|
+
expect(filenameError).toBeDefined()
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('v5.5.0 — Iframe + video chained domain whitelist', () => {
|
|
107
|
+
it('iframe with whitelisted domain (quickchart.io) passes validation', () => {
|
|
108
|
+
const result = validateComponent(
|
|
109
|
+
makeComponent('iframe', { url: 'https://quickchart.io/chart?c={}' })
|
|
110
|
+
)
|
|
111
|
+
expect(result.valid).toBe(true)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('iframe domain check IS chained after spec parse (URL with malformed bracket throws → INVALID_URL)', () => {
|
|
115
|
+
// Spec parse accepts any non-empty string; we feed it a URL the WHATWG
|
|
116
|
+
// parser refuses (unbalanced bracket), which proves the chained
|
|
117
|
+
// validateIframeDomain ran and surfaced its INVALID_URL code.
|
|
118
|
+
const result = validateComponent(
|
|
119
|
+
makeComponent('iframe', { url: 'http://[bad' })
|
|
120
|
+
)
|
|
121
|
+
expect(result.valid).toBe(false)
|
|
122
|
+
const codes = result.errors?.map((e) => e.code) ?? []
|
|
123
|
+
expect(codes).toContain('INVALID_URL')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('iframe with missing url emits INVALID_IFRAME and SKIPS the domain check (no cascading error)', () => {
|
|
127
|
+
const result = validateComponent(makeComponent('iframe', {}))
|
|
128
|
+
expect(result.valid).toBe(false)
|
|
129
|
+
const codes = result.errors?.map((e) => e.code) ?? []
|
|
130
|
+
expect(codes).toContain('INVALID_IFRAME')
|
|
131
|
+
// No domain whitelist error since url was absent — avoid noise
|
|
132
|
+
expect(codes.every((c) => c === 'INVALID_IFRAME')).toBe(true)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('video with whitelisted domain (youtube.com) passes domain check (only spec strict-url applies)', () => {
|
|
136
|
+
// VideoComponentParamsSchema uses z.string().url() — strict
|
|
137
|
+
const result = validateComponent(
|
|
138
|
+
makeComponent('video', { url: 'https://www.youtube.com/embed/abc' })
|
|
139
|
+
)
|
|
140
|
+
expect(result.valid).toBe(true)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('v5.5.0 — Imperative passthrough types preserve their rich codes', () => {
|
|
145
|
+
it('chart still emits MISSING_DATA / MISSING_DATASETS / MISSING_LABELS via validateChartComponent', () => {
|
|
146
|
+
const result = validateComponent(makeComponent('chart', {}))
|
|
147
|
+
expect(result.valid).toBe(false)
|
|
148
|
+
expect(result.errors?.[0].code).toBe('MISSING_DATA')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('table still emits EMPTY_COLUMNS via validateTableComponent', () => {
|
|
152
|
+
const result = validateComponent(makeComponent('table', { columns: [], rows: [] }))
|
|
153
|
+
expect(result.valid).toBe(false)
|
|
154
|
+
expect(result.errors?.some((e) => e.code === 'EMPTY_COLUMNS')).toBe(true)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('form still emits EMPTY_FORM via the imperative path (spec form schema is too strict for LLM payloads)', () => {
|
|
158
|
+
const result = validateComponent(makeComponent('form', { fields: [] }))
|
|
159
|
+
expect(result.valid).toBe(false)
|
|
160
|
+
expect(result.errors?.some((e) => e.code === 'EMPTY_FORM')).toBe(true)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('map with object center {lat, lng} still passes (spec tuple shape would have rejected — kept imperative for compat)', () => {
|
|
164
|
+
const result = validateComponent(
|
|
165
|
+
makeComponent('map', { center: { lat: 48.8566, lng: 2.3522 }, zoom: 13 })
|
|
166
|
+
)
|
|
167
|
+
// Pre-v5.5.0 behavior preserved: object-shaped center is accepted.
|
|
168
|
+
expect(result.valid).toBe(true)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('modal with arbitrary minimal params still passes (no validation)', () => {
|
|
172
|
+
const result = validateComponent(makeComponent('modal', { title: 'OK' }))
|
|
173
|
+
expect(result.valid).toBe(true)
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('v5.5.0 — Invariants preserved (regression guard)', () => {
|
|
178
|
+
it('truly unknown types still rejected with UNKNOWN_COMPONENT_TYPE', () => {
|
|
179
|
+
const result = validateComponent(makeComponent('zzz-not-a-type' as ComponentType))
|
|
180
|
+
expect(result.valid).toBe(false)
|
|
181
|
+
expect(result.errors?.some((e) => e.code === 'UNKNOWN_COMPONENT_TYPE')).toBe(true)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('missing component.params still emits MISSING_PARAMS (early return, before spec dispatch)', () => {
|
|
185
|
+
const component = {
|
|
186
|
+
id: 'x',
|
|
187
|
+
type: 'metric' as ComponentType,
|
|
188
|
+
position: { colStart: 1, colSpan: 12 },
|
|
189
|
+
params: undefined as any,
|
|
190
|
+
}
|
|
191
|
+
const result = validateComponent(component)
|
|
192
|
+
expect(result.valid).toBe(false)
|
|
193
|
+
expect(result.errors?.some((e) => e.code === 'MISSING_PARAMS')).toBe(true)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('grid position errors still emit (validateGridPosition runs before spec dispatch)', () => {
|
|
197
|
+
const broken: UIComponent = {
|
|
198
|
+
id: 'x',
|
|
199
|
+
type: 'metric',
|
|
200
|
+
position: { colStart: 99, colSpan: 1 },
|
|
201
|
+
params: { title: 'X', value: 1 },
|
|
202
|
+
}
|
|
203
|
+
const result = validateComponent(broken)
|
|
204
|
+
expect(result.valid).toBe(false)
|
|
205
|
+
expect(result.errors?.some((e) => e.code === 'INVALID_GRID_COL_START')).toBe(true)
|
|
206
|
+
})
|
|
207
|
+
})
|
|
@@ -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
|
-
'
|
|
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
|
-
|
|
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
|
+
})
|
|
@@ -8,6 +8,21 @@
|
|
|
8
8
|
* - Security constraints (domain whitelist, XSS prevention)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import type { ZodIssue, ZodSchema } from 'zod'
|
|
12
|
+
import {
|
|
13
|
+
MetricComponentParamsSchema,
|
|
14
|
+
TextComponentParamsSchema,
|
|
15
|
+
IframeComponentParamsSchema,
|
|
16
|
+
ImageComponentParamsSchema,
|
|
17
|
+
LinkComponentParamsSchema,
|
|
18
|
+
CarouselComponentParamsSchema,
|
|
19
|
+
ArtifactComponentParamsSchema,
|
|
20
|
+
ActionParamsSchema,
|
|
21
|
+
VideoComponentParamsSchema,
|
|
22
|
+
ImageGalleryParamsSchema,
|
|
23
|
+
ActionGroupParamsSchema,
|
|
24
|
+
CodeComponentParamsSchema,
|
|
25
|
+
} from '@seed-ship/mcp-ui-spec'
|
|
11
26
|
import type {
|
|
12
27
|
UIComponent,
|
|
13
28
|
UILayout,
|
|
@@ -31,6 +46,60 @@ const KNOWN_COMPONENT_TYPES: Set<string> = new Set<ComponentType>([
|
|
|
31
46
|
'action-group', 'image-gallery', 'video', 'code', 'map',
|
|
32
47
|
])
|
|
33
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Spec-driven validation dispatch table (B.1 — v5.5.0).
|
|
51
|
+
*
|
|
52
|
+
* For each ComponentType where we delegate shape validation to a Zod schema
|
|
53
|
+
* from `@seed-ship/mcp-ui-spec`, this table maps:
|
|
54
|
+
* - the schema to safeParse against
|
|
55
|
+
* - the legacy error code to emit when shape parsing fails (preserves the
|
|
56
|
+
* pre-v5.5.0 `errors[].code` API contract — see MCP-UI-AUDIT-2026-04-26.md
|
|
57
|
+
* §I.3.a + §J.1)
|
|
58
|
+
*
|
|
59
|
+
* Types deliberately omitted (kept on the imperative path):
|
|
60
|
+
* - `chart`, `table` — have rich imperative validators with their own
|
|
61
|
+
* 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
|
+
* - `modal` — all params are optional; nothing to enforce.
|
|
67
|
+
* - `grid`, `footer`, `composite` — pass-through, validated elsewhere.
|
|
68
|
+
*/
|
|
69
|
+
const SPEC_VALIDATORS: Partial<Record<ComponentType, { schema: ZodSchema; legacyCode: string }>> = {
|
|
70
|
+
metric: { schema: MetricComponentParamsSchema, legacyCode: 'INVALID_METRIC' },
|
|
71
|
+
text: { schema: TextComponentParamsSchema, legacyCode: 'INVALID_TEXT' },
|
|
72
|
+
iframe: { schema: IframeComponentParamsSchema, legacyCode: 'INVALID_IFRAME' },
|
|
73
|
+
image: { schema: ImageComponentParamsSchema, legacyCode: 'INVALID_IMAGE' },
|
|
74
|
+
link: { schema: LinkComponentParamsSchema, legacyCode: 'INVALID_LINK' },
|
|
75
|
+
action: { schema: ActionParamsSchema, legacyCode: 'INVALID_ACTION' },
|
|
76
|
+
video: { schema: VideoComponentParamsSchema, legacyCode: 'INVALID_VIDEO' },
|
|
77
|
+
carousel: { schema: CarouselComponentParamsSchema, legacyCode: 'EMPTY_CAROUSEL' },
|
|
78
|
+
'image-gallery': { schema: ImageGalleryParamsSchema, legacyCode: 'EMPTY_GALLERY' },
|
|
79
|
+
'action-group': { schema: ActionGroupParamsSchema, legacyCode: 'EMPTY_ACTION_GROUP' },
|
|
80
|
+
code: { schema: CodeComponentParamsSchema, legacyCode: 'INVALID_CODE' },
|
|
81
|
+
artifact: { schema: ArtifactComponentParamsSchema, legacyCode: 'INVALID_ARTIFACT' },
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Map a Zod issue list to the legacy `ValidationError[]` shape.
|
|
86
|
+
*
|
|
87
|
+
* Preserves the pre-v5.5.0 contract: `path` always begins with `params`,
|
|
88
|
+
* `code` is the per-type legacy code (so consumers that filtered by
|
|
89
|
+
* `errors[].code === 'EMPTY_CAROUSEL'` keep working), `message` is Zod's
|
|
90
|
+
* native human-readable message.
|
|
91
|
+
*/
|
|
92
|
+
function mapZodIssuesToErrors(
|
|
93
|
+
issues: readonly ZodIssue[],
|
|
94
|
+
legacyCode: string
|
|
95
|
+
): NonNullable<ValidationResult['errors']> {
|
|
96
|
+
return issues.map((issue) => ({
|
|
97
|
+
path: issue.path.length > 0 ? `params.${issue.path.join('.')}` : 'params',
|
|
98
|
+
message: issue.message,
|
|
99
|
+
code: legacyCode,
|
|
100
|
+
}))
|
|
101
|
+
}
|
|
102
|
+
|
|
34
103
|
/**
|
|
35
104
|
* Default resource limits (configurable via env)
|
|
36
105
|
*/
|
|
@@ -499,9 +568,17 @@ export function validateIframeDomain(
|
|
|
499
568
|
effectiveWhitelist = [...DEFAULT_IFRAME_DOMAINS, ...options.customDomains]
|
|
500
569
|
}
|
|
501
570
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
)
|
|
571
|
+
// SECURITY (v5.5.1) — pre-fix bug: predicate was `allowed === 'localhost'`
|
|
572
|
+
// which trivially returned true for every URL once the whitelist contained
|
|
573
|
+
// 'localhost' (an entry from DEFAULT_IFRAME_DOMAINS), making the entire
|
|
574
|
+
// domain whitelist inoperative. Fixed: only the URL's actual hostname
|
|
575
|
+
// being 'localhost' (or a 127.0.0.x loopback) bypasses the whitelist.
|
|
576
|
+
const isLoopback = domain === 'localhost' || /^127(\.\d{1,3}){3}$/.test(domain)
|
|
577
|
+
const isAllowed =
|
|
578
|
+
isLoopback ||
|
|
579
|
+
effectiveWhitelist.some(
|
|
580
|
+
(allowed) => allowed !== 'localhost' && (domain === allowed || domain.endsWith(`.${allowed}`))
|
|
581
|
+
)
|
|
505
582
|
|
|
506
583
|
if (!isAllowed) {
|
|
507
584
|
return {
|
|
@@ -600,201 +677,86 @@ export function validateComponent(
|
|
|
600
677
|
errors.push(...(sizeResult.errors || []))
|
|
601
678
|
}
|
|
602
679
|
|
|
603
|
-
// Type-specific validation
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
// Basic validation for metrics
|
|
623
|
-
const metricParams = component.params as any
|
|
624
|
-
if (!metricParams.title || !metricParams.value) {
|
|
625
|
-
errors.push({
|
|
626
|
-
path: 'params',
|
|
627
|
-
message: 'Metric must have title and value',
|
|
628
|
-
code: 'INVALID_METRIC',
|
|
629
|
-
})
|
|
630
|
-
}
|
|
631
|
-
break
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
case 'text': {
|
|
635
|
-
// Basic validation for text
|
|
636
|
-
const textParams = component.params as any
|
|
637
|
-
if (!textParams.content) {
|
|
638
|
-
errors.push({
|
|
639
|
-
path: 'params',
|
|
640
|
-
message: 'Text component must have content',
|
|
641
|
-
code: 'INVALID_TEXT',
|
|
642
|
-
})
|
|
643
|
-
}
|
|
644
|
-
break
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
case 'iframe': {
|
|
648
|
-
// Basic validation for iframe
|
|
649
|
-
const iframeParams = component.params as any
|
|
650
|
-
if (!iframeParams.url) {
|
|
651
|
-
errors.push({
|
|
652
|
-
path: 'params',
|
|
653
|
-
message: 'Iframe component must have url',
|
|
654
|
-
code: 'INVALID_IFRAME',
|
|
655
|
-
})
|
|
656
|
-
} else {
|
|
657
|
-
// Validate iframe domain against whitelist
|
|
658
|
-
const iframeResult = validateIframeDomain(iframeParams.url, {
|
|
680
|
+
// Type-specific validation (B.1 — v5.5.0).
|
|
681
|
+
//
|
|
682
|
+
// 12 types delegate shape validation to Zod schemas in `mcp-ui-spec` via
|
|
683
|
+
// SPEC_VALIDATORS. The 5 remaining types stay imperative because they
|
|
684
|
+
// need cross-field consistency, resource limits, or backward-compat logic
|
|
685
|
+
// that pure Zod can't express without `.refine()` (see SPEC_VALIDATORS docstring).
|
|
686
|
+
const specValidator = SPEC_VALIDATORS[component.type]
|
|
687
|
+
if (specValidator) {
|
|
688
|
+
const result = specValidator.schema.safeParse(component.params)
|
|
689
|
+
if (!result.success) {
|
|
690
|
+
errors.push(...mapZodIssuesToErrors(result.error.issues, specValidator.legacyCode))
|
|
691
|
+
}
|
|
692
|
+
// Iframe + video: chain the domain whitelist post-check ONLY when the
|
|
693
|
+
// shape parse succeeded (i.e. url is present and a string). Skipping the
|
|
694
|
+
// domain check when shape failed avoids cascading errors on the same field.
|
|
695
|
+
if (result.success && (component.type === 'iframe' || component.type === 'video')) {
|
|
696
|
+
const url = (component.params as { url?: string })?.url
|
|
697
|
+
if (typeof url === 'string') {
|
|
698
|
+
const domainResult = validateIframeDomain(url, {
|
|
659
699
|
policy: options?.iframePolicy,
|
|
660
700
|
customDomains: options?.customIframeDomains,
|
|
661
701
|
})
|
|
662
|
-
if (!
|
|
663
|
-
errors.push(...(
|
|
702
|
+
if (!domainResult.valid) {
|
|
703
|
+
errors.push(...(domainResult.errors || []))
|
|
664
704
|
}
|
|
665
705
|
}
|
|
666
|
-
break
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
case 'image': {
|
|
670
|
-
// Basic validation for image
|
|
671
|
-
const imageParams = component.params as any
|
|
672
|
-
if (!imageParams.url) {
|
|
673
|
-
errors.push({
|
|
674
|
-
path: 'params',
|
|
675
|
-
message: 'Image component must have url',
|
|
676
|
-
code: 'INVALID_IMAGE',
|
|
677
|
-
})
|
|
678
|
-
}
|
|
679
|
-
break
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
case 'link': {
|
|
683
|
-
// Basic validation for link
|
|
684
|
-
const linkParams = component.params as any
|
|
685
|
-
if (!linkParams.url) {
|
|
686
|
-
errors.push({
|
|
687
|
-
path: 'params',
|
|
688
|
-
message: 'Link component must have url',
|
|
689
|
-
code: 'INVALID_LINK',
|
|
690
|
-
})
|
|
691
|
-
}
|
|
692
|
-
break
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
case 'action': {
|
|
696
|
-
// Basic validation for action
|
|
697
|
-
const actionParams = component.params as any
|
|
698
|
-
if (!actionParams.label) {
|
|
699
|
-
errors.push({
|
|
700
|
-
path: 'params',
|
|
701
|
-
message: 'Action component must have label',
|
|
702
|
-
code: 'INVALID_ACTION',
|
|
703
|
-
})
|
|
704
|
-
}
|
|
705
|
-
break
|
|
706
706
|
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
const videoResult = validateIframeDomain(videoParams.url, {
|
|
715
|
-
policy: options?.iframePolicy,
|
|
716
|
-
customDomains: options?.customIframeDomains,
|
|
717
|
-
})
|
|
718
|
-
if (!videoResult.valid) {
|
|
719
|
-
errors.push(...(videoResult.errors || []))
|
|
707
|
+
} else {
|
|
708
|
+
// Imperative path for chart/table/form/map/modal/grid/footer/composite.
|
|
709
|
+
switch (component.type) {
|
|
710
|
+
case 'chart': {
|
|
711
|
+
const chartResult = validateChartComponent(component.params as ChartComponentParams, limits)
|
|
712
|
+
if (!chartResult.valid) {
|
|
713
|
+
errors.push(...(chartResult.errors || []))
|
|
720
714
|
}
|
|
715
|
+
break
|
|
721
716
|
}
|
|
722
|
-
break
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
case 'carousel': {
|
|
726
|
-
const carouselParams = component.params as any
|
|
727
|
-
if (!Array.isArray(carouselParams.items) || carouselParams.items.length === 0) {
|
|
728
|
-
errors.push({ path: 'params.items', message: 'Carousel must have non-empty items array', code: 'EMPTY_CAROUSEL' })
|
|
729
|
-
}
|
|
730
|
-
break
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
case 'image-gallery': {
|
|
734
|
-
const galleryParams = component.params as any
|
|
735
|
-
if (!Array.isArray(galleryParams.images) || galleryParams.images.length === 0) {
|
|
736
|
-
errors.push({ path: 'params.images', message: 'Gallery must have non-empty images array', code: 'EMPTY_GALLERY' })
|
|
737
|
-
}
|
|
738
|
-
break
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
case 'form': {
|
|
742
|
-
const formParams = component.params as any
|
|
743
|
-
if (!Array.isArray(formParams.fields) || formParams.fields.length === 0) {
|
|
744
|
-
errors.push({ path: 'params.fields', message: 'Form must have non-empty fields array', code: 'EMPTY_FORM' })
|
|
745
|
-
}
|
|
746
|
-
break
|
|
747
|
-
}
|
|
748
717
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
718
|
+
case 'table': {
|
|
719
|
+
const tableResult = validateTableComponent(component.params as TableComponentParams, limits)
|
|
720
|
+
if (!tableResult.valid) {
|
|
721
|
+
errors.push(...(tableResult.errors || []))
|
|
722
|
+
}
|
|
723
|
+
break
|
|
753
724
|
}
|
|
754
|
-
break
|
|
755
|
-
}
|
|
756
725
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
|
761
732
|
}
|
|
762
|
-
break
|
|
763
|
-
}
|
|
764
733
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
|
770
742
|
}
|
|
771
|
-
break
|
|
772
|
-
}
|
|
773
743
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
}
|
|
744
|
+
case 'modal':
|
|
745
|
+
// Modal is valid with minimal params (title optional, content can be children).
|
|
746
|
+
break
|
|
778
747
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
748
|
+
default:
|
|
749
|
+
// Known types without specific validation pass through — renderer handles errors.
|
|
750
|
+
// Truly unknown types (e.g. typos in streamed JSON) are rejected.
|
|
751
|
+
if (!KNOWN_COMPONENT_TYPES.has(component.type)) {
|
|
752
|
+
errors.push({
|
|
753
|
+
path: 'type',
|
|
754
|
+
message: `Unknown component type: ${component.type}`,
|
|
755
|
+
code: 'UNKNOWN_COMPONENT_TYPE',
|
|
756
|
+
})
|
|
757
|
+
}
|
|
758
|
+
break
|
|
785
759
|
}
|
|
786
|
-
|
|
787
|
-
default:
|
|
788
|
-
// Known types without specific validation pass through — renderer handles errors
|
|
789
|
-
// Truly unknown types (e.g. typos in streamed JSON) are rejected
|
|
790
|
-
if (!KNOWN_COMPONENT_TYPES.has(component.type)) {
|
|
791
|
-
errors.push({
|
|
792
|
-
path: 'type',
|
|
793
|
-
message: `Unknown component type: ${component.type}`,
|
|
794
|
-
code: 'UNKNOWN_COMPONENT_TYPE',
|
|
795
|
-
})
|
|
796
|
-
}
|
|
797
|
-
break
|
|
798
760
|
}
|
|
799
761
|
|
|
800
762
|
return {
|