@webmcp-auto-ui/core 0.5.0 → 2.5.4

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.
@@ -0,0 +1,538 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @webmcp-auto-ui/core — WebMCP Server
3
+ // A WebMCP server exposes tools and widget recipes for local UI rendering.
4
+ // Symmetric to MCP (remote data) — WebMCP handles display/interaction.
5
+ // ---------------------------------------------------------------------------
6
+
7
+ import { validateJsonSchema } from './validate.js';
8
+ import type { JsonSchema } from './types.js';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Public types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export interface WebMcpServerOptions {
15
+ description: string;
16
+ }
17
+
18
+ export interface WebMcpToolDef {
19
+ name: string;
20
+ description: string;
21
+ inputSchema: Record<string, unknown>;
22
+ execute: (params: Record<string, unknown>) => Promise<unknown>;
23
+ }
24
+
25
+ /** A vanilla renderer: receives a container + data, optionally returns a cleanup function. */
26
+ export type WidgetRenderer =
27
+ | ((container: HTMLElement, data: Record<string, unknown>) => void | (() => void))
28
+ | unknown; // framework component (Svelte, React, etc.)
29
+
30
+ export interface WidgetEntry {
31
+ name: string;
32
+ description: string;
33
+ inputSchema: Record<string, unknown>;
34
+ recipe: string;
35
+ renderer: WidgetRenderer;
36
+ group?: string;
37
+ /** True when the renderer is a plain function (not a framework component). */
38
+ vanilla: boolean;
39
+ }
40
+
41
+ export interface WebMcpServer {
42
+ readonly name: string;
43
+ readonly description: string;
44
+
45
+ registerWidget(recipeMarkdown: string, renderer: WidgetRenderer): void;
46
+ addTool(tool: WebMcpToolDef): void;
47
+
48
+ layer(): {
49
+ protocol: 'webmcp';
50
+ serverName: string;
51
+ description: string;
52
+ tools: WebMcpToolDef[];
53
+ };
54
+
55
+ getWidget(name: string): WidgetEntry | undefined;
56
+ listWidgets(): WidgetEntry[];
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Frontmatter parser
61
+ // ---------------------------------------------------------------------------
62
+
63
+ export interface ParsedFrontmatter {
64
+ frontmatter: Record<string, unknown>;
65
+ body: string;
66
+ }
67
+
68
+ /**
69
+ * Parse a markdown file with YAML frontmatter (--- delimited).
70
+ * Supports: scalars, nested objects (indentation), arrays (- item), inline values.
71
+ * No external YAML dependency.
72
+ */
73
+ export function parseFrontmatter(markdown: string): ParsedFrontmatter {
74
+ const trimmed = markdown.trimStart();
75
+ if (!trimmed.startsWith('---\n') && !trimmed.startsWith('---\r\n')) {
76
+ return { frontmatter: {}, body: markdown };
77
+ }
78
+
79
+ const endIdx = trimmed.indexOf('\n---', 3);
80
+ if (endIdx === -1) {
81
+ return { frontmatter: {}, body: markdown };
82
+ }
83
+
84
+ const yamlBlock = trimmed.slice(4, endIdx); // skip opening "---\n"
85
+ const body = trimmed.slice(endIdx + 4).replace(/^\r?\n/, ''); // skip closing "---\n" or "---\r\n"
86
+
87
+ const frontmatter = parseYaml(yamlBlock);
88
+ return { frontmatter, body };
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Minimal YAML parser
93
+ // ---------------------------------------------------------------------------
94
+
95
+ interface YamlLine {
96
+ indent: number;
97
+ raw: string;
98
+ content: string; // trimmed
99
+ }
100
+
101
+ function tokenize(yaml: string): YamlLine[] {
102
+ return yaml.split('\n').map(raw => {
103
+ const match = raw.match(/^(\s*)/);
104
+ const indent = match ? match[1].length : 0;
105
+ return { indent, raw, content: raw.trim() };
106
+ }).filter(l => l.content !== '' && !l.content.startsWith('#'));
107
+ }
108
+
109
+ function parseYaml(yaml: string): Record<string, unknown> {
110
+ const lines = tokenize(yaml);
111
+ const [result] = parseObject(lines, 0, 0);
112
+ return result;
113
+ }
114
+
115
+ /**
116
+ * Parse an object starting at `start` with minimum indentation `minIndent`.
117
+ * Returns [parsed object, next line index].
118
+ */
119
+ function parseObject(
120
+ lines: YamlLine[],
121
+ start: number,
122
+ minIndent: number,
123
+ ): [Record<string, unknown>, number] {
124
+ const obj: Record<string, unknown> = {};
125
+ let i = start;
126
+
127
+ while (i < lines.length) {
128
+ const line = lines[i];
129
+ if (line.indent < minIndent) break;
130
+
131
+ // key: value
132
+ const kvMatch = line.content.match(/^([^:]+?):\s*(.*)?$/);
133
+ if (!kvMatch) { i++; continue; }
134
+
135
+ const key = kvMatch[1].trim();
136
+ const valuePart = (kvMatch[2] ?? '').trim();
137
+
138
+ if (valuePart !== '') {
139
+ // Inline value
140
+ obj[key] = parseScalar(valuePart);
141
+ i++;
142
+ } else {
143
+ // Block value — look ahead to determine if array or nested object
144
+ if (i + 1 < lines.length && lines[i + 1].indent > line.indent) {
145
+ const childIndent = lines[i + 1].indent;
146
+ if (lines[i + 1].content.startsWith('- ')) {
147
+ const [arr, next] = parseArray(lines, i + 1, childIndent);
148
+ obj[key] = arr;
149
+ i = next;
150
+ } else {
151
+ const [nested, next] = parseObject(lines, i + 1, childIndent);
152
+ obj[key] = nested;
153
+ i = next;
154
+ }
155
+ } else {
156
+ obj[key] = null;
157
+ i++;
158
+ }
159
+ }
160
+ }
161
+
162
+ return [obj, i];
163
+ }
164
+
165
+ /**
166
+ * Parse an array starting at `start` with items at `minIndent`.
167
+ */
168
+ function parseArray(
169
+ lines: YamlLine[],
170
+ start: number,
171
+ minIndent: number,
172
+ ): [unknown[], number] {
173
+ const arr: unknown[] = [];
174
+ let i = start;
175
+
176
+ while (i < lines.length) {
177
+ const line = lines[i];
178
+ if (line.indent < minIndent) break;
179
+ if (!line.content.startsWith('- ')) break;
180
+
181
+ const itemContent = line.content.slice(2).trim();
182
+
183
+ // Check if this is a mapping item (- key: value on same line, possibly with children)
184
+ const kvMatch = itemContent.match(/^([^:]+?):\s*(.*)?$/);
185
+ if (kvMatch) {
186
+ // It's a mapping starting on the dash line
187
+ const firstKey = kvMatch[1].trim();
188
+ const firstVal = (kvMatch[2] ?? '').trim();
189
+ const itemObj: Record<string, unknown> = {};
190
+ itemObj[firstKey] = firstVal !== '' ? parseScalar(firstVal) : null;
191
+
192
+ // Collect remaining keys of this mapping (indented deeper than the dash)
193
+ i++;
194
+ const childIndent = line.indent + 2; // standard: items indented 2 past dash
195
+ while (i < lines.length && lines[i].indent >= childIndent && !lines[i].content.startsWith('- ')) {
196
+ const childKv = lines[i].content.match(/^([^:]+?):\s*(.*)?$/);
197
+ if (childKv) {
198
+ const ck = childKv[1].trim();
199
+ const cv = (childKv[2] ?? '').trim();
200
+ if (cv !== '') {
201
+ itemObj[ck] = parseScalar(cv);
202
+ } else if (i + 1 < lines.length && lines[i + 1].indent > lines[i].indent) {
203
+ const nextIndent = lines[i + 1].indent;
204
+ if (lines[i + 1].content.startsWith('- ')) {
205
+ const [subArr, next] = parseArray(lines, i + 1, nextIndent);
206
+ itemObj[ck] = subArr;
207
+ i = next;
208
+ continue;
209
+ } else {
210
+ const [subObj, next] = parseObject(lines, i + 1, nextIndent);
211
+ itemObj[ck] = subObj;
212
+ i = next;
213
+ continue;
214
+ }
215
+ } else {
216
+ itemObj[ck] = null;
217
+ }
218
+ }
219
+ i++;
220
+ }
221
+ arr.push(itemObj);
222
+ } else {
223
+ // Simple scalar item
224
+ arr.push(parseScalar(itemContent));
225
+ i++;
226
+ }
227
+ }
228
+
229
+ return [arr, i];
230
+ }
231
+
232
+ /**
233
+ * Parse a scalar YAML value: numbers, booleans, null, inline objects/arrays, or strings.
234
+ */
235
+ function parseScalar(value: string): unknown {
236
+ if (value === 'true') return true;
237
+ if (value === 'false') return false;
238
+ if (value === 'null' || value === '~') return null;
239
+
240
+ // Quoted string
241
+ if ((value.startsWith('"') && value.endsWith('"')) ||
242
+ (value.startsWith("'") && value.endsWith("'"))) {
243
+ return value.slice(1, -1);
244
+ }
245
+
246
+ // Inline JSON-style object { key: val, ... }
247
+ if (value.startsWith('{') && value.endsWith('}')) {
248
+ return parseInlineObject(value);
249
+ }
250
+
251
+ // Inline array [a, b, c]
252
+ if (value.startsWith('[') && value.endsWith(']')) {
253
+ const inner = value.slice(1, -1).trim();
254
+ if (inner === '') return [];
255
+ return inner.split(',').map(s => parseScalar(s.trim()));
256
+ }
257
+
258
+ // Number
259
+ const num = Number(value);
260
+ if (!isNaN(num) && value !== '') return num;
261
+
262
+ return value;
263
+ }
264
+
265
+ /**
266
+ * Parse inline YAML object like { type: string, description: Some text }
267
+ */
268
+ function parseInlineObject(value: string): Record<string, unknown> {
269
+ const inner = value.slice(1, -1).trim();
270
+ const obj: Record<string, unknown> = {};
271
+ // Split on ", " but only at top level (no nested braces handling needed for our use case)
272
+ const parts = inner.split(/,\s*/);
273
+ for (const part of parts) {
274
+ const colonIdx = part.indexOf(':');
275
+ if (colonIdx === -1) continue;
276
+ const k = part.slice(0, colonIdx).trim();
277
+ const v = part.slice(colonIdx + 1).trim();
278
+ obj[k] = parseScalar(v);
279
+ }
280
+ return obj;
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Mount helper — framework-agnostic widget mounting
285
+ // ---------------------------------------------------------------------------
286
+
287
+ /**
288
+ * Mount a widget into a DOM container by searching registered servers.
289
+ * If the renderer is a function (vanilla renderer), it is called directly.
290
+ * Returns an optional cleanup function.
291
+ * Falls back to a text placeholder if no server provides the widget.
292
+ */
293
+ export function mountWidget(
294
+ container: HTMLElement,
295
+ type: string,
296
+ data: Record<string, unknown>,
297
+ servers: WebMcpServer[],
298
+ ): (() => void) | void {
299
+ for (const server of servers) {
300
+ const widget = server.getWidget(type);
301
+ if (widget?.renderer && widget.vanilla) {
302
+ return (widget.renderer as (container: HTMLElement, data: Record<string, unknown>) => void | (() => void))(container, data);
303
+ }
304
+ }
305
+ container.textContent = `[${type}]`;
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Factory
310
+ // ---------------------------------------------------------------------------
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // Image URL sanitizer — strips hallucinated URLs from widget params
314
+ // ---------------------------------------------------------------------------
315
+
316
+ const VALID_URL_PREFIXES = ['http://', 'https://', 'data:', '/'];
317
+ const IMAGE_KEY_PATTERN = /^(src|image|avatar|photo|thumbnail|poster|icon|logo|cover|banner|background)$/i;
318
+
319
+ /** Recursively scan widget params and nullify image-like fields with invalid URLs. */
320
+ function sanitizeImageUrls(obj: unknown): unknown {
321
+ if (Array.isArray(obj)) return obj.map(sanitizeImageUrls);
322
+ if (obj !== null && typeof obj === 'object') {
323
+ const result: Record<string, unknown> = {};
324
+ for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
325
+ if (IMAGE_KEY_PATTERN.test(key) && typeof value === 'string') {
326
+ if (VALID_URL_PREFIXES.some(p => value.startsWith(p))) {
327
+ result[key] = value;
328
+ } else {
329
+ // Invalid image URL — strip it (likely hallucinated)
330
+ // Keep the key but set to undefined so the widget can use its fallback
331
+ }
332
+ } else if (typeof value === 'object' && value !== null) {
333
+ // Special case: avatar as object { src: '...' }
334
+ if (IMAGE_KEY_PATTERN.test(key) && 'src' in (value as Record<string, unknown>)) {
335
+ const srcVal = (value as Record<string, unknown>).src;
336
+ if (typeof srcVal === 'string' && !VALID_URL_PREFIXES.some(p => srcVal.startsWith(p))) {
337
+ // Strip the whole avatar object if src is invalid
338
+ continue;
339
+ }
340
+ }
341
+ result[key] = sanitizeImageUrls(value);
342
+ } else {
343
+ result[key] = value;
344
+ }
345
+ }
346
+ return result;
347
+ }
348
+ return obj;
349
+ }
350
+
351
+ export function createWebMcpServer(
352
+ name: string,
353
+ options: WebMcpServerOptions,
354
+ ): WebMcpServer {
355
+ const widgets = new Map<string, WidgetEntry>();
356
+ const customTools: WebMcpToolDef[] = [];
357
+ let builtinTools: WebMcpToolDef[] | null = null;
358
+
359
+ function generateId(): string {
360
+ return 'w_' + Math.random().toString(36).slice(2, 8);
361
+ }
362
+
363
+ /** Lazily create the 3 built-in tools on first widget registration. */
364
+ function ensureBuiltinTools(): void {
365
+ if (builtinTools) return;
366
+
367
+ builtinTools = [
368
+ {
369
+ name: 'search_recipes',
370
+ description: 'List available widget recipes with their descriptions.',
371
+ inputSchema: {
372
+ type: 'object',
373
+ properties: {
374
+ query: {
375
+ type: 'string',
376
+ description: 'Optional search term to filter recipes',
377
+ },
378
+ },
379
+ additionalProperties: false,
380
+ },
381
+ execute: async (params: Record<string, unknown>) => {
382
+ const query = (params.query as string | undefined)?.toLowerCase();
383
+ const results = [...widgets.values()]
384
+ .filter(w => !query || w.name.includes(query) || w.description.toLowerCase().includes(query))
385
+ .map(w => ({ name: w.name, description: w.description, group: w.group }));
386
+ return results;
387
+ },
388
+ },
389
+ {
390
+ name: 'list_recipes',
391
+ description: 'List all available widget recipes with their name and description.',
392
+ inputSchema: {
393
+ type: 'object',
394
+ properties: {},
395
+ additionalProperties: false,
396
+ },
397
+ execute: async () => {
398
+ const results = [...widgets.values()]
399
+ .map(w => ({ name: w.name, description: w.description, group: w.group }));
400
+ return results;
401
+ },
402
+ },
403
+ {
404
+ name: 'get_recipe',
405
+ description: 'Get the full recipe for a widget: JSON schema + usage instructions.',
406
+ inputSchema: {
407
+ type: 'object',
408
+ properties: {
409
+ name: { type: 'string', description: 'Widget name' },
410
+ },
411
+ required: ['name'],
412
+ additionalProperties: false,
413
+ },
414
+ execute: async (params: Record<string, unknown>) => {
415
+ const widgetName = params.name as string;
416
+ const entry = widgets.get(widgetName);
417
+ if (!entry) {
418
+ return { error: `Widget "${widgetName}" not found. Available: ${[...widgets.keys()].join(', ')}` };
419
+ }
420
+ return {
421
+ name: entry.name,
422
+ description: entry.description,
423
+ schema: entry.inputSchema,
424
+ recipe: entry.recipe,
425
+ };
426
+ },
427
+ },
428
+ {
429
+ name: 'widget_display',
430
+ // Description is dynamic — rebuilt in layer()
431
+ description: 'Display a widget on the canvas. REQUIRED: {name: "widget_type", params: {…}}. Example: {name: "text", params: {content: "Hello world"}}',
432
+ inputSchema: {
433
+ type: 'object',
434
+ properties: {
435
+ name: { type: 'string', description: 'Widget name (e.g. "text", "stat", "list", "chart", "data-table")' },
436
+ params: {
437
+ type: 'object',
438
+ description: 'Widget parameters as JSON object (call get_recipe for the full schema). Example for text: {content: "Hello"}',
439
+ additionalProperties: true,
440
+ },
441
+ },
442
+ required: ['name'],
443
+ additionalProperties: false,
444
+ },
445
+ execute: async (params: Record<string, unknown>) => {
446
+ const widgetName = params.name as string;
447
+ const entry = widgets.get(widgetName);
448
+ if (!entry) {
449
+ return {
450
+ error: `Widget "${widgetName}" not found. Available: ${[...widgets.keys()].join(', ')}`,
451
+ };
452
+ }
453
+
454
+ const rawParams = (params.params ?? {}) as Record<string, unknown>;
455
+ const validation = validateJsonSchema(rawParams, entry.inputSchema as JsonSchema);
456
+ if (!validation.valid) {
457
+ return {
458
+ error: 'Validation failed',
459
+ details: validation.errors,
460
+ expected_schema: entry.inputSchema,
461
+ };
462
+ }
463
+
464
+ // Sanitize image URLs — strip hallucinated/invalid URLs before sending to UI
465
+ const widgetParams = sanitizeImageUrls(rawParams) as Record<string, unknown>;
466
+
467
+ return { widget: widgetName, data: widgetParams, id: generateId() };
468
+ },
469
+ },
470
+ ];
471
+ }
472
+
473
+ const server: WebMcpServer = {
474
+ get name() { return name; },
475
+ get description() { return options.description; },
476
+
477
+ registerWidget(recipeMarkdown: string, renderer: WidgetRenderer): void {
478
+ const { frontmatter, body } = parseFrontmatter(recipeMarkdown);
479
+
480
+ const widgetName = frontmatter.widget as string | undefined;
481
+ if (!widgetName) {
482
+ throw new Error('Recipe frontmatter must include a "widget" field.');
483
+ }
484
+
485
+ const schema = frontmatter.schema as Record<string, unknown> | undefined;
486
+ if (!schema) {
487
+ throw new Error(`Recipe "${widgetName}" frontmatter must include a "schema" field.`);
488
+ }
489
+
490
+ const entry: WidgetEntry = {
491
+ name: widgetName,
492
+ description: (frontmatter.description as string) ?? '',
493
+ inputSchema: schema,
494
+ recipe: body,
495
+ renderer,
496
+ group: frontmatter.group as string | undefined,
497
+ vanilla: typeof renderer === 'function',
498
+ };
499
+
500
+ widgets.set(widgetName, entry);
501
+ ensureBuiltinTools();
502
+ },
503
+
504
+ addTool(tool: WebMcpToolDef): void {
505
+ customTools.push(tool);
506
+ },
507
+
508
+ layer() {
509
+ const allTools = [...customTools];
510
+
511
+ if (builtinTools) {
512
+ // Rebuild widget_display description with current widget names
513
+ const names = [...widgets.keys()];
514
+ const displayTool = builtinTools.find(t => t.name === 'widget_display')!;
515
+ displayTool.description = `Display a widget on the canvas. REQUIRED: {name: "widget_type", params: {…}}. Example: {name: "text", params: {content: "Hello"}}. Available widgets: ${names.join(', ')}.`;
516
+
517
+ allTools.push(...builtinTools);
518
+ }
519
+
520
+ return {
521
+ protocol: 'webmcp' as const,
522
+ serverName: name,
523
+ description: options.description,
524
+ tools: allTools,
525
+ };
526
+ },
527
+
528
+ getWidget(widgetName: string): WidgetEntry | undefined {
529
+ return widgets.get(widgetName);
530
+ },
531
+
532
+ listWidgets(): WidgetEntry[] {
533
+ return [...widgets.values()];
534
+ },
535
+ };
536
+
537
+ return server;
538
+ }