@webmcp-auto-ui/ui 2.5.31 → 2.5.33

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.
Files changed (80) hide show
  1. package/package.json +15 -2
  2. package/src/agent/DiagnosticModal.svelte +126 -50
  3. package/src/agent/EphemeralBubble.svelte +13 -3
  4. package/src/agent/MCPserversList.svelte +147 -0
  5. package/src/agent/McpConnector.svelte +10 -1
  6. package/src/agent/RecipeBrowser.svelte +384 -0
  7. package/src/agent/RemoteMCPserversDemo.svelte +5 -121
  8. package/src/agent/ToolBrowser.svelte +133 -0
  9. package/src/agent/WebMCPserversList.svelte +2 -0
  10. package/src/agent/useAgentLoop.svelte.ts +396 -0
  11. package/src/base/chat-inline.svelte +64 -0
  12. package/src/base/dialog-content.svelte +3 -1
  13. package/src/components/HeaderControls.svelte +78 -0
  14. package/src/index.ts +13 -35
  15. package/src/stores/canvas.svelte.ts +0 -6
  16. package/src/widgets/SafeImage.svelte +67 -0
  17. package/src/widgets/WidgetRenderer.svelte +153 -78
  18. package/src/widgets/notebook/executors/index.ts +0 -1
  19. package/src/widgets/notebook/executors/sql.ts +32 -182
  20. package/src/widgets/notebook/import-modal-api.ts +237 -0
  21. package/src/widgets/notebook/import-modal.svelte +738 -0
  22. package/src/widgets/notebook/left-pane.ts +1 -1
  23. package/src/widgets/notebook/notebook.svelte +75 -0
  24. package/src/widgets/notebook/notebook.ts +38 -73
  25. package/src/widgets/notebook/prose.ts +6 -3
  26. package/src/widgets/notebook/shared.ts +68 -49
  27. package/src/widgets/rich/cards.svelte +74 -0
  28. package/src/widgets/rich/carousel.svelte +126 -0
  29. package/src/widgets/rich/chart-rich.svelte +221 -0
  30. package/src/widgets/rich/chat-input.svelte +52 -0
  31. package/src/widgets/rich/data-table.svelte +132 -0
  32. package/src/widgets/rich/gallery.svelte +115 -0
  33. package/src/widgets/rich/grid-data.svelte +85 -0
  34. package/src/widgets/rich/hemicycle.svelte +95 -0
  35. package/src/widgets/rich/js-sandbox.svelte +67 -0
  36. package/src/widgets/rich/json-viewer.svelte +82 -0
  37. package/src/widgets/rich/log.svelte +62 -0
  38. package/src/widgets/rich/profile.svelte +91 -0
  39. package/src/widgets/rich/sankey.svelte +73 -0
  40. package/src/widgets/rich/stat-card.svelte +60 -0
  41. package/src/widgets/rich/timeline.svelte +95 -0
  42. package/src/widgets/rich/trombinoscope.svelte +87 -0
  43. package/src/widgets/simple/actions.svelte +36 -0
  44. package/src/widgets/simple/alert.svelte +52 -0
  45. package/src/widgets/simple/chart.svelte +38 -0
  46. package/src/widgets/simple/code.svelte +30 -0
  47. package/src/widgets/simple/kv.svelte +31 -0
  48. package/src/widgets/simple/list.svelte +35 -0
  49. package/src/widgets/simple/stat.svelte +36 -0
  50. package/src/widgets/simple/tags.svelte +34 -0
  51. package/src/widgets/simple/text.svelte +130 -0
  52. package/src/widgets/helpers/safe-image.ts +0 -78
  53. package/src/widgets/notebook/import-modals.ts +0 -560
  54. package/src/widgets/notebook/recipe-browser.ts +0 -350
  55. package/src/widgets/rich/cards.ts +0 -181
  56. package/src/widgets/rich/carousel.ts +0 -319
  57. package/src/widgets/rich/chart-rich.ts +0 -386
  58. package/src/widgets/rich/d3.ts +0 -503
  59. package/src/widgets/rich/data-table.ts +0 -342
  60. package/src/widgets/rich/gallery.ts +0 -350
  61. package/src/widgets/rich/grid-data.ts +0 -173
  62. package/src/widgets/rich/hemicycle.ts +0 -313
  63. package/src/widgets/rich/js-sandbox.ts +0 -122
  64. package/src/widgets/rich/json-viewer.ts +0 -202
  65. package/src/widgets/rich/log.ts +0 -143
  66. package/src/widgets/rich/map.ts +0 -218
  67. package/src/widgets/rich/profile.ts +0 -256
  68. package/src/widgets/rich/sankey.ts +0 -257
  69. package/src/widgets/rich/stat-card.ts +0 -125
  70. package/src/widgets/rich/timeline.ts +0 -179
  71. package/src/widgets/rich/trombinoscope.ts +0 -246
  72. package/src/widgets/simple/actions.ts +0 -89
  73. package/src/widgets/simple/alert.ts +0 -100
  74. package/src/widgets/simple/chart.ts +0 -189
  75. package/src/widgets/simple/code.ts +0 -79
  76. package/src/widgets/simple/kv.ts +0 -68
  77. package/src/widgets/simple/list.ts +0 -89
  78. package/src/widgets/simple/stat.ts +0 -58
  79. package/src/widgets/simple/tags.ts +0 -125
  80. package/src/widgets/simple/text.ts +0 -198
@@ -0,0 +1,67 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SafeImage — URL validation + error fallback. Replaces raw <img> in widgets.
4
+ */
5
+ interface Props {
6
+ src: string | undefined | null;
7
+ alt?: string;
8
+ class?: string;
9
+ style?: string;
10
+ loading?: 'lazy' | 'eager';
11
+ fallbackText?: string;
12
+ hideOnError?: boolean;
13
+ }
14
+
15
+ let { src, alt = '', class: className = '', style = '', loading = 'lazy', fallbackText, hideOnError = false }: Props = $props();
16
+
17
+ const VALID_PREFIXES = ['http://', 'https://', 'data:', '/'];
18
+
19
+ const isValidUrl = $derived(
20
+ typeof src === 'string' && src.length > 0 && VALID_PREFIXES.some(p => src!.startsWith(p))
21
+ );
22
+
23
+ let hasError = $state(false);
24
+ let isLoaded = $state(false);
25
+
26
+ $effect(() => {
27
+ if (src) {
28
+ hasError = false;
29
+ isLoaded = false;
30
+ }
31
+ });
32
+
33
+ function onError() { hasError = true; }
34
+ function onLoad() { isLoaded = true; }
35
+
36
+ const showPlaceholder = $derived(!isValidUrl || hasError);
37
+ const label = $derived(fallbackText ?? alt ?? 'Image');
38
+ </script>
39
+
40
+ {#if showPlaceholder}
41
+ {#if !hideOnError}
42
+ <div
43
+ class="flex items-center justify-center bg-surface2 text-text2 text-xs {className}"
44
+ {style}
45
+ role="img"
46
+ aria-label={label}
47
+ >
48
+ <svg class="w-5 h-5 opacity-40 mr-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
49
+ <rect x="3" y="3" width="18" height="18" rx="2" />
50
+ <circle cx="8.5" cy="8.5" r="1.5" />
51
+ <path d="M21 15l-5-5L5 21" />
52
+ </svg>
53
+ <span class="truncate max-w-[80%]">{label}</span>
54
+ </div>
55
+ {/if}
56
+ {:else}
57
+ <img
58
+ {src}
59
+ {alt}
60
+ class={className}
61
+ {style}
62
+ {loading}
63
+ onerror={onError}
64
+ onload={onLoad}
65
+ referrerpolicy="no-referrer"
66
+ />
67
+ {/if}
@@ -13,84 +13,89 @@
13
13
  }
14
14
 
15
15
  // Safe clone for data passed to vanilla renderers — strips Svelte 5 $state
16
- // proxies without crashing on BigInt / circular refs / undefined values.
16
+ // proxies, then falls back to a JSON pass that drops non-serializable values.
17
17
  function safeClone<T>(value: T): T {
18
18
  if (value === null || typeof value !== 'object') return value;
19
19
  try {
20
20
  return structuredClone(value);
21
- } catch (err) {
22
- console.warn('[WidgetRenderer] structuredClone failed, falling back to shallow copy', err);
23
- return { ...(value as Record<string, unknown>) } as T;
21
+ } catch {
22
+ try {
23
+ const seen = new WeakSet<object>();
24
+ return JSON.parse(JSON.stringify(value, (_k, v) => {
25
+ if (v === null || v === undefined) return v;
26
+ const t = typeof v;
27
+ if (t === 'function' || t === 'symbol' || t === 'bigint') return undefined;
28
+ if (t !== 'object') return v;
29
+ if (typeof Node !== 'undefined' && v instanceof Node) return undefined;
30
+ if (typeof Window !== 'undefined' && v instanceof Window) return undefined;
31
+ if (typeof Event !== 'undefined' && v instanceof Event) return undefined;
32
+ if (seen.has(v)) return undefined;
33
+ seen.add(v);
34
+ return v;
35
+ })) as T;
36
+ } catch {
37
+ return value;
38
+ }
24
39
  }
25
40
  }
26
41
 
27
- // ── Native vanilla renderers ─────────────────────────────────────────────
28
- // Simple widgets
29
- import { render as renderStat } from './simple/stat.js';
30
- import { render as renderKv } from './simple/kv.js';
31
- import { render as renderList } from './simple/list.js';
32
- import { render as renderChart } from './simple/chart.js';
33
- import { render as renderAlert } from './simple/alert.js';
34
- import { render as renderCode } from './simple/code.js';
35
- import { render as renderText } from './simple/text.js';
36
- import { render as renderActions } from './simple/actions.js';
37
- import { render as renderTags } from './simple/tags.js';
38
- // Rich widgets
39
- import { render as renderStatCard } from './rich/stat-card.js';
40
- import { render as renderProfile } from './rich/profile.js';
41
- import { render as renderJsonViewer } from './rich/json-viewer.js';
42
- import { render as renderChartRich } from './rich/chart-rich.js';
43
- import { render as renderSankey } from './rich/sankey.js';
44
- import { render as renderHemicycle } from './rich/hemicycle.js';
45
- import { render as renderDataTable } from './rich/data-table.js';
46
- import { render as renderTimeline } from './rich/timeline.js';
47
- import { render as renderTrombinoscope } from './rich/trombinoscope.js';
48
- import { render as renderCards } from './rich/cards.js';
49
- import { render as renderGridData } from './rich/grid-data.js';
50
- import { render as renderMap } from './rich/map.js';
51
- import { render as renderD3 } from './rich/d3.js';
52
- import { render as renderJsSandbox } from './rich/js-sandbox.js';
53
- import { render as renderLog } from './rich/log.js';
54
- import { render as renderGallery } from './rich/gallery.js';
55
- import { render as renderCarousel } from './rich/carousel.js';
56
-
57
- /** A vanilla renderer: returns cleanup or Promise thereof. */
42
+ // ── Native custom-element widgets (Svelte 5 compiled) ────────────────────
43
+ // Side-effect imports register the custom elements with the browser.
44
+ // Simple (9)
45
+ import './simple/stat.svelte';
46
+ import './simple/kv.svelte';
47
+ import './simple/list.svelte';
48
+ import './simple/chart.svelte';
49
+ import './simple/alert.svelte';
50
+ import './simple/code.svelte';
51
+ import './simple/text.svelte';
52
+ import './simple/actions.svelte';
53
+ import './simple/tags.svelte';
54
+ // Rich (15) map and d3 intentionally omitted (see plan: handled by leaflet/d3 servers)
55
+ import './rich/stat-card.svelte';
56
+ import './rich/profile.svelte';
57
+ import './rich/json-viewer.svelte';
58
+ import './rich/chart-rich.svelte';
59
+ import './rich/sankey.svelte';
60
+ import './rich/hemicycle.svelte';
61
+ import './rich/data-table.svelte';
62
+ import './rich/timeline.svelte';
63
+ import './rich/trombinoscope.svelte';
64
+ import './rich/cards.svelte';
65
+ import './rich/grid-data.svelte';
66
+ import './rich/js-sandbox.svelte';
67
+ import './rich/log.svelte';
68
+ import './rich/gallery.svelte';
69
+ import './rich/carousel.svelte';
70
+ import './rich/chat-input.svelte';
71
+ // Notebook (1)
72
+ import './notebook/notebook.svelte';
73
+ // Agent browsers (registered as widgets for widget_display)
74
+ import '../agent/RecipeBrowser.svelte';
75
+
76
+ /** Native widget types served as custom elements (`<auto-${type}>`). */
77
+ const NATIVE_CUSTOM_ELEMENTS = new Set<string>([
78
+ // Simple
79
+ 'stat', 'kv', 'list', 'chart', 'alert', 'code', 'text', 'actions', 'tags',
80
+ // Rich
81
+ 'stat-card', 'profile', 'json-viewer', 'chart-rich', 'sankey', 'hemicycle',
82
+ 'data-table', 'timeline', 'trombinoscope', 'cards', 'grid-data',
83
+ 'js-sandbox', 'log', 'gallery', 'carousel', 'chat-input',
84
+ // Notebook
85
+ 'notebook',
86
+ // Agent browsers
87
+ 'recipe-browser',
88
+ ]);
89
+
90
+ /** A vanilla renderer: returns cleanup or Promise thereof. Still used for
91
+ * server-provided custom widgets via `widget.vanilla = true`. */
58
92
  type VanillaRenderer = (
59
93
  container: HTMLElement,
60
94
  data: Record<string, unknown>,
61
95
  ) => void | (() => void) | Promise<void | (() => void)>;
62
96
 
63
- /** Static map of all native widget types vanilla renderer */
64
- const NATIVE_VANILLA_MAP: Record<string, VanillaRenderer> = {
65
- // Simple
66
- 'stat': renderStat,
67
- 'kv': renderKv,
68
- 'list': renderList,
69
- 'chart': renderChart,
70
- 'alert': renderAlert,
71
- 'code': renderCode,
72
- 'text': renderText,
73
- 'actions': renderActions,
74
- 'tags': renderTags,
75
- // Rich
76
- 'stat-card': renderStatCard,
77
- 'profile': renderProfile,
78
- 'json-viewer': renderJsonViewer,
79
- 'chart-rich': renderChartRich,
80
- 'sankey': renderSankey,
81
- 'hemicycle': renderHemicycle,
82
- 'data-table': renderDataTable,
83
- 'timeline': renderTimeline,
84
- 'trombinoscope': renderTrombinoscope,
85
- 'cards': renderCards,
86
- 'grid-data': renderGridData,
87
- 'map': renderMap,
88
- 'd3': renderD3 as unknown as VanillaRenderer,
89
- 'js-sandbox': renderJsSandbox as unknown as VanillaRenderer,
90
- 'log': renderLog,
91
- 'gallery': renderGallery,
92
- 'carousel': renderCarousel,
93
- };
97
+ /** No native vanilla widgets remain — all native widgets are custom elements. */
98
+ const NATIVE_VANILLA_MAP: Record<string, VanillaRenderer> = {};
94
99
 
95
100
  interface Props {
96
101
  id?: string;
@@ -115,7 +120,7 @@
115
120
  bus.broadcast(busId, 'interact', { type, action, payload });
116
121
  }
117
122
 
118
- // ── Renderer resolution: servers > native vanilla > fallback ────────────
123
+ // ── Renderer resolution: servers > native custom-element > native vanilla > fallback ────
119
124
 
120
125
  // Look up a custom widget entry from connected WebMCP servers
121
126
  const customWidgetEntry = $derived.by(() => {
@@ -130,8 +135,13 @@
130
135
  const customRenderer = $derived(customWidgetEntry?.renderer ?? null);
131
136
  const isCustomVanilla = $derived(customWidgetEntry?.vanilla === true);
132
137
 
138
+ /** True if this widget type is served as a native Svelte-compiled custom element. */
139
+ const isNativeCustomElement = $derived(
140
+ !customRenderer && NATIVE_CUSTOM_ELEMENTS.has(type),
141
+ );
142
+
133
143
  const nativeVanillaRenderer = $derived<VanillaRenderer | undefined>(
134
- customRenderer ? undefined : NATIVE_VANILLA_MAP[type],
144
+ customRenderer || isNativeCustomElement ? undefined : NATIVE_VANILLA_MAP[type],
135
145
  );
136
146
 
137
147
  /** True when a vanilla renderer (custom or native) should be used */
@@ -148,9 +158,54 @@
148
158
  // libs (D3, Leaflet, etc.) rely on Object.defineProperty which conflicts with proxies.
149
159
  const plainData: Record<string, unknown> = $derived(safeClone(data) as Record<string, unknown>);
150
160
 
161
+ // ── Custom element container + element handle ─────────
162
+ let ceContainer: HTMLElement | undefined = $state(undefined);
163
+ let ceElement: HTMLElement | undefined = undefined;
164
+
165
+ $effect(() => {
166
+ if (!isNativeCustomElement || !ceContainer) return;
167
+ const tag = `auto-${type}`;
168
+ // Instantiate on first mount. `data` setter is reactive via Svelte 5 custom-element.
169
+ const el = document.createElement(tag) as HTMLElement;
170
+ (el as unknown as { data: unknown }).data = plainData;
171
+ const onInteract = (ev: Event) => {
172
+ const ce = ev as CustomEvent<{ action?: string; payload?: unknown }>;
173
+ const action = ce.detail?.action ?? 'interact';
174
+ emit(action, ce.detail?.payload);
175
+ };
176
+ el.addEventListener('widget:interact', onInteract);
177
+ ceContainer.innerHTML = '';
178
+ ceContainer.appendChild(el);
179
+ ceElement = el;
180
+ return () => {
181
+ el.removeEventListener('widget:interact', onInteract);
182
+ ceElement = undefined;
183
+ if (ceContainer) ceContainer.innerHTML = '';
184
+ };
185
+ });
186
+
187
+ // In-place data updates on the custom element — no remount.
188
+ $effect(() => {
189
+ const next = plainData;
190
+ if (!isNativeCustomElement || !ceElement) return;
191
+ (ceElement as unknown as { data: unknown }).data = next;
192
+ });
193
+
151
194
  // ── Vanilla renderer container + lifecycle ────────────
152
195
  let vanillaContainer: HTMLElement | undefined = $state(undefined);
153
196
 
197
+ // Cleanup handle shared between the mount effect and the data-update fallback
198
+ // remount — so a fallback remount can tear down the previous render even
199
+ // though it doesn't re-run the mount effect.
200
+ let currentCleanup: (() => void) | undefined = undefined;
201
+ function runCurrentCleanup() {
202
+ const c = currentCleanup;
203
+ currentCleanup = undefined;
204
+ if (typeof c === 'function') {
205
+ try { c(); } catch (err) { console.error('[WidgetRenderer] cleanup failed:', err); }
206
+ }
207
+ }
208
+
154
209
  // Mount effect — re-runs only when the widget identity changes (type /
155
210
  // renderer / container). Data changes are handled separately to avoid a
156
211
  // flickering full remount on every agent update.
@@ -160,6 +215,9 @@
160
215
  if (!useVanilla || !vanillaRenderer || !vanillaContainer) return;
161
216
  const container = vanillaContainer;
162
217
  const renderer = vanillaRenderer;
218
+ // If a previous render is still live (e.g. via data-update fallback),
219
+ // tear it down before we clear the DOM.
220
+ runCurrentCleanup();
163
221
  container.innerHTML = '';
164
222
 
165
223
  const onInteract = (ev: Event) => {
@@ -169,17 +227,24 @@
169
227
  };
170
228
  container.addEventListener('widget:interact', onInteract);
171
229
 
172
- let cleanup: (() => void) | void;
173
230
  let cancelled = false;
174
231
 
175
232
  try {
176
233
  const result = renderer(container, untrack(() => plainData));
177
234
  if (result && typeof (result as Promise<unknown>).then === 'function') {
178
235
  (result as Promise<void | (() => void)>).then(
179
- (c) => { if (!cancelled) cleanup = c ?? undefined; },
236
+ (c) => {
237
+ // If we were torn down before the promise resolved, invoke the
238
+ // late cleanup immediately rather than leaking resources.
239
+ if (cancelled && typeof c === 'function') {
240
+ try { c(); } catch { /* ignore */ }
241
+ } else {
242
+ currentCleanup = c ?? undefined;
243
+ }
244
+ },
180
245
  ).catch((err) => { console.error('[WidgetRenderer] async render failed:', err); });
181
246
  } else {
182
- cleanup = result as (() => void) | void;
247
+ currentCleanup = (result as (() => void) | undefined) ?? undefined;
183
248
  }
184
249
  } catch (err) {
185
250
  console.error('[WidgetRenderer] sync render failed:', err);
@@ -188,9 +253,7 @@
188
253
  return () => {
189
254
  cancelled = true;
190
255
  container.removeEventListener('widget:interact', onInteract);
191
- if (typeof cleanup === 'function') {
192
- try { cleanup(); } catch (err) { console.error('[WidgetRenderer] cleanup failed:', err); }
193
- }
256
+ runCurrentCleanup();
194
257
  };
195
258
  });
196
259
 
@@ -208,10 +271,20 @@
208
271
  const handled = !vanillaContainer.dispatchEvent(ev);
209
272
  if (handled || !vanillaRenderer) return;
210
273
  // Not handled — fall back to remount by clearing + calling renderer again.
274
+ // Run the previous cleanup first so the old renderer releases its
275
+ // resources (timers, observers, third-party instances).
211
276
  const container = vanillaContainer;
277
+ runCurrentCleanup();
212
278
  container.innerHTML = '';
213
279
  try {
214
- vanillaRenderer(container, data);
280
+ const result = vanillaRenderer(container, data);
281
+ if (result && typeof (result as Promise<unknown>).then === 'function') {
282
+ (result as Promise<void | (() => void)>).then(
283
+ (c) => { currentCleanup = c ?? undefined; },
284
+ ).catch((err) => { console.error('[WidgetRenderer] fallback async render failed:', err); });
285
+ } else {
286
+ currentCleanup = (result as (() => void) | undefined) ?? undefined;
287
+ }
215
288
  } catch (err) {
216
289
  console.error('[WidgetRenderer] fallback remount failed:', err);
217
290
  }
@@ -250,7 +323,7 @@
250
323
  description: `Update the data of ${type} widget (id: ${busId}).`,
251
324
  inputSchema: { type: 'object', properties: {}, additionalProperties: true },
252
325
  execute: (args: Record<string, unknown>) => {
253
- oninteract?.(type, 'update', args);
326
+ oninteract?.(type, 'bus-update', args);
254
327
  return { content: [{ type: 'text', text: `widget_${busId} updated` }] };
255
328
  },
256
329
  });
@@ -260,7 +333,7 @@
260
333
  description: `Remove ${type} widget (id: ${busId}) from the view.`,
261
334
  inputSchema: { type: 'object', properties: {} },
262
335
  execute: () => {
263
- oninteract?.(type, 'remove', {});
336
+ oninteract?.(type, 'bus-remove', {});
264
337
  return { content: [{ type: 'text', text: `widget_${busId} removed` }] };
265
338
  },
266
339
  annotations: { destructiveHint: true },
@@ -277,7 +350,9 @@
277
350
  });
278
351
  </script>
279
352
 
280
- {#if useVanilla}
353
+ {#if isNativeCustomElement}
354
+ <div bind:this={ceContainer} class="ce-container w-full h-full overflow-auto p-2"></div>
355
+ {:else if useVanilla}
281
356
  <div bind:this={vanillaContainer} class="vanilla-container w-full h-full overflow-auto p-2"></div>
282
357
  {:else if customRenderer}
283
358
  <svelte:component this={customRenderer as Component<any>} {data} {id} />
@@ -1,4 +1,3 @@
1
1
  export { createSqlExecutor } from './sql.js';
2
- export type { SqlExecutorOptions } from './sql.js';
3
2
  export { createJsExecutor } from './js-worker.js';
4
3
  export type { JsExecutorOptions } from './js-worker.js';
@@ -1,206 +1,56 @@
1
- // @ts-nocheck
2
- /**
3
- * SQL executor for notebook cells.
4
- *
5
- * Finds a SQL-capable tool on the connected data servers via auto-pattern,
6
- * calls it via postMessage, parses the result into a `table` CellResult.
7
- */
8
-
9
1
  import { callToolViaPostMessage } from '@webmcp-auto-ui/core';
10
- import type { CellExecutor, CellExecContext, DataServerDescriptor } from '../shared.js';
11
-
12
- export interface SqlExecutorOptions {
13
- /** Timeout per query (ms). Default 30000 */
14
- timeoutMs?: number;
15
- /** Max rows to keep in result (truncate beyond). Default 1000 */
16
- maxRows?: number;
17
- }
2
+ import type { CellExecutor, CellExecContext, CellResult, DataServerDescriptor } from '../shared.js';
18
3
 
19
4
  const PATTERN_PRIMARY = /^.*query_sql$/i;
20
5
  const PATTERN_FALLBACK = /^(query|run|execute)(_sql)?$/i;
21
6
 
22
7
  function findSqlTool(servers: DataServerDescriptor[]): string | null {
23
- // Priority 1: *_query_sql or query_sql
24
- for (const srv of servers) {
25
- for (const t of srv.tools ?? []) {
26
- if (PATTERN_PRIMARY.test(t.name)) return t.name;
27
- }
28
- }
29
- // Priority 2: query / run / execute (with optional _sql)
30
- for (const srv of servers) {
31
- for (const t of srv.tools ?? []) {
32
- if (PATTERN_FALLBACK.test(t.name)) return t.name;
8
+ for (const p of [PATTERN_PRIMARY, PATTERN_FALLBACK]) {
9
+ for (const srv of servers) {
10
+ for (const t of srv.tools ?? []) if (p.test(t.name)) return t.name;
33
11
  }
34
12
  }
35
13
  return null;
36
14
  }
37
15
 
38
- /**
39
- * Extract the first text content from an MCP tool result.
40
- */
41
- function extractText(result: any): string | null {
42
- if (!result) return null;
43
- const content = result.content ?? result;
44
- if (!Array.isArray(content)) {
45
- if (typeof content === 'string') return content;
46
- return null;
47
- }
48
- for (const item of content) {
49
- if (item && item.type === 'text' && typeof item.text === 'string') {
50
- return item.text;
51
- }
52
- }
53
- return null;
54
- }
55
-
56
- function tryParseJson(text: string): unknown {
57
- try {
58
- return JSON.parse(text);
59
- } catch {
60
- return text; // not JSON, return raw
61
- }
62
- }
63
-
64
- export function createSqlExecutor(
65
- getServers: () => DataServerDescriptor[],
66
- opts?: SqlExecutorOptions
67
- ): CellExecutor {
68
- const timeoutMs = opts?.timeoutMs ?? 30_000;
69
- const maxRows = opts?.maxRows ?? 1000;
70
-
71
- return async (ctx: CellExecContext) => {
16
+ export function createSqlExecutor(getServers: () => DataServerDescriptor[]): CellExecutor {
17
+ return async (ctx: CellExecContext): Promise<CellResult> => {
72
18
  const startedAt = Date.now();
73
- const servers = getServers();
74
-
75
- const toolName = findSqlTool(servers);
19
+ const toolName = findSqlTool(getServers());
76
20
  if (!toolName) {
77
- return {
78
- ok: false,
79
- error: 'No SQL tool found on connected servers',
80
- errorKind: 'schema',
81
- durationMs: Date.now() - startedAt,
82
- };
21
+ return { ok: false, error: 'No SQL tool available on connected servers.', errorKind: 'schema', durationMs: Date.now() - startedAt };
83
22
  }
84
-
85
23
  const sql = (ctx.cell.content ?? '').trim();
86
- if (!sql) {
87
- return { ok: true, kind: 'empty', durationMs: Date.now() - startedAt };
88
- }
89
-
90
- // Wrap the tool call with the cell's AbortSignal so an external abort
91
- // rejects the promise even if callToolViaPostMessage doesn't support signals.
92
- const callPromise = callToolViaPostMessage(toolName, { sql }, { timeout: timeoutMs });
24
+ if (!sql) return { ok: true, kind: 'empty', durationMs: Date.now() - startedAt };
93
25
 
94
- let raceResult: any;
26
+ let raw: unknown;
95
27
  try {
96
- raceResult = await new Promise((resolve, reject) => {
97
- let settled = false;
98
- const onAbort = () => {
99
- if (settled) return;
100
- settled = true;
101
- reject(new Error('aborted'));
102
- };
103
- if (ctx.signal.aborted) {
104
- onAbort();
105
- return;
106
- }
107
- ctx.signal.addEventListener('abort', onAbort, { once: true });
108
- callPromise.then(
109
- (v) => {
110
- if (settled) return;
111
- settled = true;
112
- ctx.signal.removeEventListener('abort', onAbort);
113
- resolve(v);
114
- },
115
- (err) => {
116
- if (settled) return;
117
- settled = true;
118
- ctx.signal.removeEventListener('abort', onAbort);
119
- reject(err);
120
- }
121
- );
122
- });
123
- } catch (err: any) {
124
- const durationMs = Date.now() - startedAt;
125
- const msg = String(err?.message ?? err);
126
- const isTimeout = /timed out|aborted/i.test(msg);
127
- return {
128
- ok: false,
129
- error: msg,
130
- errorKind: isTimeout ? 'timeout' : 'runtime',
131
- durationMs,
132
- };
28
+ raw = await callToolViaPostMessage(toolName, { sql });
29
+ } catch (err) {
30
+ return { ok: false, error: String((err as { message?: unknown })?.message ?? err), errorKind: 'runtime', durationMs: Date.now() - startedAt };
133
31
  }
134
-
135
32
  const durationMs = Date.now() - startedAt;
136
33
 
137
- // Unwrap MCP content array text JSON.
138
- const text = extractText(raceResult);
139
- const parsed = text != null ? tryParseJson(text) : raceResult;
140
-
141
- // Error shape returned inside the tool result
142
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'error' in (parsed as any) && (parsed as any).error) {
143
- return {
144
- ok: false,
145
- error: String((parsed as any).error),
146
- errorKind: 'runtime',
147
- durationMs,
148
- };
149
- }
150
-
151
- // {rows, columns} shape
152
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Array.isArray((parsed as any).rows)) {
153
- let rows: Record<string, unknown>[] = (parsed as any).rows;
154
- const columns: string[] = Array.isArray((parsed as any).columns)
155
- ? (parsed as any).columns
156
- : rows.length > 0 && typeof rows[0] === 'object'
157
- ? Object.keys(rows[0] as Record<string, unknown>)
158
- : [];
159
- let truncated = false;
160
- if (rows.length > maxRows) {
161
- rows = rows.slice(0, maxRows);
162
- truncated = true;
163
- }
164
- return {
165
- ok: true,
166
- kind: 'table',
167
- rows,
168
- columns,
169
- rowCount: (parsed as any).rows.length,
170
- truncated: truncated || undefined,
171
- durationMs,
172
- };
34
+ const content = (raw as { content?: unknown })?.content;
35
+ const text = Array.isArray(content)
36
+ ? (content.find((c) => (c as { type?: unknown })?.type === 'text') as { text?: string } | undefined)?.text ?? ''
37
+ : '';
38
+ let parsed: unknown = text;
39
+ try { parsed = JSON.parse(text); } catch { /* not JSON */ }
40
+
41
+ if (!text) return { ok: true, kind: 'empty', durationMs };
42
+
43
+ const rows: unknown[] =
44
+ Array.isArray(parsed) ? parsed
45
+ : Array.isArray((parsed as { rows?: unknown })?.rows) ? (parsed as { rows: unknown[] }).rows
46
+ : [];
47
+ if (rows.length && rows.every((r) => r && typeof r === 'object')) {
48
+ const declared = (parsed as { columns?: unknown })?.columns;
49
+ const columns = Array.isArray(declared)
50
+ ? declared.map(String)
51
+ : Array.from(new Set(rows.flatMap((r) => Object.keys(r as Record<string, unknown>))));
52
+ return { ok: true, kind: 'table', rows: rows as Record<string, unknown>[], columns, rowCount: rows.length, durationMs };
173
53
  }
174
-
175
- // Array of objects
176
- if (Array.isArray(parsed)) {
177
- if (parsed.length === 0) {
178
- return { ok: true, kind: 'table', rows: [], columns: [], rowCount: 0, durationMs };
179
- }
180
- const first = parsed[0];
181
- if (first && typeof first === 'object' && !Array.isArray(first)) {
182
- let rows = parsed as Record<string, unknown>[];
183
- const columns = Object.keys(first as Record<string, unknown>);
184
- const rowCount = rows.length;
185
- let truncated = false;
186
- if (rows.length > maxRows) {
187
- rows = rows.slice(0, maxRows);
188
- truncated = true;
189
- }
190
- return {
191
- ok: true,
192
- kind: 'table',
193
- rows,
194
- columns,
195
- rowCount,
196
- truncated: truncated || undefined,
197
- durationMs,
198
- };
199
- }
200
- return { ok: true, kind: 'value', value: parsed, durationMs };
201
- }
202
-
203
- // Anything else — a scalar or object that isn't tabular
204
54
  return { ok: true, kind: 'value', value: parsed, durationMs };
205
55
  };
206
56
  }