@webmcp-auto-ui/sdk 2.5.35 → 2.5.37

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.35",
3
+ "version": "2.5.37",
4
4
  "description": "Skills CRUD, HyperSkill format, Svelte 5 stores",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -27,6 +27,7 @@
27
27
  "build": "svelte-package -i src"
28
28
  },
29
29
  "dependencies": {
30
+ "@webmcp-auto-ui/core": "file:../core",
30
31
  "hyperskills": "^0.1.4"
31
32
  },
32
33
  "peerDependencies": {
@@ -34,7 +35,6 @@
34
35
  },
35
36
  "devDependencies": {
36
37
  "@sveltejs/package": "^2.3.0",
37
- "@webmcp-auto-ui/core": "file:../core",
38
38
  "svelte": "^5.0.0",
39
39
  "svelte-check": "^4.0.0",
40
40
  "typescript": "^5.0.0"
package/src/index.ts CHANGED
@@ -83,15 +83,15 @@ export {
83
83
  } from './skills/registry.js';
84
84
  export type { Skill, SkillBlock } from './skills/registry.js';
85
85
 
86
- // MCP demo servers
87
- export { MCP_DEMO_SERVERS } from './mcp-demo-servers.js';
88
- export type { McpDemoServer } from './mcp-demo-servers.js';
86
+ // Remote MCP server registry
87
+ export { REMOTE_MCP_REGISTRY } from './remote-mcp-registry.js';
88
+ export type { RemoteMcpRegistryEntry } from './remote-mcp-registry.js';
89
89
 
90
90
  // Canvas store — browser-only (Svelte 5 runes), import directly from src:
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 } from './recipes/index.js';
94
+ export { parseBody, runCode, estimateTokens, safeStringify, findCodeParamName, buildToolArgs } 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
@@ -1,3 +1,3 @@
1
1
  export { parseBody } from './parse.js';
2
- export { runCode, estimateTokens, safeStringify } from './runner.js';
2
+ export { runCode, estimateTokens, safeStringify, findCodeParamName, buildToolArgs } from './runner.js';
3
3
  export type { ParsedSegment, RunResult, RunLog, RunTab, RecipeData } from './types.js';
@@ -35,27 +35,180 @@ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as
35
35
  interface RunnerCtx {
36
36
  log: (msg: string) => void;
37
37
  start: number;
38
+ widgets: Array<{ name: string; params: Record<string, unknown> }>;
38
39
  }
39
40
 
40
41
  function makeCtx(): RunnerCtx & { logs: RunLog[] } {
41
42
  const start = performance.now();
42
43
  const logs: RunLog[] = [];
44
+ const widgets: Array<{ name: string; params: Record<string, unknown> }> = [];
43
45
  return {
44
46
  start,
45
47
  logs,
48
+ widgets,
46
49
  log(msg: string) {
47
50
  logs.push({ t: Math.round(performance.now() - start), msg });
48
51
  },
49
52
  };
50
53
  }
51
54
 
52
- async function runJsLike(code: string, ctx: RunnerCtx): Promise<unknown> {
55
+ /**
56
+ * Build the `call(toolName, args)` helper exposed to recipe JS sandboxes.
57
+ * Resolves a tool by name across all connected MCP servers, calls it, and
58
+ * returns the parsed text payload (JSON if possible) or the raw result.
59
+ */
60
+ function makeCallHelper(multiClient: McpMultiClient | undefined, ctx: RunnerCtx) {
61
+ return async (toolName: string, args: Record<string, unknown> = {}) => {
62
+ const found = findToolOnAnyServer(multiClient, toolName);
63
+ if (!found || !multiClient) {
64
+ throw new Error(`No MCP server exposes tool "${toolName}"`);
65
+ }
66
+ ctx.log(`call(${toolName})`);
67
+ const res = await multiClient.callToolOn(found.url, toolName, args);
68
+ // MCP-spec: prefer structuredContent (typed payload) over content[].text.
69
+ const sc = (res as { structuredContent?: unknown }).structuredContent;
70
+ if (sc != null && typeof sc === 'object') return sc;
71
+ const textPart = res?.content?.find((c: { type: string }) => c.type === 'text') as
72
+ | { text?: string }
73
+ | undefined;
74
+ if (textPart?.text) {
75
+ try { return JSON.parse(textPart.text); } catch { return textPart.text; }
76
+ }
77
+ return res;
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Build the `widget(name, params)` helper. Captures each call into the run
83
+ * context so the host can mount them stacked in the run panel.
84
+ */
85
+ function makeWidgetHelper(ctx: RunnerCtx) {
86
+ return async (name: string, params: Record<string, unknown> = {}) => {
87
+ ctx.log(`widget(${name})`);
88
+ ctx.widgets.push({ name, params });
89
+ return { name, params };
90
+ };
91
+ }
92
+
93
+ /** Find the index of the matching close bracket for `code[startIdx]`. */
94
+ function matchBracket(code: string, startIdx: number): number {
95
+ const open = code[startIdx];
96
+ const close = open === '[' ? ']' : open === '{' ? '}' : open === '(' ? ')' : '';
97
+ if (!close) return -1;
98
+ let depth = 0;
99
+ for (let i = startIdx; i < code.length; i++) {
100
+ if (code[i] === open) depth++;
101
+ else if (code[i] === close) { depth--; if (depth === 0) return i; }
102
+ }
103
+ return -1;
104
+ }
105
+
106
+ /** Extract identifier names from a destructuring pattern body (without
107
+ * the outer brackets). Handles `[a, b]`, `{a, b}`, `{a: alias}`,
108
+ * `[a, ...rest]`, default values `a = 1`, top-level commas only. */
109
+ function namesFromPattern(content: string): string[] {
110
+ const parts: string[] = [];
111
+ let depth = 0, start = 0;
112
+ for (let i = 0; i < content.length; i++) {
113
+ const c = content[i];
114
+ if (c === '{' || c === '[' || c === '(') depth++;
115
+ else if (c === '}' || c === ']' || c === ')') depth--;
116
+ else if (c === ',' && depth === 0) { parts.push(content.slice(start, i)); start = i + 1; }
117
+ }
118
+ parts.push(content.slice(start));
119
+ const names: string[] = [];
120
+ for (let part of parts) {
121
+ part = part.trim();
122
+ if (!part) continue;
123
+ if (part.startsWith('...')) part = part.slice(3).trim();
124
+ // Strip default value `name = expr`
125
+ const eqIdx = part.indexOf('=');
126
+ if (eqIdx >= 0) part = part.slice(0, eqIdx).trim();
127
+ // Object rename `key: alias` → take alias side
128
+ const colonIdx = part.indexOf(':');
129
+ if (colonIdx >= 0) {
130
+ const alias = part.slice(colonIdx + 1).trim();
131
+ // alias may itself be a nested pattern — recurse
132
+ if (alias.startsWith('{') || alias.startsWith('[')) {
133
+ const end = matchBracket(alias, 0);
134
+ if (end > 0) names.push(...namesFromPattern(alias.slice(1, end)));
135
+ } else if (/^[a-zA-Z_$][\w$]*$/.test(alias)) {
136
+ names.push(alias);
137
+ }
138
+ continue;
139
+ }
140
+ if (/^[a-zA-Z_$][\w$]*$/.test(part)) names.push(part);
141
+ }
142
+ return names;
143
+ }
144
+
145
+ /** Match top-level `const|let|var name`, `function name`, `class name`,
146
+ * plus destructuring `const [a, b] = ...` and `const { a, b } = ...`.
147
+ * Skips matches that occur inside a `{}` block (e.g. callback bodies) by
148
+ * counting unbalanced braces before the match position, on a sanitized copy
149
+ * of the code where strings and comments are blanked out. */
150
+ function extractTopLevelDecls(code: string): string[] {
151
+ const stripped = code
152
+ .replace(/\/\*[\s\S]*?\*\//g, '')
153
+ .replace(/\/\/[^\n]*/g, '')
154
+ .replace(/'(?:\\.|[^'\\])*'/g, "''")
155
+ .replace(/"(?:\\.|[^"\\])*"/g, '""')
156
+ .replace(/`(?:\\.|[^`\\])*`/g, '``');
157
+ const out = new Set<string>();
158
+ // Identifier-form decls (existing behavior)
159
+ const reId = /^[\t ]*(?:const|let|var|function|class)\s+([a-zA-Z_$][\w$]*)/gm;
160
+ let m: RegExpExecArray | null;
161
+ while ((m = reId.exec(stripped)) !== null) {
162
+ const before = stripped.slice(0, m.index);
163
+ const opens = (before.match(/\{/g) ?? []).length;
164
+ const closes = (before.match(/\}/g) ?? []).length;
165
+ if (opens === closes) out.add(m[1]);
166
+ }
167
+ // Destructuring: const|let|var followed by `[` or `{`
168
+ const reDestr = /^[\t ]*(?:const|let|var)\s+([\[\{])/gm;
169
+ while ((m = reDestr.exec(stripped)) !== null) {
170
+ const before = stripped.slice(0, m.index);
171
+ const opens = (before.match(/\{/g) ?? []).length;
172
+ const closes = (before.match(/\}/g) ?? []).length;
173
+ if (opens !== closes) continue;
174
+ const bracketStart = m.index + m[0].length - 1;
175
+ const end = matchBracket(stripped, bracketStart);
176
+ if (end < 0) continue;
177
+ const inner = stripped.slice(bracketStart + 1, end);
178
+ for (const n of namesFromPattern(inner)) out.add(n);
179
+ }
180
+ return Array.from(out);
181
+ }
182
+
183
+ async function runJsLike(
184
+ code: string,
185
+ ctx: RunnerCtx,
186
+ multiClient?: McpMultiClient,
187
+ scope?: Record<string, unknown>,
188
+ ): Promise<unknown> {
53
189
  // Wrap user code as the body of an async function that executes itself.
54
190
  // Users can use `await`, define vars, and return a final value.
55
- const wrapped = `return (async () => {\n${code}\n})();`;
56
- const fn = new AsyncFunction(wrapped);
191
+ // We inject `call` and `widget` helpers as parameters so recipes can
192
+ // 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));
197
+ const priorKeys = scope
198
+ ? Object.keys(scope).filter((k) => /^[a-zA-Z_$][\w$]*$/.test(k) && !currentDecls.has(k))
199
+ : [];
200
+ 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')
203
+ : '';
204
+ const wrapped = `return (async () => {\n${preamble}\n${code}\n${writeback}\n})();`;
205
+ const fn = new (AsyncFunction as unknown as new (
206
+ ...args: string[]
207
+ ) => (call: unknown, widget: unknown, __scope__: Record<string, unknown>) => Promise<unknown>)(
208
+ 'call', 'widget', '__scope__', wrapped,
209
+ );
57
210
  ctx.log('dispatched (inline async)');
58
- const out = await fn();
211
+ const out = await fn(makeCallHelper(multiClient, ctx), makeWidgetHelper(ctx), scope ?? {});
59
212
  ctx.log('resolved');
60
213
  return out;
61
214
  }
@@ -70,7 +223,7 @@ interface McpToolDef {
70
223
  * Inspects a tool's inputSchema to find the string parameter that likely
71
224
  * holds the code/script/query. Returns the param name or null.
72
225
  */
73
- function findCodeParamName(schema: unknown): string | null {
226
+ export function findCodeParamName(schema: unknown): string | null {
74
227
  const s = schema as
75
228
  | { properties?: Record<string, { type?: string }>; required?: string[] }
76
229
  | null
@@ -132,7 +285,7 @@ function inferParamValue(
132
285
  * or leaving it unset if nothing can be inferred (MCP will error explicitly
133
286
  * so the user knows what to add).
134
287
  */
135
- function buildToolArgs(
288
+ export function buildToolArgs(
136
289
  schema: unknown,
137
290
  codeParam: string,
138
291
  code: string,
@@ -210,9 +363,71 @@ async function runViaMcp(
210
363
  return res;
211
364
  }
212
365
 
366
+ function findBalancedBraces(text: string, fromIndex: number): string | null {
367
+ let depth = 0;
368
+ let start = -1;
369
+ for (let i = fromIndex; i < text.length; i++) {
370
+ const ch = text[i];
371
+ if (ch === '{') {
372
+ if (start === -1) start = i;
373
+ depth++;
374
+ } else if (ch === '}') {
375
+ depth--;
376
+ if (depth === 0 && start !== -1) return text.slice(start, i + 1);
377
+ }
378
+ }
379
+ return null;
380
+ }
381
+
382
+ /**
383
+ * Detect calls of the form `widget_display({name, params})` or
384
+ * `<server>_webmcp_widget_display({name, params})` in a recipe snippet.
385
+ * Recipes are bundled at build time from our own source, so `new Function`
386
+ * eval of the object literal is acceptable.
387
+ *
388
+ * Some recipes contain placeholders (`data: [...]`, `params: {...}`) that
389
+ * are not valid JS. We extract `name` via a separate regex so the widget can
390
+ * always render, even if the `params` literal cannot be eval'd; in that case
391
+ * we return `params: {}` rather than falling through to `run_script`.
392
+ */
393
+ export function parseWidgetDisplayCall(
394
+ code: string,
395
+ ): { name: string; params: Record<string, unknown>; paramsParseFailed?: boolean } | null {
396
+ const m = /(?:^|\W)(?:[a-zA-Z_]\w*_)?widget_display\s*\(/m.exec(code);
397
+ if (!m) return null;
398
+ const objStart = code.indexOf('{', m.index + m[0].length - 1);
399
+ if (objStart === -1) return null;
400
+ const objLiteral = findBalancedBraces(code, objStart);
401
+ if (!objLiteral) return null;
402
+ // `name` is extracted independently so a broken `params` placeholder doesn't
403
+ // disqualify the call.
404
+ const nameMatch = /\bname\s*:\s*['"]([^'"]+)['"]/.exec(objLiteral);
405
+ if (!nameMatch) return null;
406
+ const name = nameMatch[1];
407
+ // Sanitize common placeholder forms before eval.
408
+ const sanitized = objLiteral
409
+ .replace(/\[\s*\.\.\.\s*\]/g, '[]')
410
+ .replace(/\{\s*\.\.\.\s*\}/g, '{}');
411
+ try {
412
+ const fn = new Function(`return (${sanitized});`);
413
+ const value = fn();
414
+ if (value && typeof value === 'object') {
415
+ const v = value as { params?: unknown };
416
+ if (v.params && typeof v.params === 'object') {
417
+ return { name, params: v.params as Record<string, unknown> };
418
+ }
419
+ }
420
+ return { name, params: {} };
421
+ } catch {
422
+ return { name, params: {}, paramsParseFailed: true };
423
+ }
424
+ }
425
+
213
426
  /**
214
427
  * Run a snippet of code in a given language.
215
428
  *
429
+ * - `widget_display(...)` (in a `text`/untagged block): parsed locally and
430
+ * surfaced via `result.widget` so the host can mount the widget live.
216
431
  * - JS / TS: executed inline via AsyncFunction (TS is NOT transpiled; code must
217
432
  * be valid JS or the caller should keep type annotations minimal).
218
433
  * - SQL: dispatched to `query_sql` on any connected MCP server that exposes it.
@@ -222,15 +437,34 @@ async function runViaMcp(
222
437
  export async function runCode(
223
438
  code: string,
224
439
  lang: string,
225
- multiClient?: McpMultiClient
440
+ multiClient?: McpMultiClient,
441
+ scope?: Record<string, unknown>,
226
442
  ): Promise<RunResult> {
227
443
  const ctx = makeCtx();
228
444
  const normLang = (lang || '').toLowerCase();
229
445
  const startedAt = ctx.start;
230
446
  try {
447
+ if (normLang === '' || normLang === 'text') {
448
+ const parsed = parseWidgetDisplayCall(code);
449
+ if (parsed) {
450
+ ctx.log(`widget_display: ${parsed.name}`);
451
+ if (parsed.paramsParseFailed) {
452
+ ctx.log('params placeholder — rendering with empty params');
453
+ }
454
+ const durationMs = Math.round(performance.now() - ctx.start);
455
+ return {
456
+ status: 'done',
457
+ startedAt,
458
+ durationMs,
459
+ tokens: estimateTokens(code),
460
+ widget: { name: parsed.name, params: parsed.params },
461
+ logs: ctx.logs,
462
+ };
463
+ }
464
+ }
231
465
  let output: unknown;
232
466
  if (JS_LANGS.has(normLang) || TS_LANGS.has(normLang) || normLang === '') {
233
- output = await runJsLike(code, ctx);
467
+ output = await runJsLike(code, ctx, multiClient, scope);
234
468
  } else {
235
469
  output = await runViaMcp(code, normLang, multiClient, ctx);
236
470
  }
@@ -243,6 +477,7 @@ export async function runCode(
243
477
  tokens,
244
478
  output,
245
479
  logs: ctx.logs,
480
+ ...(ctx.widgets.length > 0 ? { widgets: ctx.widgets } : {}),
246
481
  };
247
482
  } catch (err) {
248
483
  const durationMs = Math.round(performance.now() - ctx.start);
@@ -44,6 +44,18 @@ export interface RunResult {
44
44
  output?: unknown;
45
45
  error?: string;
46
46
  logs: RunLog[];
47
+ /**
48
+ * Set when the executed snippet was a `*widget_display({name, params})`
49
+ * call. The runner does not call the MCP tool in that case — instead it
50
+ * exposes the parsed widget to the host so it can be mounted live.
51
+ */
52
+ widget?: { name: string; params: Record<string, unknown> };
53
+ /**
54
+ * Set when the executed JS snippet called `widget(name, params)` one or
55
+ * more times via the in-sandbox helper. Each call is captured in order
56
+ * so the host can mount them stacked in the same run panel.
57
+ */
58
+ widgets?: Array<{ name: string; params: Record<string, unknown> }>;
47
59
  }
48
60
 
49
61
  export interface RunTab {
@@ -1,72 +1,71 @@
1
- // @webmcp-auto-ui/sdk — MCP demo servers registry
2
- // Lists all MCP servers available on the production VM (demos.hyperskills.net)
1
+ // @webmcp-auto-ui/sdk — Remote MCP server registry
2
+ // Single source of truth for the demo MCP servers exposed by the project.
3
+ // Convention aligned with WEBMCP_SERVER_REGISTRY (packages/servers/src/registry.ts):
4
+ // stable `id` as identity, `label` as display name.
3
5
 
4
- export interface McpDemoServer {
6
+ export interface RemoteMcpRegistryEntry {
5
7
  id: string;
6
- name: string;
8
+ label: string;
7
9
  description: string;
8
10
  url: string;
9
11
  tags?: string[];
12
+ /** Optional auth headers passed to McpClient on handshake (e.g. Bearer token). */
13
+ headers?: Record<string, string>;
10
14
  }
11
15
 
12
- /**
13
- * MCP demo servers available for webmcp-auto-ui demos.
14
- * tricoteuses has its own domain; others follow the pattern
15
- * https://demos.hyperskills.net/<id>/mcp
16
- */
17
- export const MCP_DEMO_SERVERS: McpDemoServer[] = [
16
+ export const REMOTE_MCP_REGISTRY: RemoteMcpRegistryEntry[] = [
18
17
  {
19
18
  id: 'tricoteuses',
20
- name: 'Tricoteuses',
19
+ label: 'Tricoteuses',
21
20
  description: 'French parliamentary database — amendments, votes, MPs, political groups.',
22
21
  url: 'https://mcp.code4code.eu/mcp',
23
22
  tags: ['politics', 'france', 'parliament', 'open-data'],
24
23
  },
25
24
  {
26
25
  id: 'hackernews',
27
- name: 'Hacker News',
26
+ label: 'Hacker News',
28
27
  description: 'Hacker News stories, comments, and rankings.',
29
28
  url: 'https://demos.hyperskills.net/mcp-hackernews/mcp',
30
29
  tags: ['tech', 'news', 'community'],
31
30
  },
32
31
  {
33
32
  id: 'metmuseum',
34
- name: 'Met Museum',
33
+ label: 'Met Museum',
35
34
  description: 'Metropolitan Museum of Art — collections, artworks, artists.',
36
35
  url: 'https://demos.hyperskills.net/mcp-metmuseum/mcp',
37
36
  tags: ['art', 'museum', 'culture', 'collections'],
38
37
  },
39
38
  {
40
39
  id: 'openmeteo',
41
- name: 'Open-Meteo',
40
+ label: 'Open-Meteo',
42
41
  description: 'Weather data — forecasts, history, geolocation.',
43
42
  url: 'https://demos.hyperskills.net/mcp-openmeteo/mcp',
44
43
  tags: ['weather', 'climate', 'forecasts', 'geo'],
45
44
  },
46
45
  {
47
46
  id: 'wikipedia',
48
- name: 'Wikipedia',
47
+ label: 'Wikipedia',
49
48
  description: 'Wikipedia search and content — articles, summaries, categories.',
50
49
  url: 'https://demos.hyperskills.net/mcp-wikipedia/mcp',
51
50
  tags: ['encyclopedia', 'knowledge', 'search'],
52
51
  },
53
52
  {
54
53
  id: 'inaturalist',
55
- name: 'iNaturalist',
54
+ label: 'iNaturalist',
56
55
  description: 'Nature observations — species, taxa, biodiversity statistics.',
57
56
  url: 'https://demos.hyperskills.net/mcp-inaturalist/mcp',
58
57
  tags: ['nature', 'biodiversity', 'observations', 'citizen-science'],
59
58
  },
60
59
  {
61
60
  id: 'datagouv',
62
- name: 'data.gouv.fr',
61
+ label: 'data.gouv.fr',
63
62
  description: 'French open data — public datasets, statistics, reference data.',
64
63
  url: 'https://demos.hyperskills.net/mcp-datagouv/mcp',
65
64
  tags: ['open-data', 'france', 'government', 'statistics'],
66
65
  },
67
66
  {
68
67
  id: 'nasa',
69
- name: 'NASA',
68
+ label: 'NASA',
70
69
  description: 'NASA — space imagery, astronomical data, Mars rovers, asteroids.',
71
70
  url: 'https://demos.hyperskills.net/mcp-nasa/mcp',
72
71
  },
@@ -118,6 +118,8 @@ function createCanvas() {
118
118
  setDataServerMeta: canvasVanilla.setDataServerMeta.bind(canvasVanilla),
119
119
  setDataServerEnabled: canvasVanilla.setDataServerEnabled.bind(canvasVanilla),
120
120
  toggleDataServer: canvasVanilla.toggleDataServer.bind(canvasVanilla),
121
+ callTool: canvasVanilla.callTool.bind(canvasVanilla),
122
+ get multiClient() { return canvasVanilla.multiClient; },
121
123
 
122
124
  // HyperSkill
123
125
  buildSkillJSON: canvasVanilla.buildSkillJSON.bind(canvasVanilla),
@@ -5,23 +5,21 @@
5
5
  * Reactivity: subscribe(fn) / getSnapshot() pattern (useSyncExternalStore compatible).
6
6
  *
7
7
  * ---------------------------------------------------------------------------
8
- * Unified server model (2026-04-23 debloat)
8
+ * Unified server model
9
9
  * ---------------------------------------------------------------------------
10
- *
11
- * Historically this store had TWO parallel surfaces for MCP servers:
12
- * - `mcpUrl` / `mcpName` / `mcpConnected` / `mcpConnecting` / `mcpTools`
13
- * (flat, single-server or comma-joined multi) driven by legacy
14
- * `setMcpConnected`/`setMcpConnecting`/`setMcpError` setters.
15
- * - `dataServers: DataServer[]` (list, managed by MultiMcpBridge)
16
- *
17
- * They were the same concept. The legacy setters and the `primary` flag have
18
- * been removed; writes go through `addDataServer` / `setDataServerMeta` /
19
- * `setDataServerEnabled` / `addMcpServer` (url shorthand) exclusively. Flat
20
- * getters (`mcpConnected`, `mcpName`, `mcpUrl`, `mcpConnecting`, `mcpTools`)
21
- * remain as read-only derivations over the server list for UI back-compat.
10
+ * `dataServers: DataServer[]` is the single source of truth for MCP servers.
11
+ * The store owns an internal McpMultiClient and reconciles real connections
12
+ * with user intent (`enabled`) on every mutation. Writes go through
13
+ * `addDataServer` / `setDataServerMeta` / `setDataServerEnabled` /
14
+ * `addMcpServer` (url shorthand) exclusively. Flat getters
15
+ * (`mcpConnected`, `mcpName`, `mcpUrl`, `mcpConnecting`, `mcpTools`) remain
16
+ * as read-only derivations over the server list for UI back-compat.
22
17
  */
23
18
 
24
19
  import { encode, decode } from '../hyperskills.js';
20
+ import { REMOTE_MCP_REGISTRY } from '../remote-mcp-registry.js';
21
+ import { McpMultiClient } from '@webmcp-auto-ui/core';
22
+ import type { McpTool } from '@webmcp-auto-ui/core';
25
23
 
26
24
  export type WidgetType =
27
25
  | 'stat' | 'kv' | 'list' | 'chart' | 'alert' | 'code' | 'text' | 'actions' | 'tags'
@@ -60,11 +58,15 @@ export interface McpToolInfo {
60
58
 
61
59
  /**
62
60
  * Single MCP server entry — the one true shape.
63
- * All servers are equal; there is no "primary" concept. The MultiMcpBridge
64
- * singleton reconciles connection state across all entries.
61
+ * All servers are equal; there is no "primary" concept. The store's internal
62
+ * sync reconciles real connection state with `enabled` on every mutation.
65
63
  */
66
64
  export interface DataServer {
67
- name: string; // user-chosen label
65
+ /** Stable identity key. Convention aligned with WEBMCP_SERVER_REGISTRY:
66
+ * registry id when matched (e.g. 'wikipedia'), or 'manual-<hash>' for
67
+ * arbitrary URLs. Replaces the previous URL-host derivation, which
68
+ * collided across servers sharing a host (e.g. demos.hyperskills.net). */
69
+ name: string;
68
70
  url: string;
69
71
  kind: 'data'; // legacy field, kept for schema stability
70
72
  enabled: boolean; // user intent
@@ -73,8 +75,11 @@ export interface DataServer {
73
75
  tools?: McpToolInfo[];
74
76
  recipes?: { name: string; description?: string; body?: string }[];
75
77
  error?: string;
76
- /** Real server name from MCP handshake (initResult.serverInfo.name, aliased).
77
- * Canvas `.name` is URL-host; `.serverName` is what to display. */
78
+ /** Display label (registry label, or URL host for manual entries). */
79
+ label?: string;
80
+ /** Optional auth headers passed to the MCP transport on handshake. */
81
+ headers?: Record<string, string>;
82
+ /** Real server name from MCP handshake (initResult.serverInfo.name, aliased). */
78
83
  serverName?: string;
79
84
  }
80
85
 
@@ -103,6 +108,50 @@ type Listener = () => void;
103
108
  function uuid() { return 'w_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5); }
104
109
  function msgId() { return 'm_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5); }
105
110
 
111
+ /** Deterministic short hash for synthesizing stable ids from URLs. */
112
+ function stableHash(s: string): string {
113
+ let h = 5381;
114
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
115
+ return (h >>> 0).toString(36);
116
+ }
117
+
118
+ /** Extract `{ name, description?, body? }` items from an MCP tool response.
119
+ * Looks for a text chunk whose payload parses as JSON and contains either an
120
+ * array or an object with an `items`/`recipes` array. */
121
+ function parseRecipesFromToolResponse(res: unknown): { name: string; description?: string; body?: string }[] | null {
122
+ if (!res || typeof res !== 'object') return null;
123
+ const content = (res as { content?: unknown }).content;
124
+ if (!Array.isArray(content)) return null;
125
+ for (const chunk of content) {
126
+ if (!chunk || typeof chunk !== 'object') continue;
127
+ const c = chunk as { type?: unknown; text?: unknown };
128
+ if (c.type !== 'text' || typeof c.text !== 'string') continue;
129
+ let parsed: unknown;
130
+ try { parsed = JSON.parse(c.text); } catch { continue; }
131
+ const candidate = Array.isArray(parsed)
132
+ ? parsed
133
+ : Array.isArray((parsed as { items?: unknown })?.items)
134
+ ? (parsed as { items: unknown[] }).items
135
+ : Array.isArray((parsed as { recipes?: unknown })?.recipes)
136
+ ? (parsed as { recipes: unknown[] }).recipes
137
+ : null;
138
+ if (!candidate) continue;
139
+ const out: { name: string; description?: string; body?: string }[] = [];
140
+ for (const it of candidate) {
141
+ if (!it || typeof it !== 'object') continue;
142
+ const o = it as { name?: unknown; id?: unknown; description?: unknown; body?: unknown };
143
+ const name = typeof o.name === 'string' ? o.name : typeof o.id === 'string' ? o.id : null;
144
+ if (!name) continue;
145
+ const entry: { name: string; description?: string; body?: string } = { name };
146
+ if (typeof o.description === 'string') entry.description = o.description;
147
+ if (typeof o.body === 'string') entry.body = o.body;
148
+ out.push(entry);
149
+ }
150
+ if (out.length > 0) return out;
151
+ }
152
+ return null;
153
+ }
154
+
106
155
  const NAME_ALIAS: Record<string, string> = { 'moulineuse': 'Tricoteuses' };
107
156
  function aliasName(n: string): string { return NAME_ALIAS[n] ?? n; }
108
157
 
@@ -158,7 +207,7 @@ function createCanvasVanilla() {
158
207
  }
159
208
 
160
209
  // ── Server list actions (public, stable) ───────────────────────────────
161
- function addDataServer(desc: { name: string; url: string }): DataServer {
210
+ function addDataServer(desc: { name: string; url: string; label?: string; headers?: Record<string, string> }): DataServer {
162
211
  const existing = _servers.find((s) => s.name === desc.name);
163
212
  if (existing) return existing;
164
213
  const srv: DataServer = {
@@ -167,6 +216,8 @@ function createCanvasVanilla() {
167
216
  kind: 'data',
168
217
  enabled: true,
169
218
  connected: false,
219
+ ...(desc.label ? { label: desc.label } : {}),
220
+ ...(desc.headers ? { headers: desc.headers } : {}),
170
221
  };
171
222
  _servers = [..._servers, srv];
172
223
  notify();
@@ -174,15 +225,21 @@ function createCanvasVanilla() {
174
225
  }
175
226
 
176
227
  /**
177
- * Add a server by URL alone (derives name from the URL host). Returns the
178
- * canvas name so callers can reference it later.
228
+ * Add a server by URL. Resolves identity from REMOTE_MCP_REGISTRY (by URL):
229
+ * matched entries get the registry `id` as canvas key and `label` as display;
230
+ * unmatched URLs get a stable synthetic id. Returns the canvas name (= id).
179
231
  */
180
- function addMcpServer(url: string): string {
232
+ function addMcpServer(url: string, opts?: { headers?: Record<string, string> }): string {
181
233
  if (!url) return '';
182
- let name: string;
183
- try { name = new URL(url, 'http://local').host || url; }
184
- catch { name = url; }
185
- addDataServer({ name, url });
234
+ const reg = REMOTE_MCP_REGISTRY.find((e) => e.url === url);
235
+ const name = reg?.id ?? `manual-${stableHash(url)}`;
236
+ let label = reg?.label;
237
+ if (!label) {
238
+ try { label = new URL(url, 'http://local').host || url; }
239
+ catch { label = url; }
240
+ }
241
+ const headers = opts?.headers ?? reg?.headers;
242
+ addDataServer({ name, url, label, headers });
186
243
  setDataServerEnabled(name, true);
187
244
  return name;
188
245
  }
@@ -220,6 +277,96 @@ function createCanvasVanilla() {
220
277
  return setDataServerEnabled(name, !s.enabled);
221
278
  }
222
279
 
280
+ // ── Internal MCP transport + sync ──────────────────────────────────────
281
+ const multiClient = new McpMultiClient();
282
+ const inFlight = new Set<string>();
283
+
284
+ async function syncServer(name: string): Promise<void> {
285
+ const srv = _servers.find((s) => s.name === name);
286
+ if (!srv) return;
287
+ if (inFlight.has(name)) return;
288
+
289
+ const liveUrls = new Set(multiClient.listServers().map((s) => s.url));
290
+
291
+ if (srv.enabled && !srv.connected && !liveUrls.has(srv.url)) {
292
+ inFlight.add(name);
293
+ try {
294
+ const opts = srv.headers ? { headers: srv.headers } : undefined;
295
+ const { name: actualName, tools } = await multiClient.addServer(srv.url, opts);
296
+ let recipes: { name: string; description?: string; body?: string }[] = [];
297
+ if (tools.some((t: McpTool) => t.name === 'list_recipes')) {
298
+ try {
299
+ const res = await multiClient.callToolOn(srv.url, 'list_recipes', {});
300
+ recipes = parseRecipesFromToolResponse(res) ?? [];
301
+ } catch { /* no recipes */ }
302
+ }
303
+ // Prefetch full bodies in parallel so apps can render markdown / run
304
+ // code blocks without re-calling get_recipe per surface.
305
+ if (recipes.length && tools.some((t: McpTool) => t.name === 'get_recipe')) {
306
+ recipes = await Promise.all(recipes.map(async (r) => {
307
+ try {
308
+ const res = await multiClient.callToolOn(srv.url, 'get_recipe', { name: r.name, id: r.name });
309
+ const text = (res as { content?: { type?: string; text?: string }[] })
310
+ .content?.find((c) => c?.type === 'text')?.text;
311
+ if (!text) return r;
312
+ let body = text;
313
+ try {
314
+ const p = JSON.parse(text);
315
+ if (p && typeof p === 'object' && typeof (p as { content?: unknown }).content === 'string') {
316
+ body = (p as { content: string }).content;
317
+ }
318
+ } catch { /* raw markdown */ }
319
+ return { ...r, body };
320
+ } catch { return r; }
321
+ }));
322
+ }
323
+ setDataServerMeta(name, {
324
+ connected: true, connecting: false,
325
+ tools: tools as McpToolInfo[],
326
+ recipes,
327
+ serverName: actualName,
328
+ error: undefined,
329
+ });
330
+ } catch (err) {
331
+ setDataServerMeta(name, {
332
+ connected: false, connecting: false,
333
+ tools: [],
334
+ error: err instanceof Error ? err.message : String(err),
335
+ });
336
+ } finally {
337
+ inFlight.delete(name);
338
+ }
339
+ } else if (!srv.enabled && (srv.connected || liveUrls.has(srv.url))) {
340
+ try { await multiClient.removeServer(srv.url); } catch { /* ignore */ }
341
+ setDataServerMeta(name, { connected: false });
342
+ }
343
+ }
344
+
345
+ function reconcile(): void {
346
+ for (const srv of _servers) void syncServer(srv.name);
347
+ // Drop transport entries for servers removed from the store entirely.
348
+ const storedUrls = new Set(_servers.map((s) => s.url));
349
+ for (const live of multiClient.listServers()) {
350
+ if (!storedUrls.has(live.url)) void multiClient.removeServer(live.url);
351
+ }
352
+ }
353
+
354
+ // Internal subscriber — fires reconcile() after every store mutation.
355
+ // The `inFlight` guard and the `srv.connected` checks prevent loops when
356
+ // syncServer itself triggers a notify via setDataServerMeta.
357
+ listeners.add(() => { reconcile(); });
358
+
359
+ /** Call a tool on a connected MCP server, addressed by canvas name (= id). */
360
+ async function callTool(
361
+ name: string,
362
+ toolName: string,
363
+ args: Record<string, unknown> = {},
364
+ ): Promise<unknown> {
365
+ const srv = _servers.find((s) => s.name === name);
366
+ if (!srv) throw new Error(`canvas.callTool: no server "${name}"`);
367
+ return multiClient.callToolOn(srv.url, toolName, args);
368
+ }
369
+
223
370
  // ── Widget actions ─────────────────────────────────────────────────────
224
371
  function addWidget(type: WidgetType, data: Record<string, unknown> = {}): Widget {
225
372
  const widget: Widget = { id: uuid(), type, data };
@@ -428,6 +575,8 @@ function createCanvasVanilla() {
428
575
  setDataServerMeta,
429
576
  setDataServerEnabled,
430
577
  toggleDataServer,
578
+ callTool,
579
+ get multiClient() { return multiClient; },
431
580
 
432
581
  buildSkillJSON, buildHyperskillParam, loadFromParam, loadFromUrl,
433
582