@webmcp-auto-ui/sdk 2.5.36 → 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 +2 -2
- package/src/index.ts +3 -3
- package/src/recipes/runner.ts +241 -6
- package/src/recipes/types.ts +12 -0
- package/src/{mcp-demo-servers.ts → remote-mcp-registry.ts} +17 -18
- package/src/stores/canvas.svelte.ts +2 -0
- package/src/stores/canvas.ts +175 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webmcp-auto-ui/sdk",
|
|
3
|
-
"version": "2.5.
|
|
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,9 +83,9 @@ export {
|
|
|
83
83
|
} from './skills/registry.js';
|
|
84
84
|
export type { Skill, SkillBlock } from './skills/registry.js';
|
|
85
85
|
|
|
86
|
-
// MCP
|
|
87
|
-
export {
|
|
88
|
-
export type {
|
|
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'
|
package/src/recipes/runner.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
}
|
|
@@ -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);
|
package/src/recipes/types.ts
CHANGED
|
@@ -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
|
|
2
|
-
//
|
|
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
|
|
6
|
+
export interface RemoteMcpRegistryEntry {
|
|
5
7
|
id: string;
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
package/src/stores/canvas.ts
CHANGED
|
@@ -5,23 +5,21 @@
|
|
|
5
5
|
* Reactivity: subscribe(fn) / getSnapshot() pattern (useSyncExternalStore compatible).
|
|
6
6
|
*
|
|
7
7
|
* ---------------------------------------------------------------------------
|
|
8
|
-
* Unified server model
|
|
8
|
+
* Unified server model
|
|
9
9
|
* ---------------------------------------------------------------------------
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
64
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
77
|
-
|
|
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
|
|
178
|
-
*
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|