@webmcp-auto-ui/sdk 2.5.38 → 2.5.40

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webmcp-auto-ui/sdk",
3
- "version": "2.5.38",
3
+ "version": "2.5.40",
4
4
  "description": "Skills CRUD, HyperSkill format, Svelte 5 stores",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -4,6 +4,8 @@
4
4
  * present, snapshot fallback otherwise.
5
5
  */
6
6
 
7
+ import { pickFence } from './recipes/fence.js';
8
+
7
9
  export interface WidgetLineage {
8
10
  widgetType: string;
9
11
  widgetParams: Record<string, unknown>;
@@ -138,13 +140,64 @@ export function canvasToNotebookMarkdown(
138
140
  return fm.join('\n') + '\n' + body.join('\n').replace(/\n+$/, '') + '\n';
139
141
  }
140
142
 
143
+ export interface CanvasToNotebookData {
144
+ title?: string;
145
+ description?: string;
146
+ /** Active MCP servers (name, url) — to seed the notebook widget's data. */
147
+ servers?: Array<{ name: string; url: string }>;
148
+ /** Active bundled WebMCP server ids. */
149
+ webmcpServers?: string[];
150
+ /** Cells ready to feed into a NotebookState. */
151
+ cells: Array<{
152
+ id: string;
153
+ type: 'md' | 'sql' | 'js';
154
+ content: string;
155
+ varname?: string;
156
+ status?: 'idle';
157
+ }>;
158
+ }
159
+
160
+ /** Same logic as canvasToNotebookMarkdown but returns structured cells for direct widget mounting. */
161
+ export function canvasToNotebookCells(
162
+ blocks: CanvasBlock[],
163
+ getLineage: (blockId: string) => WidgetLineage | null,
164
+ opts: CanvasToNotebookOptions = {},
165
+ ): CanvasToNotebookData {
166
+ let counter = 0;
167
+ const cells: CanvasToNotebookData['cells'] = [];
168
+
169
+ for (const block of blocks) {
170
+ const raw = lineageToCells(block, getLineage(block.id));
171
+ for (const cell of raw) {
172
+ const entry: CanvasToNotebookData['cells'][number] = {
173
+ id: `c${counter++}`,
174
+ type: cell.kind,
175
+ content: cell.content,
176
+ status: 'idle',
177
+ };
178
+ if (cell.varname !== undefined) entry.varname = cell.varname;
179
+ cells.push(entry);
180
+ }
181
+ }
182
+
183
+ const out: CanvasToNotebookData = { cells };
184
+ if (opts.title) out.title = opts.title;
185
+ if (opts.description) out.description = opts.description;
186
+ if (opts.servers && opts.servers.length > 0) out.servers = opts.servers;
187
+ if (opts.webmcpServers && opts.webmcpServers.length > 0) out.webmcpServers = opts.webmcpServers;
188
+ return out;
189
+ }
190
+
141
191
  function renderCell(cell: NotebookCell): string {
142
192
  if (cell.kind === 'md') return cell.content;
143
193
  if (cell.kind === 'sql') {
144
194
  const meta = cell.varname ? `-- @meta {"varname": "${cell.varname}"}\n` : '';
145
- return '```sql\n' + meta + cell.content + '\n```';
195
+ const body = meta + cell.content;
196
+ const fence = pickFence(body);
197
+ return fence + 'sql\n' + body + '\n' + fence;
146
198
  }
147
- return '```js\n' + cell.content + '\n```';
199
+ const fence = pickFence(cell.content);
200
+ return fence + 'js\n' + cell.content + '\n' + fence;
148
201
  }
149
202
 
150
203
  function pickArrayParamKey(params: Record<string, unknown>): string | null {
package/src/index.ts CHANGED
@@ -91,7 +91,7 @@ export type { RemoteMcpRegistryEntry } from './remote-mcp-registry.js';
91
91
  // import { canvas } from '@webmcp-auto-ui/sdk/canvas'
92
92
 
93
93
  // Recipe runner — markdown-fence parser + JS/TS/SQL/etc executor over MCP
94
- export { parseBody, runCode, estimateTokens, safeStringify, findCodeParamName, buildToolArgs, parseWidgetDisplayCall } from './recipes/index.js';
94
+ export { parseBody, pickFence, runCode, estimateTokens, safeStringify, findCodeParamName, buildToolArgs, parseWidgetDisplayCall, extractTopLevelDecls, hasIdentifierReference } from './recipes/index.js';
95
95
  export type { ParsedSegment, RunResult, RunLog, RunTab, RecipeData } from './recipes/index.js';
96
96
 
97
97
  // Short URL — domain-dependent compact token
@@ -104,10 +104,11 @@ export { extractSampleData } from './widget-sample-data.js';
104
104
  export { unwrap } from './unwrap.js';
105
105
 
106
106
  // Canvas → HyperSkill notebook serializer
107
- export { lineageToCells, canvasToNotebookMarkdown } from './canvas-to-notebook.js';
107
+ export { lineageToCells, canvasToNotebookMarkdown, canvasToNotebookCells } from './canvas-to-notebook.js';
108
108
  export type {
109
109
  WidgetLineage,
110
110
  CanvasToNotebookOptions,
111
+ CanvasToNotebookData,
111
112
  NotebookCell,
112
113
  CanvasBlock,
113
114
  } from './canvas-to-notebook.js';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Pick a backtick fence longer than any run of backticks inside `content`.
3
+ * CommonMark rule: a fenced code block opens with N≥3 backticks; the content
4
+ * must not contain a closing fence of the same length, and the closing fence
5
+ * must be at least as long as the opening. Choosing N = max(3, longestRun+1)
6
+ * makes round-trips safe even when the cell content embeds Markdown fences.
7
+ */
8
+ export function pickFence(content: string): string {
9
+ const runs = content.match(/`+/g) ?? [];
10
+ const longest = runs.reduce((m, r) => Math.max(m, r.length), 0);
11
+ return '`'.repeat(Math.max(3, longest + 1));
12
+ }
@@ -1,3 +1,4 @@
1
1
  export { parseBody } from './parse.js';
2
- export { runCode, estimateTokens, safeStringify, findCodeParamName, buildToolArgs, parseWidgetDisplayCall } from './runner.js';
2
+ export { pickFence } from './fence.js';
3
+ export { runCode, estimateTokens, safeStringify, findCodeParamName, buildToolArgs, parseWidgetDisplayCall, extractTopLevelDecls, hasIdentifierReference } from './runner.js';
3
4
  export type { ParsedSegment, RunResult, RunLog, RunTab, RecipeData } from './types.js';
@@ -16,14 +16,21 @@ export function parseBody(body: string): ParsedSegment[] {
16
16
 
17
17
  const segments: ParsedSegment[] = [];
18
18
  // Match fenced code blocks with optional language tag.
19
- // Non-greedy body; allow any chars between the fences.
20
- const re = /```([a-zA-Z0-9_+-]*)\r?\n([\s\S]*?)```/g;
19
+ // Variable-length fence (≥3 backticks, CommonMark §4.5): the closing fence
20
+ // must use the same number of backticks as the opening — captured via \1.
21
+ // The length match is what protects against premature close when cell
22
+ // content embeds ``` (e.g. widget('text', { content: "...```js..." }))
23
+ // — pickFence emits 4+ backticks in that case. We deliberately do NOT
24
+ // require a newline before the closing fence so that indented fences
25
+ // (common in numbered Markdown lists, where the close is " ```")
26
+ // still parse correctly.
27
+ const re = /(`{3,})([a-zA-Z0-9_+-]*)\r?\n([\s\S]*?)\1/g;
21
28
 
22
29
  let lastIndex = 0;
23
30
  let match: RegExpExecArray | null;
24
31
 
25
32
  while ((match = re.exec(body)) !== null) {
26
- const [full, langRaw, codeRaw] = match;
33
+ const [full, , langRaw, codeRaw] = match;
27
34
  const start = match.index;
28
35
 
29
36
  if (start > lastIndex) {
@@ -78,6 +78,21 @@ function makeCallHelper(multiClient: McpMultiClient | undefined, ctx: RunnerCtx)
78
78
  };
79
79
  }
80
80
 
81
+ /**
82
+ * Sandbox-wide `unwrap(r)` helper. Many recipes assume MCP results are
83
+ * wrapped in `{ data | results | items: [...] }` and unwrap them with this
84
+ * one-liner. Falls through to the input itself, then to an empty array.
85
+ */
86
+ const unwrapHelper = (r: unknown): unknown => {
87
+ if (r && typeof r === 'object') {
88
+ const o = r as Record<string, unknown>;
89
+ if (o.data !== undefined) return o.data;
90
+ if (o.results !== undefined) return o.results;
91
+ if (o.items !== undefined) return o.items;
92
+ }
93
+ return r ?? [];
94
+ };
95
+
81
96
  /**
82
97
  * Build the `widget(name, params)` helper. Captures each call into the run
83
98
  * context so the host can mount them stacked in the run panel.
@@ -85,8 +100,10 @@ function makeCallHelper(multiClient: McpMultiClient | undefined, ctx: RunnerCtx)
85
100
  function makeWidgetHelper(ctx: RunnerCtx) {
86
101
  return async (name: string, params: Record<string, unknown> = {}) => {
87
102
  ctx.log(`widget(${name})`);
88
- ctx.widgets.push({ name, params });
89
- return { name, params };
103
+ // Push and return the SAME reference so widget_display(r) can dedup via ===.
104
+ const entry = { name, params };
105
+ ctx.widgets.push(entry);
106
+ return entry;
90
107
  };
91
108
  }
92
109
 
@@ -147,13 +164,16 @@ function namesFromPattern(content: string): string[] {
147
164
  * Skips matches that occur inside a `{}` block (e.g. callback bodies) by
148
165
  * counting unbalanced braces before the match position, on a sanitized copy
149
166
  * of the code where strings and comments are blanked out. */
150
- function extractTopLevelDecls(code: string): string[] {
167
+ export function extractTopLevelDecls(code: string): string[] {
168
+ // Order matters: strings before comments. Otherwise a URL like
169
+ // 'https://x.png' has its `//` consumed as a line comment, leaving an
170
+ // unbalanced quote that cascades into mismatched braces and drops decls.
151
171
  const stripped = code
152
- .replace(/\/\*[\s\S]*?\*\//g, '')
153
- .replace(/\/\/[^\n]*/g, '')
154
172
  .replace(/'(?:\\.|[^'\\])*'/g, "''")
155
173
  .replace(/"(?:\\.|[^"\\])*"/g, '""')
156
- .replace(/`(?:\\.|[^`\\])*`/g, '``');
174
+ .replace(/`(?:\\.|[^`\\])*`/g, '``')
175
+ .replace(/\/\*[\s\S]*?\*\//g, '')
176
+ .replace(/\/\/[^\n]*/g, '');
157
177
  const out = new Set<string>();
158
178
  // Identifier-form decls (existing behavior)
159
179
  const reId = /^[\t ]*(?:const|let|var|function|class)\s+([a-zA-Z_$][\w$]*)/gm;
@@ -180,6 +200,146 @@ function extractTopLevelDecls(code: string): string[] {
180
200
  return Array.from(out);
181
201
  }
182
202
 
203
+ /** String/comment stripper that PRESERVES template literal interpolations.
204
+ * Replaces literal text inside backtick strings with spaces but keeps
205
+ * `${...}` expressions intact, so cross-cell dependency detection picks up
206
+ * references like `\`Total: ${totalPoints}\``. Single/double-quoted strings
207
+ * and comments are blanked entirely. */
208
+ function stripPreservingInterpolations(code: string): string {
209
+ let out = '';
210
+ let i = 0;
211
+ while (i < code.length) {
212
+ const c = code[i];
213
+ // Single/double-quoted strings — wholly blanked
214
+ if (c === "'" || c === '"') {
215
+ const quote = c;
216
+ out += ' ';
217
+ i++;
218
+ while (i < code.length && code[i] !== quote) {
219
+ if (code[i] === '\\' && i + 1 < code.length) { out += ' '; i += 2; continue; }
220
+ out += code[i] === '\n' ? '\n' : ' ';
221
+ i++;
222
+ }
223
+ out += ' ';
224
+ i++;
225
+ continue;
226
+ }
227
+ // Block comment
228
+ if (c === '/' && code[i + 1] === '*') {
229
+ out += ' ';
230
+ i += 2;
231
+ while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) {
232
+ out += code[i] === '\n' ? '\n' : ' ';
233
+ i++;
234
+ }
235
+ out += ' ';
236
+ i += 2;
237
+ continue;
238
+ }
239
+ // Line comment
240
+ if (c === '/' && code[i + 1] === '/') {
241
+ while (i < code.length && code[i] !== '\n') { out += ' '; i++; }
242
+ continue;
243
+ }
244
+ // Template literal — preserve ${...} interpolations
245
+ if (c === '`') {
246
+ out += ' ';
247
+ i++;
248
+ while (i < code.length && code[i] !== '`') {
249
+ if (code[i] === '\\' && i + 1 < code.length) { out += ' '; i += 2; continue; }
250
+ if (code[i] === '$' && code[i + 1] === '{') {
251
+ out += '${';
252
+ i += 2;
253
+ let depth = 1;
254
+ while (i < code.length && depth > 0) {
255
+ if (code[i] === '{') depth++;
256
+ else if (code[i] === '}') depth--;
257
+ out += code[i];
258
+ i++;
259
+ }
260
+ continue;
261
+ }
262
+ out += code[i] === '\n' ? '\n' : ' ';
263
+ i++;
264
+ }
265
+ out += ' ';
266
+ i++;
267
+ continue;
268
+ }
269
+ out += c;
270
+ i++;
271
+ }
272
+ return out;
273
+ }
274
+
275
+ /** Whether `name` appears as a free identifier in `code` — i.e. as a
276
+ * standalone token (not part of a longer name) and not as a property
277
+ * accessor (`obj.name` does not count). Strings and comments are stripped
278
+ * but template-literal interpolations are preserved. */
279
+ export function hasIdentifierReference(code: string, name: string): boolean {
280
+ if (!name) return false;
281
+ const stripped = stripPreservingInterpolations(code);
282
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
283
+ const re = new RegExp(`(?:^|[^.\\w$])${escaped}(?:[^\\w$]|$)`);
284
+ return re.test(stripped);
285
+ }
286
+
287
+ /** Same string/comment stripper as extractTopLevelDecls but preserves
288
+ * positions (replaces tokens with same-length whitespace) so callers can
289
+ * re-locate matches in the original code. */
290
+ function stripJsTokensSameLength(code: string): string {
291
+ return code
292
+ .replace(/'(?:\\.|[^'\\])*'/g, (s) => ' '.repeat(s.length))
293
+ .replace(/"(?:\\.|[^"\\])*"/g, (s) => ' '.repeat(s.length))
294
+ .replace(/`(?:\\.|[^`\\])*`/g, (s) => ' '.repeat(s.length))
295
+ .replace(/\/\*[\s\S]*?\*\//g, (s) => s.replace(/[^\n]/g, ' '))
296
+ .replace(/\/\/[^\n]*/g, (s) => ' '.repeat(s.length));
297
+ }
298
+
299
+ /** Top-level identifier-form const/let/var decls (subset of
300
+ * extractTopLevelDecls — destructuring/function/class excluded). Used to
301
+ * hoist these names outside the try block so a finally-clause writeback can
302
+ * access them even when the user code returns early. */
303
+ function extractTopLevelHoistableNames(code: string): string[] {
304
+ const stripped = stripJsTokensSameLength(code);
305
+ const out = new Set<string>();
306
+ const re = /^[\t ]*(?:const|let|var)\s+([a-zA-Z_$][\w$]*)/gm;
307
+ let m: RegExpExecArray | null;
308
+ while ((m = re.exec(stripped)) !== null) {
309
+ const before = stripped.slice(0, m.index);
310
+ const opens = (before.match(/\{/g) ?? []).length;
311
+ const closes = (before.match(/\}/g) ?? []).length;
312
+ if (opens === closes) out.add(m[1]);
313
+ }
314
+ return [...out];
315
+ }
316
+
317
+ /** Replace top-level `const|let|var ` keywords (+ trailing whitespace) with
318
+ * whitespace of identical length, turning declarations into plain
319
+ * assignments. Identifier names are preserved untouched. */
320
+ function stripConstLetVarKeywords(code: string, names: Set<string>): string {
321
+ if (names.size === 0) return code;
322
+ const stripped = stripJsTokensSameLength(code);
323
+ const re = /(^|\n)([\t ]*)(const|let|var)(\s+)([a-zA-Z_$][\w$]*)/g;
324
+ const ranges: { from: number; to: number }[] = [];
325
+ let m: RegExpExecArray | null;
326
+ while ((m = re.exec(stripped)) !== null) {
327
+ if (!names.has(m[5])) continue;
328
+ const before = stripped.slice(0, m.index);
329
+ const opens = (before.match(/\{/g) ?? []).length;
330
+ const closes = (before.match(/\}/g) ?? []).length;
331
+ if (opens !== closes) continue;
332
+ const kwStart = m.index + m[1].length + m[2].length;
333
+ const kwEnd = kwStart + m[3].length + m[4].length;
334
+ ranges.push({ from: kwStart, to: kwEnd });
335
+ }
336
+ let result = code;
337
+ for (const r of ranges.reverse()) {
338
+ result = result.slice(0, r.from) + ' '.repeat(r.to - r.from) + result.slice(r.to);
339
+ }
340
+ return result;
341
+ }
342
+
183
343
  async function runJsLike(
184
344
  code: string,
185
345
  ctx: RunnerCtx,
@@ -188,27 +348,72 @@ async function runJsLike(
188
348
  ): Promise<unknown> {
189
349
  // Wrap user code as the body of an async function that executes itself.
190
350
  // Users can use `await`, define vars, and return a final value.
191
- // We inject `call` and `widget` helpers as parameters so recipes can
351
+ // We inject `call`, `widget`, `unwrap` helpers as parameters so recipes can
192
352
  // invoke MCP tools and emit widgets without preamble.
193
- // When a `scope` object is provided, top-level decls of prior blocks are
194
- // re-injected as local consts (preamble), and the current block's top-level
195
- // decls are written back at the end so the next block can read them.
196
- const currentDecls = new Set(extractTopLevelDecls(code));
353
+ //
354
+ // Scope propagation: top-level decls of prior blocks are re-injected as
355
+ // local consts (preamble), and the current block's top-level decls are
356
+ // written back so the next block can read them.
357
+ //
358
+ // Early-return safety: identifier-form const/let/var decls are HOISTED as
359
+ // `let` outside a try { ... } finally { writeback } block. Recipes
360
+ // commonly do `if (!x) return;` after a failed call, which would skip a
361
+ // post-code writeback. With finally-clause writeback, scope propagates
362
+ // even when the cell returns early. Destructuring / function / class
363
+ // decls remain inside the try (writeback at end of try) — they only
364
+ // propagate on natural fall-through, same as before.
365
+ const allDecls = new Set(extractTopLevelDecls(code));
366
+ const hoistable = extractTopLevelHoistableNames(code);
367
+ const hoistableSet = new Set(hoistable);
368
+ const innerOnly = [...allDecls].filter((n) => !hoistableSet.has(n));
369
+ const transformedCode = stripConstLetVarKeywords(code, hoistableSet);
197
370
  const priorKeys = scope
198
- ? Object.keys(scope).filter((k) => /^[a-zA-Z_$][\w$]*$/.test(k) && !currentDecls.has(k))
371
+ ? Object.keys(scope).filter((k) => /^[a-zA-Z_$][\w$]*$/.test(k) && !allDecls.has(k))
199
372
  : [];
200
373
  const preamble = priorKeys.map((k) => `const ${k} = __scope__[${JSON.stringify(k)}];`).join('\n');
201
- const writeback = scope
202
- ? Array.from(currentDecls).map((k) => `__scope__[${JSON.stringify(k)}] = ${k};`).join('\n')
374
+ const hoist = hoistable.length ? `let ${hoistable.join(', ')};` : '';
375
+ const finallyWriteback = scope
376
+ ? hoistable.map((k) => `try { __scope__[${JSON.stringify(k)}] = ${k}; } catch {}`).join('\n')
377
+ : '';
378
+ const innerWriteback = scope
379
+ ? innerOnly.map((k) => `try { __scope__[${JSON.stringify(k)}] = ${k}; } catch {}`).join('\n')
203
380
  : '';
204
- const wrapped = `return (async () => {\n${preamble}\n${code}\n${writeback}\n})();`;
381
+ const wrapped = `return (async () => {\n${preamble}\n${hoist}\ntry {\n${transformedCode}\n${innerWriteback}\n} finally {\n${finallyWriteback}\n}\n})();`;
205
382
  const fn = new (AsyncFunction as unknown as new (
206
383
  ...args: string[]
207
- ) => (call: unknown, widget: unknown, __scope__: Record<string, unknown>) => Promise<unknown>)(
208
- 'call', 'widget', '__scope__', wrapped,
384
+ ) => (
385
+ call: unknown,
386
+ widget: unknown,
387
+ widget_display: unknown,
388
+ unwrap: unknown,
389
+ __scope__: Record<string, unknown>,
390
+ ) => Promise<unknown>)(
391
+ 'call', 'widget', 'widget_display', 'unwrap', '__scope__', wrapped,
209
392
  );
210
393
  ctx.log('dispatched (inline async)');
211
- const out = await fn(makeCallHelper(multiClient, ctx), makeWidgetHelper(ctx), scope ?? {});
394
+ // widget_display is an alias for widget that accepts either ({name,params}) or (name, params).
395
+ // LLMs trained on the autoui MCP tool sometimes emit it directly in JS cells.
396
+ const widgetHelper = makeWidgetHelper(ctx);
397
+ const widgetDisplayAlias = async (a: unknown, b?: unknown) => {
398
+ // Dedup the common LLM pattern `widget_display(widget(...))` /
399
+ // `const r = await widget(...); widget_display(r)`. The argument is then
400
+ // the exact `{name, params}` object that widget() just pushed; re-pushing
401
+ // would render the widget twice.
402
+ const last = ctx.widgets[ctx.widgets.length - 1];
403
+ if (last && a === last) return last;
404
+ if (a && typeof a === 'object' && 'name' in (a as object)) {
405
+ const o = a as { name?: unknown; params?: unknown };
406
+ return widgetHelper(String(o.name ?? ''), (o.params as Record<string, unknown>) ?? {});
407
+ }
408
+ return widgetHelper(String(a ?? ''), (b as Record<string, unknown>) ?? {});
409
+ };
410
+ const out = await fn(
411
+ makeCallHelper(multiClient, ctx),
412
+ widgetHelper,
413
+ widgetDisplayAlias,
414
+ unwrapHelper,
415
+ scope ?? {},
416
+ );
212
417
  ctx.log('resolved');
213
418
  return out;
214
419
  }
@@ -18,7 +18,7 @@ export const REMOTE_MCP_REGISTRY: RemoteMcpRegistryEntry[] = [
18
18
  id: 'tricoteuses',
19
19
  label: 'Tricoteuses',
20
20
  description: 'French parliamentary database — amendments, votes, MPs, political groups.',
21
- url: 'https://mcp.code4code.eu/mcp',
21
+ url: 'https://demos.hyperskills.net/mcp-code4code/mcp',
22
22
  tags: ['politics', 'france', 'parliament', 'open-data'],
23
23
  },
24
24
  {
@@ -99,7 +99,7 @@ export function loadSkills(skills: Skill[]): void {
99
99
  const DEMO_SKILLS: Omit<Skill, 'id' | 'createdAt' | 'updatedAt'>[] = [
100
100
  {
101
101
  name: 'weather-dashboard',
102
- mcp: 'https://mcp.code4code.eu/mcp',
102
+ mcp: 'https://demos.hyperskills.net/mcp-code4code/mcp',
103
103
  mcpName: 'tricoteuses',
104
104
  description: 'Local weather with temperature, conditions, and forecasts',
105
105
  tags: ['weather', 'dashboard'],
@@ -111,7 +111,7 @@ const DEMO_SKILLS: Omit<Skill, 'id' | 'createdAt' | 'updatedAt'>[] = [
111
111
  },
112
112
  {
113
113
  name: 'kpi-overview',
114
- mcp: 'https://mcp.code4code.eu/mcp',
114
+ mcp: 'https://demos.hyperskills.net/mcp-code4code/mcp',
115
115
  mcpName: 'tricoteuses',
116
116
  description: 'KPI overview: revenue, users, churn',
117
117
  tags: ['kpi', 'dashboard'],
@@ -124,7 +124,7 @@ const DEMO_SKILLS: Omit<Skill, 'id' | 'createdAt' | 'updatedAt'>[] = [
124
124
  },
125
125
  {
126
126
  name: 'status-monitor',
127
- mcp: 'https://mcp.code4code.eu/mcp',
127
+ mcp: 'https://demos.hyperskills.net/mcp-code4code/mcp',
128
128
  mcpName: 'tricoteuses',
129
129
  description: 'Service status monitoring',
130
130
  tags: ['ops', 'monitoring'],
@@ -301,26 +301,9 @@ function createCanvasVanilla() {
301
301
  recipes = parseRecipesFromToolResponse(res) ?? [];
302
302
  } catch { /* no recipes */ }
303
303
  }
304
- // Prefetch full bodies in parallel so apps can render markdown / run
305
- // code blocks without re-calling get_recipe per surface.
306
- if (recipes.length && tools.some((t: McpTool) => t.name === 'get_recipe')) {
307
- recipes = await Promise.all(recipes.map(async (r) => {
308
- try {
309
- const res = await multiClient.callToolOn(srv.url, 'get_recipe', { name: r.name, id: r.name });
310
- const text = (res as { content?: { type?: string; text?: string }[] })
311
- .content?.find((c) => c?.type === 'text')?.text;
312
- if (!text) return r;
313
- let body = text;
314
- try {
315
- const p = JSON.parse(text);
316
- if (p && typeof p === 'object' && typeof (p as { content?: unknown }).content === 'string') {
317
- body = (p as { content: string }).content;
318
- }
319
- } catch { /* raw markdown */ }
320
- return { ...r, body };
321
- } catch { return r; }
322
- }));
323
- }
304
+ // Bodies are fetched lazily DiscoveryCache.resolve('get_recipe')
305
+ // returns null when body is missing, letting the agent loop fall
306
+ // through to a live MCP dispatch on demand. Avoids 1×N upfront cost.
324
307
  setDataServerMeta(name, {
325
308
  connected: true, connecting: false,
326
309
  tools: tools as McpToolInfo[],
@@ -74,7 +74,7 @@ describe('loadDemoSkills', () => {
74
74
  loadDemoSkills();
75
75
  const skills = listSkills();
76
76
  expect(skills.length).toBe(3);
77
- expect(skills.every(s => s.mcp === 'https://mcp.code4code.eu/mcp')).toBe(true);
77
+ expect(skills.every(s => s.mcp === 'https://demos.hyperskills.net/mcp-code4code/mcp')).toBe(true);
78
78
  expect(skills.every(s => s.mcpName === 'tricoteuses')).toBe(true);
79
79
  });
80
80
 
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { runCode } from '../src/recipes/runner.js';
3
+
4
+ describe('JS sandbox widget helpers', () => {
5
+ it('widget(name, params) pushes a single entry', async () => {
6
+ const res = await runCode(`await widget('echarts-pie', { values: [{ name: 'a', value: 1 }] }); return 42;`, 'js');
7
+ expect(res.status).toBe('done');
8
+ expect(res.widgets).toHaveLength(1);
9
+ expect(res.widgets?.[0]).toMatchObject({ name: 'echarts-pie', params: { values: [{ name: 'a', value: 1 }] } });
10
+ expect(res.output).toBe(42);
11
+ });
12
+
13
+ it('widget_display(name, params) works as alias of widget()', async () => {
14
+ const res = await runCode(`widget_display('echarts-pie', { values: [{ name: 'a', value: 1 }] });`, 'js');
15
+ expect(res.status).toBe('done');
16
+ expect(res.widgets).toHaveLength(1);
17
+ expect(res.widgets?.[0]).toMatchObject({ name: 'echarts-pie' });
18
+ });
19
+
20
+ it('widget_display({name, params}) works (autoui MCP shape)', async () => {
21
+ const res = await runCode(`widget_display({ name: 'echarts-pie', params: { values: [{ name: 'a', value: 1 }] } });`, 'js');
22
+ expect(res.status).toBe('done');
23
+ expect(res.widgets).toHaveLength(1);
24
+ expect(res.widgets?.[0]).toMatchObject({ name: 'echarts-pie' });
25
+ });
26
+
27
+ it('widget_display(widget(...)) does NOT double-push', async () => {
28
+ // The common LLM pattern that previously rendered the widget twice.
29
+ const res = await runCode(
30
+ `const r = await widget('echarts-pie', { values: [] }); widget_display(r);`,
31
+ 'js',
32
+ );
33
+ expect(res.status).toBe('done');
34
+ expect(res.widgets).toHaveLength(1);
35
+ });
36
+
37
+ it('two distinct widget calls produce two entries', async () => {
38
+ const res = await runCode(
39
+ `await widget('echarts-pie', { values: [] }); await widget('echarts-bar', { rows: [] });`,
40
+ 'js',
41
+ );
42
+ expect(res.status).toBe('done');
43
+ expect(res.widgets).toHaveLength(2);
44
+ expect(res.widgets?.[0].name).toBe('echarts-pie');
45
+ expect(res.widgets?.[1].name).toBe('echarts-bar');
46
+ });
47
+ });