@seed-ship/mcp-ui-solid 5.3.1 → 5.5.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 +104 -0
- package/dist/components/StreamingUIRenderer.cjs +106 -90
- package/dist/components/StreamingUIRenderer.cjs.map +1 -1
- package/dist/components/StreamingUIRenderer.d.ts +7 -0
- package/dist/components/StreamingUIRenderer.d.ts.map +1 -1
- package/dist/components/StreamingUIRenderer.js +107 -91
- package/dist/components/StreamingUIRenderer.js.map +1 -1
- package/dist/components/UIResourceRenderer.cjs +101 -82
- package/dist/components/UIResourceRenderer.cjs.map +1 -1
- package/dist/components/UIResourceRenderer.d.ts +23 -0
- package/dist/components/UIResourceRenderer.d.ts.map +1 -1
- package/dist/components/UIResourceRenderer.js +102 -83
- package/dist/components/UIResourceRenderer.js.map +1 -1
- package/dist/index.cjs +7 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- 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 +70 -152
- package/dist/services/validation.cjs.map +1 -1
- package/dist/services/validation.d.ts.map +1 -1
- package/dist/services/validation.js +70 -152
- package/dist/services/validation.js.map +1 -1
- package/dist/utils/logger.cjs +26 -4
- package/dist/utils/logger.cjs.map +1 -1
- package/dist/utils/logger.d.ts +30 -3
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +27 -5
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/perf.cjs +34 -0
- package/dist/utils/perf.cjs.map +1 -0
- package/dist/utils/perf.d.ts +19 -0
- package/dist/utils/perf.d.ts.map +1 -0
- package/dist/utils/perf.js +34 -0
- package/dist/utils/perf.js.map +1 -0
- package/package.json +3 -2
- package/src/components/StreamingUIRenderer.tsx +54 -2
- package/src/components/UIResourceRenderer.errorMode.test.tsx +95 -0
- package/src/components/UIResourceRenderer.tsx +72 -4
- package/src/index.ts +7 -0
- package/src/services/validation.spec-migration.test.ts +207 -0
- package/src/services/validation.ts +132 -178
- package/src/utils/logger.test.ts +130 -0
- package/src/utils/logger.ts +60 -7
- package/src/utils/perf.test.ts +59 -0
- package/src/utils/perf.ts +50 -0
- 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
|
+
})
|
|
@@ -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
|
*/
|
|
@@ -600,201 +669,86 @@ export function validateComponent(
|
|
|
600
669
|
errors.push(...(sizeResult.errors || []))
|
|
601
670
|
}
|
|
602
671
|
|
|
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, {
|
|
672
|
+
// Type-specific validation (B.1 — v5.5.0).
|
|
673
|
+
//
|
|
674
|
+
// 12 types delegate shape validation to Zod schemas in `mcp-ui-spec` via
|
|
675
|
+
// SPEC_VALIDATORS. The 5 remaining types stay imperative because they
|
|
676
|
+
// need cross-field consistency, resource limits, or backward-compat logic
|
|
677
|
+
// that pure Zod can't express without `.refine()` (see SPEC_VALIDATORS docstring).
|
|
678
|
+
const specValidator = SPEC_VALIDATORS[component.type]
|
|
679
|
+
if (specValidator) {
|
|
680
|
+
const result = specValidator.schema.safeParse(component.params)
|
|
681
|
+
if (!result.success) {
|
|
682
|
+
errors.push(...mapZodIssuesToErrors(result.error.issues, specValidator.legacyCode))
|
|
683
|
+
}
|
|
684
|
+
// Iframe + video: chain the domain whitelist post-check ONLY when the
|
|
685
|
+
// shape parse succeeded (i.e. url is present and a string). Skipping the
|
|
686
|
+
// domain check when shape failed avoids cascading errors on the same field.
|
|
687
|
+
if (result.success && (component.type === 'iframe' || component.type === 'video')) {
|
|
688
|
+
const url = (component.params as { url?: string })?.url
|
|
689
|
+
if (typeof url === 'string') {
|
|
690
|
+
const domainResult = validateIframeDomain(url, {
|
|
659
691
|
policy: options?.iframePolicy,
|
|
660
692
|
customDomains: options?.customIframeDomains,
|
|
661
693
|
})
|
|
662
|
-
if (!
|
|
663
|
-
errors.push(...(
|
|
694
|
+
if (!domainResult.valid) {
|
|
695
|
+
errors.push(...(domainResult.errors || []))
|
|
664
696
|
}
|
|
665
697
|
}
|
|
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
698
|
}
|
|
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 || []))
|
|
699
|
+
} else {
|
|
700
|
+
// Imperative path for chart/table/form/map/modal/grid/footer/composite.
|
|
701
|
+
switch (component.type) {
|
|
702
|
+
case 'chart': {
|
|
703
|
+
const chartResult = validateChartComponent(component.params as ChartComponentParams, limits)
|
|
704
|
+
if (!chartResult.valid) {
|
|
705
|
+
errors.push(...(chartResult.errors || []))
|
|
720
706
|
}
|
|
707
|
+
break
|
|
721
708
|
}
|
|
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
709
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
749
|
-
case 'action-group': {
|
|
750
|
-
const agParams = component.params as any
|
|
751
|
-
if (!Array.isArray(agParams.actions) || agParams.actions.length === 0) {
|
|
752
|
-
errors.push({ path: 'params.actions', message: 'Action group must have non-empty actions array', code: 'EMPTY_ACTION_GROUP' })
|
|
710
|
+
case 'table': {
|
|
711
|
+
const tableResult = validateTableComponent(component.params as TableComponentParams, limits)
|
|
712
|
+
if (!tableResult.valid) {
|
|
713
|
+
errors.push(...(tableResult.errors || []))
|
|
714
|
+
}
|
|
715
|
+
break
|
|
753
716
|
}
|
|
754
|
-
break
|
|
755
|
-
}
|
|
756
717
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
718
|
+
case 'form': {
|
|
719
|
+
const formParams = component.params as { fields?: unknown[] }
|
|
720
|
+
if (!Array.isArray(formParams.fields) || formParams.fields.length === 0) {
|
|
721
|
+
errors.push({ path: 'params.fields', message: 'Form must have non-empty fields array', code: 'EMPTY_FORM' })
|
|
722
|
+
}
|
|
723
|
+
break
|
|
761
724
|
}
|
|
762
|
-
break
|
|
763
|
-
}
|
|
764
725
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
726
|
+
case 'map': {
|
|
727
|
+
// Map can auto-detect center from markers, so center is not strictly required.
|
|
728
|
+
// Spec MapComponentParamsSchema would be too strict (tuple-only center) — kept imperative.
|
|
729
|
+
const mapParams = component.params as { center?: unknown; markers?: unknown[] }
|
|
730
|
+
if (!mapParams.center && (!Array.isArray(mapParams.markers) || mapParams.markers.length === 0)) {
|
|
731
|
+
errors.push({ path: 'params', message: 'Map must have center or markers', code: 'INVALID_MAP' })
|
|
732
|
+
}
|
|
733
|
+
break
|
|
770
734
|
}
|
|
771
|
-
break
|
|
772
|
-
}
|
|
773
735
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
}
|
|
736
|
+
case 'modal':
|
|
737
|
+
// Modal is valid with minimal params (title optional, content can be children).
|
|
738
|
+
break
|
|
778
739
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
740
|
+
default:
|
|
741
|
+
// Known types without specific validation pass through — renderer handles errors.
|
|
742
|
+
// Truly unknown types (e.g. typos in streamed JSON) are rejected.
|
|
743
|
+
if (!KNOWN_COMPONENT_TYPES.has(component.type)) {
|
|
744
|
+
errors.push({
|
|
745
|
+
path: 'type',
|
|
746
|
+
message: `Unknown component type: ${component.type}`,
|
|
747
|
+
code: 'UNKNOWN_COMPONENT_TYPE',
|
|
748
|
+
})
|
|
749
|
+
}
|
|
750
|
+
break
|
|
785
751
|
}
|
|
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
752
|
}
|
|
799
753
|
|
|
800
754
|
return {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for logger debug-mode controls — v5.4.0 (B.2)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
6
|
+
import { createLogger, setDebugMode, isDebugEnabled } from './logger'
|
|
7
|
+
|
|
8
|
+
describe('setDebugMode + isDebugEnabled (v5.4.0 — B.2)', () => {
|
|
9
|
+
let originalNodeEnv: string | undefined
|
|
10
|
+
let originalDebugEnv: string | undefined
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
originalNodeEnv = process.env.NODE_ENV
|
|
14
|
+
originalDebugEnv = process.env.MCP_UI_DEBUG
|
|
15
|
+
setDebugMode(null) // reset override
|
|
16
|
+
delete (globalThis as any).__MCP_UI_DEBUG__
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
process.env.NODE_ENV = originalNodeEnv
|
|
21
|
+
process.env.MCP_UI_DEBUG = originalDebugEnv
|
|
22
|
+
setDebugMode(null)
|
|
23
|
+
delete (globalThis as any).__MCP_UI_DEBUG__
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('default (NODE_ENV=test, no override): isDebugEnabled returns true', () => {
|
|
27
|
+
// Vitest sets NODE_ENV='test' by default — that's !== 'production' so dev mode is on
|
|
28
|
+
expect(isDebugEnabled()).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('NODE_ENV=production silences debug by default', () => {
|
|
32
|
+
process.env.NODE_ENV = 'production'
|
|
33
|
+
delete process.env.MCP_UI_DEBUG
|
|
34
|
+
expect(isDebugEnabled()).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('MCP_UI_DEBUG=true re-enables debug in production', () => {
|
|
38
|
+
process.env.NODE_ENV = 'production'
|
|
39
|
+
process.env.MCP_UI_DEBUG = 'true'
|
|
40
|
+
expect(isDebugEnabled()).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('globalThis.__MCP_UI_DEBUG__=true re-enables debug in production', () => {
|
|
44
|
+
process.env.NODE_ENV = 'production'
|
|
45
|
+
delete process.env.MCP_UI_DEBUG
|
|
46
|
+
;(globalThis as any).__MCP_UI_DEBUG__ = true
|
|
47
|
+
expect(isDebugEnabled()).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('setDebugMode(true) overrides NODE_ENV=production', () => {
|
|
51
|
+
process.env.NODE_ENV = 'production'
|
|
52
|
+
setDebugMode(true)
|
|
53
|
+
expect(isDebugEnabled()).toBe(true)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('setDebugMode(false) overrides NODE_ENV=development', () => {
|
|
57
|
+
process.env.NODE_ENV = 'development'
|
|
58
|
+
setDebugMode(false)
|
|
59
|
+
expect(isDebugEnabled()).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('setDebugMode(null) restores env-based detection', () => {
|
|
63
|
+
process.env.NODE_ENV = 'production'
|
|
64
|
+
setDebugMode(true)
|
|
65
|
+
expect(isDebugEnabled()).toBe(true)
|
|
66
|
+
setDebugMode(null)
|
|
67
|
+
expect(isDebugEnabled()).toBe(false)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('createLogger respects debug mode (v5.4.0)', () => {
|
|
72
|
+
let originalNodeEnv: string | undefined
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
originalNodeEnv = process.env.NODE_ENV
|
|
76
|
+
setDebugMode(null)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
process.env.NODE_ENV = originalNodeEnv
|
|
81
|
+
setDebugMode(null)
|
|
82
|
+
vi.restoreAllMocks()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('info/warn/debug are silent when debug is off', () => {
|
|
86
|
+
process.env.NODE_ENV = 'production'
|
|
87
|
+
setDebugMode(false)
|
|
88
|
+
|
|
89
|
+
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
|
|
90
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
91
|
+
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
|
92
|
+
|
|
93
|
+
const logger = createLogger('test')
|
|
94
|
+
logger.info('a')
|
|
95
|
+
logger.warn('b')
|
|
96
|
+
logger.debug('c')
|
|
97
|
+
|
|
98
|
+
expect(infoSpy).not.toHaveBeenCalled()
|
|
99
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
100
|
+
expect(debugSpy).not.toHaveBeenCalled()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('error always logs even when debug is off', () => {
|
|
104
|
+
process.env.NODE_ENV = 'production'
|
|
105
|
+
setDebugMode(false)
|
|
106
|
+
|
|
107
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
108
|
+
|
|
109
|
+
const logger = createLogger('test')
|
|
110
|
+
logger.error('boom', { id: 1 })
|
|
111
|
+
|
|
112
|
+
expect(errorSpy).toHaveBeenCalledOnce()
|
|
113
|
+
expect(errorSpy.mock.calls[0][0]).toContain('[@seed-ship/mcp-ui-solid:test]')
|
|
114
|
+
expect(errorSpy.mock.calls[0][0]).toContain('boom')
|
|
115
|
+
expect(errorSpy.mock.calls[0][0]).toContain('"id":1')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('toggling setDebugMode at runtime affects subsequent calls', () => {
|
|
119
|
+
process.env.NODE_ENV = 'production'
|
|
120
|
+
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
|
|
121
|
+
|
|
122
|
+
const logger = createLogger('toggle')
|
|
123
|
+
logger.info('off')
|
|
124
|
+
expect(infoSpy).not.toHaveBeenCalled()
|
|
125
|
+
|
|
126
|
+
setDebugMode(true)
|
|
127
|
+
logger.info('on')
|
|
128
|
+
expect(infoSpy).toHaveBeenCalledOnce()
|
|
129
|
+
})
|
|
130
|
+
})
|