@webmcp-auto-ui/ui 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.
@@ -4,7 +4,7 @@
4
4
  // Used by the four notebook layout renderers (compact/workspace/document/editorial)
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
- import { installMultiMcpBridge } from '@webmcp-auto-ui/core';
7
+ import { serializeToMarkdown } from './share-handlers.js';
8
8
 
9
9
  export const NB_PUBLISH_HOST: string = (() => {
10
10
  try {
@@ -187,6 +187,8 @@ export function createState(initial?: Partial<NotebookState>): NotebookState {
187
187
  executors: initial?.executors ?? {},
188
188
  lastEditAt: initial?.lastEditAt ?? Date.now(),
189
189
  autoRun: initial?.autoRun ?? false,
190
+ publishedSlug: initial?.publishedSlug,
191
+ publishedToken: initial?.publishedToken,
190
192
  };
191
193
  }
192
194
 
@@ -260,9 +262,9 @@ export function cellRuntimeStatus(cell: NotebookCell, overlay: RuntimeOverlay |
260
262
  return 'idle';
261
263
  }
262
264
 
263
- /** Live-mode whitelist. Only SQL cells are re-executable publicly. */
265
+ /** Live-mode whitelist. SQL and JS cells are re-executable; markdown stays frozen. */
264
266
  export function isReRunnable(cell: NotebookCell): boolean {
265
- return cell.type === 'sql';
267
+ return cell.type === 'sql' || cell.type === 'js';
266
268
  }
267
269
 
268
270
  /**
@@ -348,22 +350,17 @@ export function lastRefreshedAt(overlay: RuntimeOverlay | null | undefined): num
348
350
  }
349
351
 
350
352
  /**
351
- * Build a CellRunner backed by a MultiMcpBridge. Discovers a SQL-capable tool
352
- * on the connected servers (matching `*_query_sql` then `query|run|execute`),
353
- * calls it with `{ sql: cell.content }`, parses content-array into a table.
353
+ * Build a CellRunner that issues SQL via `callTool`. Discovers a SQL-capable
354
+ * tool on the connected servers (matching `*_query_sql` then
355
+ * `query|run|execute`), calls it with `{ sql: cell.content }`, parses the
356
+ * content-array into a table.
354
357
  *
355
- * Throws if no server is reachable / no SQL tool found. Callers are expected
356
- * to surface this as a 'stale' status, not crash.
358
+ * Returns a runtime error result if no server is reachable / no SQL tool found.
357
359
  */
358
- export function createBridgeSqlRunner(bridge: {
359
- hasServer?: (name: string) => boolean;
360
- connectedServers?: () => string[];
361
- multiClient: {
362
- listTools?: (url: string) => Promise<{ name: string }[]>;
363
- getToolsForUrl?: (url: string) => { name: string }[];
364
- };
365
- callTool: (serverName: string, toolName: string, args: unknown) => Promise<unknown>;
366
- }, getServerDescriptors: () => DataServerDescriptor[]): CellRunner {
360
+ export function createBridgeSqlRunner(
361
+ callTool: (serverName: string, toolName: string, args: unknown) => Promise<unknown>,
362
+ getServerDescriptors: () => DataServerDescriptor[],
363
+ ): CellRunner {
367
364
  const PATTERN_PRIMARY = /^.*query_sql$/i;
368
365
  const PATTERN_FALLBACK = /^(query|run|execute)(_sql)?$/i;
369
366
 
@@ -423,20 +420,18 @@ export function createBridgeSqlRunner(bridge: {
423
420
  if (!hit) {
424
421
  return { ok: false, error: 'No SQL tool exposed by reachable servers', errorKind: 'schema', durationMs: 0 };
425
422
  }
426
- const raw = await bridge.callTool(hit.serverName, hit.toolName, { sql: cell.content });
423
+ const raw = await callTool(hit.serverName, hit.toolName, { sql: cell.content });
427
424
  return parseResult(raw, startedAt);
428
425
  };
429
426
  }
430
427
 
431
428
  /**
432
- * High-level bootstrap: auto-connect declared servers, wait for handshake, build
433
- * a bridge-backed runner, fire runAutoRefresh. Safe to call from any layout at
434
- * mount time when `state.autoRun && state.mode === 'view'`. Returns a cleanup.
429
+ * High-level bootstrap: auto-connect declared servers, wait for handshake,
430
+ * build a SQL runner, fire runAutoRefresh. Safe to call from any layout at
431
+ * mount time when `state.autoRun && state.mode === 'view'`. Returns cleanup.
435
432
  *
436
- * Reuses the global singleton bridge on `globalThis.__multiMcp` (installed via
437
- * `installMultiMcpBridge` from @webmcp-auto-ui/core) never creates a parallel
438
- * bridge. If no singleton exists yet we install it here, and only the installer
439
- * is allowed to stop it on cleanup.
433
+ * Drives connection state through the canvas store; the store's internal
434
+ * sync owns the McpMultiClient and reconciles handshakes on every mutation.
440
435
  */
441
436
  export interface BootstrapLiveRefreshOptions {
442
437
  state: NotebookState;
@@ -447,36 +442,54 @@ export interface BootstrapLiveRefreshOptions {
447
442
  timeoutMs?: number;
448
443
  }
449
444
 
445
+ interface CanvasLike {
446
+ dataServers: { name: string; url: string; enabled?: boolean; connected?: boolean }[];
447
+ callTool: (name: string, toolName: string, args: unknown) => Promise<unknown>;
448
+ }
449
+
450
+ async function waitForEnabledServers(canvas: CanvasLike, timeoutMs: number): Promise<void> {
451
+ const deadline = Date.now() + Math.max(0, timeoutMs);
452
+ while (Date.now() < deadline) {
453
+ const enabled = (canvas.dataServers ?? []).filter((s) => s.enabled !== false);
454
+ if (enabled.length === 0) return;
455
+ if (enabled.every((s) => s.connected)) return;
456
+ await new Promise((r) => setTimeout(r, 100));
457
+ }
458
+ }
459
+
450
460
  export function bootstrapLiveRefresh(opts: BootstrapLiveRefreshOptions): () => void {
451
461
  const { state, data, overlay, onCellChange, onTick, timeoutMs } = opts;
452
462
  const ac = new AbortController();
453
- let weCreatedBridge = false;
454
- let bridgeRef: any = null;
455
463
 
456
464
  void (async () => {
457
465
  try {
458
466
  autoConnectFrontmatterServers(data);
459
- const canvas: any = (globalThis as { __canvasVanilla?: unknown; canvasVanilla?: unknown })
460
- .__canvasVanilla ?? (globalThis as { canvasVanilla?: unknown }).canvasVanilla;
467
+ const canvas = ((globalThis as { __canvasVanilla?: CanvasLike; canvasVanilla?: CanvasLike })
468
+ .__canvasVanilla ?? (globalThis as { canvasVanilla?: CanvasLike }).canvasVanilla) as CanvasLike | undefined;
461
469
  if (!canvas) {
462
470
  overlay.error = 'No canvas available';
463
471
  overlay.finishedAt = Date.now();
464
472
  onTick?.(overlay);
465
473
  return;
466
474
  }
467
- const existing = (globalThis as any).__multiMcp;
468
- const bridge = existing ?? installMultiMcpBridge({ getCanvas: () => canvas });
469
- weCreatedBridge = !existing;
470
- bridgeRef = bridge;
471
- // `installMultiMcpBridge` already starts the bridge; a pre-existing
472
- // singleton is assumed to be running.
473
- await bridge.waitForEnabledServers(timeoutMs ?? 5000);
474
-
475
- const runner = createBridgeSqlRunner(bridge, () => {
476
- // filter collectDataServers to only connected ones
477
- const all = collectDataServers(data);
478
- return all.filter((s) => bridge.hasServer(s.name));
479
- });
475
+ await waitForEnabledServers(canvas, timeoutMs ?? 5000);
476
+
477
+ const sqlRunner = createBridgeSqlRunner(
478
+ canvas.callTool.bind(canvas),
479
+ () => collectDataServers(data).filter((s) => canvas.dataServers.find((d) => d.name === s.name)?.connected),
480
+ );
481
+
482
+ const runner: CellRunner = async (cell, signal) => {
483
+ if (cell.type === 'sql') return sqlRunner(cell, signal);
484
+ if (cell.type === 'js') {
485
+ const exec = state.executors?.js;
486
+ if (!exec) {
487
+ return { ok: false, error: 'No JS executor registered', errorKind: 'runtime', durationMs: 0 };
488
+ }
489
+ return exec({ cell, state, scope: state.scope, signal });
490
+ }
491
+ return { ok: false, error: `Cell type '${cell.type}' is not re-runnable`, errorKind: 'runtime', durationMs: 0 };
492
+ };
480
493
 
481
494
  await runAutoRefresh({ state, overlay, runner, onCellChange, onTick, signal: ac.signal });
482
495
  } catch (err) {
@@ -486,16 +499,7 @@ export function bootstrapLiveRefresh(opts: BootstrapLiveRefreshOptions): () => v
486
499
  }
487
500
  })();
488
501
 
489
- return () => {
490
- ac.abort();
491
- if (weCreatedBridge && bridgeRef && typeof bridgeRef.stop === 'function') {
492
- try { bridgeRef.stop(); } catch { /* ignore */ }
493
- try {
494
- const g: any = globalThis as any;
495
- if (g.__multiMcp === bridgeRef) g.__multiMcp = undefined;
496
- } catch { /* ignore */ }
497
- }
498
- };
502
+ return () => { ac.abort(); };
499
503
  }
500
504
 
501
505
  export function registerExecutor(state: NotebookState, type: CellType, fn: CellExecutor): void {
@@ -672,7 +676,12 @@ export interface DataServerRecipe {
672
676
  }
673
677
 
674
678
  export interface DataServerDescriptor {
679
+ /** Canvas key (= registry id, e.g. 'wikipedia'). Stable identity used for routing. */
675
680
  name: string;
681
+ /** Display label (registry label, or URL host for manual entries). */
682
+ label?: string;
683
+ /** Real server name from MCP handshake (initResult.serverInfo.name, aliased). */
684
+ serverName?: string;
676
685
  url?: string;
677
686
  kind?: string;
678
687
  tools?: DataServerTool[];
@@ -704,6 +713,8 @@ export function collectDataServers(data: Record<string, unknown>): DataServerDes
704
713
  .filter((s) => !isUiServer(String(s?.name ?? ''), s?.kind))
705
714
  .map((s) => ({
706
715
  name: String(s.name),
716
+ label: typeof s.label === 'string' ? s.label : undefined,
717
+ serverName: typeof s.serverName === 'string' ? s.serverName : undefined,
707
718
  url: s.url ? String(s.url) : undefined,
708
719
  recipes: Array.isArray(s.recipes) ? s.recipes : [],
709
720
  tools: Array.isArray(s.tools) ? s.tools : [],
@@ -715,6 +726,8 @@ export function collectDataServers(data: Record<string, unknown>): DataServerDes
715
726
  .filter((s) => s?.name && !isUiServer(String(s.name), s?.kind))
716
727
  .map((s) => ({
717
728
  name: String(s.name),
729
+ label: typeof s.label === 'string' ? s.label : undefined,
730
+ serverName: typeof s.serverName === 'string' ? s.serverName : undefined,
718
731
  url: s.url ? String(s.url) : undefined,
719
732
  recipes: Array.isArray(s.recipes) ? s.recipes : [],
720
733
  tools: Array.isArray(s.tools) ? s.tools : [],
@@ -753,7 +766,7 @@ function publishUrlFor(slug: string): string {
753
766
  }
754
767
 
755
768
  function publishBtnLabel(state: NotebookState): string {
756
- return state.publishedSlug ? '🔄 update' : '📤 publish';
769
+ return state.publishedSlug ? '💾 save' : '📤 publish';
757
770
  }
758
771
 
759
772
  function refreshPublishControls(state: NotebookState, controls: PublishControlsHandles): void {
@@ -761,7 +774,7 @@ function refreshPublishControls(state: NotebookState, controls: PublishControlsH
761
774
  btn.textContent = publishBtnLabel(state);
762
775
  btn.dataset.state = state.publishedSlug ? 'published' : 'draft';
763
776
  if (state.publishedSlug) {
764
- btn.title = `Update ${publishUrlFor(state.publishedSlug)}`;
777
+ btn.title = `Save changes to ${publishUrlFor(state.publishedSlug)}`;
765
778
  } else {
766
779
  btn.title = 'Publish this notebook';
767
780
  }
@@ -835,21 +848,16 @@ export function createPublishControls(state: NotebookState, opts: PublishControl
835
848
  const onClick = async () => {
836
849
  const prevLabel = btn.textContent ?? '';
837
850
  btn.disabled = true;
838
- btn.textContent = state.publishedSlug ? '… updating' : '… publishing';
851
+ btn.textContent = state.publishedSlug ? '… saving' : '… publishing';
839
852
  try {
840
- const minimal = opts.serializeState
841
- ? opts.serializeState(state)
842
- : {
843
- id: state.id,
844
- title: state.title,
845
- mode: state.mode,
846
- cells: state.cells,
847
- };
853
+ // HyperSkill standalone markdown — frontmatter (title/description/servers)
854
+ // + body with fenced cells. Re-parsable via parseFrontmatter + parseBody.
855
+ const markdown = serializeToMarkdown(state);
848
856
  const res = await fetch(`${NB_PUBLISH_HOST}/api/publish`, {
849
857
  method: 'POST',
850
858
  headers: { 'content-type': 'application/json' },
851
859
  body: JSON.stringify({
852
- state: minimal,
860
+ markdown,
853
861
  slug: state.publishedSlug,
854
862
  token: state.publishedToken,
855
863
  }),
@@ -859,15 +867,19 @@ export function createPublishControls(state: NotebookState, opts: PublishControl
859
867
  state.publishedSlug = reply.slug;
860
868
  state.publishedToken = reply.token;
861
869
  state.lastEditAt = Date.now();
862
- const url: string = reply.url ?? publishUrlFor(String(reply.slug));
863
- try { await navigator.clipboard?.writeText?.(url); } catch { /* ignore */ }
870
+ const baseUrl: string = reply.url ?? publishUrlFor(String(reply.slug));
871
+ // Author URL embeds the token via `?t=` so the viewer can hydrate it
872
+ // (one-shot — the viewer extracts it into localStorage and cleans the
873
+ // URL). Public visitors share the bare URL without ?t.
874
+ const authorUrl = `${baseUrl}?t=${encodeURIComponent(String(reply.token))}`;
875
+ try { await navigator.clipboard?.writeText?.(authorUrl); } catch { /* ignore */ }
864
876
  const updated = Boolean(reply.updated);
865
877
  toast(
866
878
  updated
867
- ? `updated · ${url.replace(/^https?:\/\//, '')} (copied)`
868
- : `published · ${url.replace(/^https?:\/\//, '')} (copied)`
879
+ ? `saved · ${baseUrl.replace(/^https?:\/\//, '')} (author link copied)`
880
+ : `published · ${baseUrl.replace(/^https?:\/\//, '')} (author link copied)`
869
881
  );
870
- opts.onPublished?.({ slug: String(reply.slug), url, updated });
882
+ opts.onPublished?.({ slug: String(reply.slug), url: baseUrl, updated });
871
883
  } catch (err: any) {
872
884
  toast(`publish failed · ${String(err?.message ?? err)}`, true);
873
885
  btn.textContent = prevLabel;