@webmcp-auto-ui/core 0.5.0 → 2.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/README.md +39 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +3 -1
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/multi-client.d.ts +7 -3
- package/dist/multi-client.d.ts.map +1 -1
- package/dist/multi-client.js +49 -4
- package/dist/multi-client.js.map +1 -1
- package/dist/webmcp-helpers.d.ts +0 -16
- package/dist/webmcp-helpers.d.ts.map +1 -1
- package/dist/webmcp-helpers.js +5 -47
- package/dist/webmcp-helpers.js.map +1 -1
- package/dist/webmcp-server.d.ts +54 -0
- package/dist/webmcp-server.d.ts.map +1 -0
- package/dist/webmcp-server.js +420 -0
- package/dist/webmcp-server.js.map +1 -0
- package/package.json +1 -1
- package/src/client.d.ts +26 -0
- package/src/client.ts +3 -1
- package/src/events.d.ts +16 -0
- package/src/index.d.ts +12 -0
- package/src/index.ts +5 -7
- package/src/multi-client.d.ts +55 -0
- package/src/multi-client.ts +53 -4
- package/src/polyfill.d.ts +51 -0
- package/src/types.d.ts +331 -0
- package/src/utils.d.ts +54 -0
- package/src/validate.d.ts +13 -0
- package/src/webmcp-helpers.d.ts +3 -0
- package/src/webmcp-helpers.ts +6 -63
- package/src/webmcp-server.d.ts +53 -0
- package/src/webmcp-server.ts +533 -0
|
@@ -0,0 +1,533 @@
|
|
|
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
|
+
},
|
|
380
|
+
execute: async (params: Record<string, unknown>) => {
|
|
381
|
+
const query = (params.query as string | undefined)?.toLowerCase();
|
|
382
|
+
const results = [...widgets.values()]
|
|
383
|
+
.filter(w => !query || w.name.includes(query) || w.description.toLowerCase().includes(query))
|
|
384
|
+
.map(w => ({ name: w.name, description: w.description, group: w.group }));
|
|
385
|
+
return results;
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
name: 'list_recipes',
|
|
390
|
+
description: 'List all available widget recipes with their name and description.',
|
|
391
|
+
inputSchema: {
|
|
392
|
+
type: 'object',
|
|
393
|
+
properties: {},
|
|
394
|
+
},
|
|
395
|
+
execute: async () => {
|
|
396
|
+
const results = [...widgets.values()]
|
|
397
|
+
.map(w => ({ name: w.name, description: w.description, group: w.group }));
|
|
398
|
+
return results;
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
name: 'get_recipe',
|
|
403
|
+
description: 'Get the full recipe for a widget: JSON schema + usage instructions.',
|
|
404
|
+
inputSchema: {
|
|
405
|
+
type: 'object',
|
|
406
|
+
properties: {
|
|
407
|
+
name: { type: 'string', description: 'Widget name' },
|
|
408
|
+
},
|
|
409
|
+
required: ['name'],
|
|
410
|
+
},
|
|
411
|
+
execute: async (params: Record<string, unknown>) => {
|
|
412
|
+
const widgetName = params.name as string;
|
|
413
|
+
const entry = widgets.get(widgetName);
|
|
414
|
+
if (!entry) {
|
|
415
|
+
return { error: `Widget "${widgetName}" not found. Available: ${[...widgets.keys()].join(', ')}` };
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
name: entry.name,
|
|
419
|
+
description: entry.description,
|
|
420
|
+
schema: entry.inputSchema,
|
|
421
|
+
recipe: entry.recipe,
|
|
422
|
+
};
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
name: 'widget_display',
|
|
427
|
+
// Description is dynamic — rebuilt in layer()
|
|
428
|
+
description: 'Display a widget on the canvas. REQUIRED: {name: "widget_type", params: {…}}. Example: {name: "text", params: {content: "Hello world"}}',
|
|
429
|
+
inputSchema: {
|
|
430
|
+
type: 'object',
|
|
431
|
+
properties: {
|
|
432
|
+
name: { type: 'string', description: 'Widget name (e.g. "text", "stat", "list", "chart", "data-table")' },
|
|
433
|
+
params: {
|
|
434
|
+
type: 'object',
|
|
435
|
+
description: 'Widget parameters as JSON object (call get_recipe for the full schema). Example for text: {content: "Hello"}',
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
required: ['name'],
|
|
439
|
+
},
|
|
440
|
+
execute: async (params: Record<string, unknown>) => {
|
|
441
|
+
const widgetName = params.name as string;
|
|
442
|
+
const entry = widgets.get(widgetName);
|
|
443
|
+
if (!entry) {
|
|
444
|
+
return {
|
|
445
|
+
error: `Widget "${widgetName}" not found. Available: ${[...widgets.keys()].join(', ')}`,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const rawParams = (params.params ?? {}) as Record<string, unknown>;
|
|
450
|
+
const validation = validateJsonSchema(rawParams, entry.inputSchema as JsonSchema);
|
|
451
|
+
if (!validation.valid) {
|
|
452
|
+
return {
|
|
453
|
+
error: 'Validation failed',
|
|
454
|
+
details: validation.errors,
|
|
455
|
+
expected_schema: entry.inputSchema,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Sanitize image URLs — strip hallucinated/invalid URLs before sending to UI
|
|
460
|
+
const widgetParams = sanitizeImageUrls(rawParams) as Record<string, unknown>;
|
|
461
|
+
|
|
462
|
+
return { widget: widgetName, data: widgetParams, id: generateId() };
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const server: WebMcpServer = {
|
|
469
|
+
get name() { return name; },
|
|
470
|
+
get description() { return options.description; },
|
|
471
|
+
|
|
472
|
+
registerWidget(recipeMarkdown: string, renderer: WidgetRenderer): void {
|
|
473
|
+
const { frontmatter, body } = parseFrontmatter(recipeMarkdown);
|
|
474
|
+
|
|
475
|
+
const widgetName = frontmatter.widget as string | undefined;
|
|
476
|
+
if (!widgetName) {
|
|
477
|
+
throw new Error('Recipe frontmatter must include a "widget" field.');
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const schema = frontmatter.schema as Record<string, unknown> | undefined;
|
|
481
|
+
if (!schema) {
|
|
482
|
+
throw new Error(`Recipe "${widgetName}" frontmatter must include a "schema" field.`);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const entry: WidgetEntry = {
|
|
486
|
+
name: widgetName,
|
|
487
|
+
description: (frontmatter.description as string) ?? '',
|
|
488
|
+
inputSchema: schema,
|
|
489
|
+
recipe: body,
|
|
490
|
+
renderer,
|
|
491
|
+
group: frontmatter.group as string | undefined,
|
|
492
|
+
vanilla: typeof renderer === 'function',
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
widgets.set(widgetName, entry);
|
|
496
|
+
ensureBuiltinTools();
|
|
497
|
+
},
|
|
498
|
+
|
|
499
|
+
addTool(tool: WebMcpToolDef): void {
|
|
500
|
+
customTools.push(tool);
|
|
501
|
+
},
|
|
502
|
+
|
|
503
|
+
layer() {
|
|
504
|
+
const allTools = [...customTools];
|
|
505
|
+
|
|
506
|
+
if (builtinTools) {
|
|
507
|
+
// Rebuild widget_display description with current widget names
|
|
508
|
+
const names = [...widgets.keys()];
|
|
509
|
+
const displayTool = builtinTools.find(t => t.name === 'widget_display')!;
|
|
510
|
+
displayTool.description = `Display a widget on the canvas. REQUIRED: {name: "widget_type", params: {…}}. Example: {name: "text", params: {content: "Hello"}}. Available widgets: ${names.join(', ')}.`;
|
|
511
|
+
|
|
512
|
+
allTools.push(...builtinTools);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
protocol: 'webmcp' as const,
|
|
517
|
+
serverName: name,
|
|
518
|
+
description: options.description,
|
|
519
|
+
tools: allTools,
|
|
520
|
+
};
|
|
521
|
+
},
|
|
522
|
+
|
|
523
|
+
getWidget(widgetName: string): WidgetEntry | undefined {
|
|
524
|
+
return widgets.get(widgetName);
|
|
525
|
+
},
|
|
526
|
+
|
|
527
|
+
listWidgets(): WidgetEntry[] {
|
|
528
|
+
return [...widgets.values()];
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
return server;
|
|
533
|
+
}
|