@webmcp-auto-ui/ui 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.
@@ -0,0 +1,331 @@
1
+ <script lang="ts">
2
+ import type { McpMultiClient } from '@webmcp-auto-ui/core';
3
+ import type { RunResult } from '@webmcp-auto-ui/sdk';
4
+ import { runCode, estimateTokens } from '@webmcp-auto-ui/sdk';
5
+ import { canvas } from '@webmcp-auto-ui/sdk/canvas';
6
+ import { highlightCode } from '../primitives/markdown-renderer.js';
7
+ import type { RecipeBlockAction } from './types.js';
8
+
9
+ interface Props {
10
+ code: string;
11
+ lang?: string;
12
+ /**
13
+ * Custom action buttons in the gutter. If omitted AND `onrun` is provided,
14
+ * a default Run button is rendered (back-compat with original flex behavior).
15
+ */
16
+ actions?: RecipeBlockAction[];
17
+ /**
18
+ * Back-compat: legacy flex usage. When set, a default Run button is rendered
19
+ * that calls runCode(code, lang) and forwards the result to this callback.
20
+ * Ignored when `actions` is provided.
21
+ */
22
+ onrun?: (payload: { code: string; lang: string; result: RunResult }) => void;
23
+ /**
24
+ * Optional shared scope object. When provided, top-level decls of prior
25
+ * blocks are visible in this block, and this block's top-level decls are
26
+ * written back so subsequent blocks can read them. Owner: the host modal.
27
+ */
28
+ scope?: Record<string, unknown>;
29
+ }
30
+
31
+ let {
32
+ code = '',
33
+ lang = 'text',
34
+ actions = undefined,
35
+ onrun,
36
+ scope,
37
+ }: Props = $props();
38
+
39
+ let editable = $state('');
40
+ let runStatus = $state<'idle' | 'running' | 'done' | 'error'>('idle');
41
+ let elapsed = $state(0);
42
+ let liveTokens = $state(0);
43
+ let lastDuration = $state<number | undefined>(undefined);
44
+ let lastTokens = $state<number | undefined>(undefined);
45
+ let timerId: ReturnType<typeof setInterval> | undefined;
46
+ let doneResetId: ReturnType<typeof setTimeout> | undefined;
47
+
48
+ $effect(() => {
49
+ editable = code;
50
+ });
51
+
52
+ let highlightedHtml = $derived(highlightCode(editable, lang || 'plaintext'));
53
+ let preEl: HTMLPreElement | undefined = $state(undefined);
54
+ let taEl: HTMLTextAreaElement | undefined = $state(undefined);
55
+
56
+ function syncScroll() {
57
+ if (preEl && taEl) {
58
+ preEl.scrollTop = taEl.scrollTop;
59
+ preEl.scrollLeft = taEl.scrollLeft;
60
+ }
61
+ }
62
+
63
+ function formatTokens(n: number): string {
64
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
65
+ return `${n} tok`;
66
+ }
67
+
68
+ function startTimer(startAt: number) {
69
+ stopTimer();
70
+ timerId = setInterval(() => {
71
+ elapsed = Math.round(performance.now() - startAt);
72
+ liveTokens = estimateTokens(editable);
73
+ }, 60);
74
+ }
75
+
76
+ function stopTimer() {
77
+ if (timerId) {
78
+ clearInterval(timerId);
79
+ timerId = undefined;
80
+ }
81
+ }
82
+
83
+ async function handleRun() {
84
+ if (runStatus === 'running') return;
85
+ if (doneResetId) {
86
+ clearTimeout(doneResetId);
87
+ doneResetId = undefined;
88
+ }
89
+ runStatus = 'running';
90
+ elapsed = 0;
91
+ liveTokens = estimateTokens(editable);
92
+ const t0 = performance.now();
93
+ startTimer(t0);
94
+
95
+ const multi = canvas.multiClient as McpMultiClient | undefined;
96
+ const result = await runCode(editable, lang, multi, scope);
97
+
98
+ stopTimer();
99
+ lastDuration = result.durationMs;
100
+ lastTokens = result.tokens;
101
+ runStatus = result.status === 'error' ? 'error' : 'done';
102
+
103
+ onrun?.({ code: editable, lang, result });
104
+
105
+ if (runStatus === 'done') {
106
+ doneResetId = setTimeout(() => {
107
+ if (runStatus === 'done') runStatus = 'idle';
108
+ }, 1000);
109
+ }
110
+ }
111
+
112
+ // Resolve the action list: explicit actions OR back-compat single Run button.
113
+ const resolvedActions = $derived<RecipeBlockAction[]>(
114
+ actions && actions.length > 0
115
+ ? actions
116
+ : onrun
117
+ ? [{
118
+ icon: '▶',
119
+ label: 'Run',
120
+ onclick: () => handleRun(),
121
+ }]
122
+ : []
123
+ );
124
+
125
+ // For the single-action Run case, we render run-status icon + stats inline.
126
+ const isSingleRunAction = $derived(
127
+ !actions && !!onrun && resolvedActions.length === 1
128
+ );
129
+
130
+ function handleActionClick(a: RecipeBlockAction) {
131
+ a.onclick(editable, lang);
132
+ }
133
+ </script>
134
+
135
+ <div class="code-block">
136
+ <div class="gutter">
137
+ {#if isSingleRunAction}
138
+ <button
139
+ type="button"
140
+ class="run-btn {runStatus}"
141
+ onclick={() => handleRun()}
142
+ disabled={runStatus === 'running'}
143
+ title={runStatus === 'running' ? 'Running...' : 'Run'}
144
+ >
145
+ <span class="icon">
146
+ {#if runStatus === 'running'}
147
+ {@html '&#x25D0;'}
148
+ {:else if runStatus === 'done'}
149
+ {@html '&#x2713;'}
150
+ {:else if runStatus === 'error'}
151
+ !
152
+ {:else}
153
+ {@html '&#x25B6;'}
154
+ {/if}
155
+ </span>
156
+ {#if runStatus === 'running' || lastDuration !== undefined}
157
+ <span class="stats">
158
+ <span class="t">
159
+ {runStatus === 'running' ? `${elapsed}ms` : `${lastDuration}ms`}
160
+ </span>
161
+ <span class="tok">
162
+ {formatTokens(runStatus === 'running' ? liveTokens : (lastTokens ?? 0))}
163
+ </span>
164
+ </span>
165
+ {/if}
166
+ </button>
167
+ {:else}
168
+ {#each resolvedActions as a}
169
+ <button
170
+ type="button"
171
+ class="action-btn {a.variant ?? 'default'}"
172
+ onclick={() => handleActionClick(a)}
173
+ title={a.label ?? a.icon}
174
+ >
175
+ <span class="icon">{@html a.icon}</span>
176
+ </button>
177
+ {/each}
178
+ {/if}
179
+ </div>
180
+
181
+ <div class="editor-wrap">
182
+ {#if lang && lang !== 'text'}
183
+ <div class="lang-tag font-mono">{lang}</div>
184
+ {/if}
185
+ <pre bind:this={preEl} class="editor highlight-layer hljs font-mono" aria-hidden="true"><code class="hljs language-{lang || 'plaintext'}">{@html highlightedHtml}</code></pre>
186
+ <textarea
187
+ bind:this={taEl}
188
+ bind:value={editable}
189
+ onscroll={syncScroll}
190
+ spellcheck="false"
191
+ autocomplete="off"
192
+ rows={Math.min(Math.max(editable.split('\n').length, 3), 20)}
193
+ class="editor input-layer font-mono"
194
+ ></textarea>
195
+ </div>
196
+ </div>
197
+
198
+ <style>
199
+ .code-block {
200
+ display: flex;
201
+ align-items: stretch;
202
+ gap: 6px;
203
+ margin: 0.5rem 0;
204
+ }
205
+ .gutter {
206
+ display: flex;
207
+ flex-direction: column;
208
+ gap: 4px;
209
+ align-items: stretch;
210
+ }
211
+ .run-btn,
212
+ .action-btn {
213
+ display: flex;
214
+ flex-direction: column;
215
+ align-items: center;
216
+ justify-content: center;
217
+ gap: 4px;
218
+ min-width: 56px;
219
+ padding: 8px 6px;
220
+ border-radius: 0.375rem;
221
+ border: 1px solid rgba(255, 255, 255, 0.08);
222
+ background: #0d1117;
223
+ color: rgb(180, 180, 180);
224
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
225
+ font-size: 11px;
226
+ cursor: pointer;
227
+ transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
228
+ }
229
+ .run-btn:hover:not(:disabled),
230
+ .action-btn:hover:not(:disabled) {
231
+ background: #161b22;
232
+ color: #fff;
233
+ }
234
+ .run-btn:disabled,
235
+ .action-btn:disabled {
236
+ cursor: progress;
237
+ }
238
+ .run-btn .icon,
239
+ .action-btn .icon {
240
+ font-size: 14px;
241
+ line-height: 1;
242
+ }
243
+ .run-btn.running .icon {
244
+ animation: spin 0.9s linear infinite;
245
+ }
246
+ .run-btn.done,
247
+ .action-btn.success {
248
+ color: rgb(74, 222, 128);
249
+ border-color: rgba(74, 222, 128, 0.4);
250
+ }
251
+ .run-btn.error,
252
+ .action-btn.error {
253
+ color: rgb(248, 113, 113);
254
+ border-color: rgba(248, 113, 113, 0.4);
255
+ }
256
+ .run-btn .stats {
257
+ display: flex;
258
+ flex-direction: column;
259
+ gap: 1px;
260
+ font-size: 9px;
261
+ color: rgb(160, 160, 160);
262
+ line-height: 1.2;
263
+ }
264
+ .run-btn .stats .t { font-weight: 600; }
265
+ .run-btn .stats .tok { opacity: 0.8; }
266
+ @keyframes spin {
267
+ from { transform: rotate(0deg); }
268
+ to { transform: rotate(360deg); }
269
+ }
270
+
271
+ .editor-wrap {
272
+ flex: 1;
273
+ min-width: 0;
274
+ position: relative;
275
+ }
276
+ .lang-tag {
277
+ position: absolute;
278
+ top: 4px;
279
+ right: 8px;
280
+ font-size: 9px;
281
+ color: rgba(255, 255, 255, 0.35);
282
+ text-transform: lowercase;
283
+ pointer-events: none;
284
+ }
285
+ .editor {
286
+ display: block;
287
+ width: 100%;
288
+ box-sizing: border-box;
289
+ background: #0d1117;
290
+ color: rgb(220, 220, 220);
291
+ border: 1px solid rgba(255, 255, 255, 0.08);
292
+ border-radius: 0.375rem;
293
+ padding: 0.7rem;
294
+ font-size: 0.7rem;
295
+ line-height: 1.5;
296
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
297
+ white-space: pre;
298
+ tab-size: 2;
299
+ margin: 0;
300
+ }
301
+ .highlight-layer {
302
+ position: absolute;
303
+ inset: 0;
304
+ overflow: auto;
305
+ pointer-events: none;
306
+ border-color: transparent;
307
+ }
308
+ .highlight-layer :global(code.hljs) {
309
+ background: transparent;
310
+ padding: 0;
311
+ }
312
+ .input-layer {
313
+ position: relative;
314
+ color: transparent;
315
+ caret-color: rgb(220, 220, 220);
316
+ background: transparent;
317
+ resize: vertical;
318
+ outline: none;
319
+ overflow: auto;
320
+ }
321
+ .input-layer::selection {
322
+ color: transparent;
323
+ background: rgba(96, 165, 250, 0.35);
324
+ }
325
+ .input-layer:focus {
326
+ border-color: rgba(96, 165, 250, 0.45);
327
+ }
328
+ .editor-wrap {
329
+ min-height: 0;
330
+ }
331
+ </style>
@@ -0,0 +1,245 @@
1
+ <script lang="ts">
2
+ import { fly } from 'svelte/transition';
3
+ import type { RunTab } from '@webmcp-auto-ui/sdk';
4
+ import { safeStringify } from '@webmcp-auto-ui/sdk';
5
+ import { WidgetRenderer } from '@webmcp-auto-ui/ui';
6
+ import type { WebMcpServer } from '@webmcp-auto-ui/core';
7
+
8
+ interface Props {
9
+ open: boolean;
10
+ runs: RunTab[];
11
+ activeTabId: string | null;
12
+ onclose: () => void;
13
+ onreplay: (tabId: string) => void;
14
+ onselectTab: (tabId: string) => void;
15
+ /** When true, render as an inline panel inside the host modal instead of a floating side panel. */
16
+ inline?: boolean;
17
+ /** Connected WebMCP servers — needed for custom widget renderers (e.g. canvas2d). */
18
+ servers?: WebMcpServer[];
19
+ }
20
+
21
+ let {
22
+ open,
23
+ runs,
24
+ activeTabId,
25
+ onclose,
26
+ onreplay,
27
+ onselectTab,
28
+ inline = false,
29
+ servers = [],
30
+ }: Props = $props();
31
+
32
+ const active = $derived(runs.find((r) => r.id === activeTabId) ?? runs[runs.length - 1] ?? null);
33
+
34
+ let logsOpen = $state(true);
35
+ let copyState = $state<'idle' | 'copied'>('idle');
36
+ let copyTimer: ReturnType<typeof setTimeout> | undefined;
37
+
38
+ function formatTokens(n: number | undefined): string {
39
+ if (n == null) return '—';
40
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
41
+ return `${n}`;
42
+ }
43
+
44
+ async function copyOutput() {
45
+ if (!active) return;
46
+ try {
47
+ const text =
48
+ active.result.status === 'error'
49
+ ? (active.result.error ?? '')
50
+ : safeStringify(active.result.output);
51
+ await navigator.clipboard.writeText(text);
52
+ copyState = 'copied';
53
+ if (copyTimer) clearTimeout(copyTimer);
54
+ copyTimer = setTimeout(() => { copyState = 'idle'; }, 1500);
55
+ } catch {
56
+ /* ignore */
57
+ }
58
+ }
59
+ </script>
60
+
61
+ {#if open}
62
+ <div
63
+ class="run-panel {inline ? 'inline' : 'side'} bg-surface border border-border2 rounded-2xl flex flex-col shadow-2xl overflow-hidden"
64
+ transition:fly={{ x: inline ? 0 : 24, y: inline ? 12 : 0, duration: 200 }}
65
+ >
66
+ <!-- Header -->
67
+ <div class="flex items-center gap-3 px-4 py-3 border-b border-border flex-shrink-0">
68
+ <span class="font-mono text-xs font-bold text-text1 flex-1 truncate">
69
+ {#if active}
70
+ {@html '&#x25B6;'} Run · <span class="text-accent">{active.label}</span>
71
+ {:else}
72
+ Run
73
+ {/if}
74
+ </span>
75
+ <button
76
+ class="text-text2 hover:text-text1 font-mono text-base leading-none transition-colors"
77
+ onclick={onclose}
78
+ title="Close"
79
+ >x</button>
80
+ </div>
81
+
82
+ <!-- Tabs (if multiple) -->
83
+ {#if runs.length > 1}
84
+ <div class="flex items-center gap-1 px-3 py-1.5 border-b border-border overflow-x-auto flex-shrink-0">
85
+ {#each runs as tab (tab.id)}
86
+ <button
87
+ class="font-mono text-[10px] px-2 py-1 rounded border transition-colors whitespace-nowrap
88
+ {tab.id === active?.id
89
+ ? 'border-accent/50 text-accent bg-accent/10'
90
+ : 'border-border2 text-text2 hover:text-text1'}"
91
+ onclick={() => onselectTab(tab.id)}
92
+ >
93
+ {tab.label}
94
+ </button>
95
+ {/each}
96
+ </div>
97
+ {/if}
98
+
99
+ {#if active}
100
+ <!-- Stats row -->
101
+ <div class="flex items-center gap-4 px-4 py-2 border-b border-border flex-shrink-0 font-mono text-[11px]">
102
+ <span class="text-text2">
103
+ {@html '&#x23F1;'} <span class="text-text1">{active.result.durationMs ?? '—'}{active.result.durationMs != null ? 'ms' : ''}</span>
104
+ </span>
105
+ <span class="text-text2">
106
+ {@html '&#x25FC;'} <span class="text-text1">{formatTokens(active.result.tokens)} tok</span>
107
+ </span>
108
+ <span class="ml-auto">
109
+ {#if active.result.status === 'running'}
110
+ <span class="text-accent">{@html '&#x25D0;'} running</span>
111
+ {:else if active.result.status === 'done'}
112
+ <span class="text-teal">{@html '&#x2713;'} done</span>
113
+ {:else if active.result.status === 'error'}
114
+ <span class="text-red-400">! error</span>
115
+ {:else}
116
+ <span class="text-text2">idle</span>
117
+ {/if}
118
+ </span>
119
+ <button
120
+ class="font-mono text-xs h-6 px-2 rounded border border-border2 text-text2 hover:text-text1 transition-colors"
121
+ onclick={() => onreplay(active.id)}
122
+ disabled={active.result.status === 'running'}
123
+ title="Replay"
124
+ >{@html '&#x21BB;'}</button>
125
+ </div>
126
+
127
+ <!-- Body -->
128
+ <div class="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3">
129
+ <!-- Output -->
130
+ <div>
131
+ <div class="flex items-center mb-1">
132
+ <span class="text-[9px] font-mono text-text2 uppercase tracking-wider">
133
+ {active.result.status === 'error'
134
+ ? 'Error'
135
+ : (active.result.widgets && active.result.widgets.length > 0) || active.result.widget
136
+ ? `Widget${active.result.widgets && active.result.widgets.length > 1 ? `s (${active.result.widgets.length})` : ''}`
137
+ : 'Output'}
138
+ </span>
139
+ {#if !active.result.widget && !(active.result.widgets && active.result.widgets.length > 0)}
140
+ <button
141
+ class="ml-auto font-mono text-[10px] px-2 py-0.5 rounded border transition-colors
142
+ {copyState === 'copied' ? 'border-teal/40 text-teal' : 'border-border2 text-text2 hover:text-text1'}"
143
+ onclick={copyOutput}
144
+ >
145
+ {copyState === 'copied' ? 'copied' : 'copy'}
146
+ </button>
147
+ {/if}
148
+ </div>
149
+ {#if active.result.status === 'done' && active.result.widgets && active.result.widgets.length > 0}
150
+ <div class="widget-host flex flex-col gap-3">
151
+ {#key active.id}
152
+ {#each active.result.widgets as w, i (i)}
153
+ <WidgetRenderer type={w.name} data={w.params} {servers} />
154
+ {/each}
155
+ {/key}
156
+ </div>
157
+ {:else if active.result.status === 'done' && active.result.widget}
158
+ <div class="widget-host">
159
+ {#key active.id}
160
+ <WidgetRenderer
161
+ type={active.result.widget.name}
162
+ data={active.result.widget.params}
163
+ {servers}
164
+ />
165
+ {/key}
166
+ </div>
167
+ {:else}
168
+ <pre class="output-pre font-mono"><code>{
169
+ active.result.status === 'running'
170
+ ? '...'
171
+ : active.result.status === 'error'
172
+ ? (active.result.error ?? '(unknown error)')
173
+ : safeStringify(active.result.output)
174
+ }</code></pre>
175
+ {/if}
176
+ </div>
177
+
178
+ <!-- Logs -->
179
+ {#if active.result.logs.length > 0}
180
+ <div>
181
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
182
+ <div
183
+ class="flex items-center gap-1 cursor-pointer select-none"
184
+ onclick={() => logsOpen = !logsOpen}
185
+ >
186
+ <span class="text-[9px] font-mono text-text2 uppercase tracking-wider">Logs ({active.result.logs.length})</span>
187
+ <span class="text-[10px] text-text2 ml-1 transition-transform {logsOpen ? 'rotate-90' : ''}">{@html '&#x25B6;'}</span>
188
+ </div>
189
+ {#if logsOpen}
190
+ <div class="mt-1 flex flex-col gap-0.5">
191
+ {#each active.result.logs as entry, i (i)}
192
+ <div class="font-mono text-[10px] text-text2">
193
+ <span class="text-text2/60">[+{entry.t}ms]</span>
194
+ <span class="text-text1">{entry.msg}</span>
195
+ </div>
196
+ {/each}
197
+ </div>
198
+ {/if}
199
+ </div>
200
+ {/if}
201
+ </div>
202
+ {:else}
203
+ <div class="flex-1 flex items-center justify-center">
204
+ <span class="font-mono text-xs text-text2">No run yet</span>
205
+ </div>
206
+ {/if}
207
+ </div>
208
+ {/if}
209
+
210
+ <style>
211
+ .run-panel.side {
212
+ width: 100%;
213
+ height: 100%;
214
+ }
215
+ .run-panel.inline {
216
+ width: 100%;
217
+ max-height: 50vh;
218
+ }
219
+ .widget-host {
220
+ background: #0d1117;
221
+ border: 1px solid rgba(255, 255, 255, 0.08);
222
+ border-radius: 0.375rem;
223
+ padding: 0.6rem 0.7rem;
224
+ min-height: 200px;
225
+ display: flex;
226
+ flex-direction: column;
227
+ }
228
+ .widget-host :global(> *) {
229
+ flex: 1;
230
+ min-height: 0;
231
+ }
232
+ .output-pre {
233
+ background: #0d1117;
234
+ border: 1px solid rgba(255, 255, 255, 0.08);
235
+ border-radius: 0.375rem;
236
+ padding: 0.6rem 0.7rem;
237
+ margin: 0;
238
+ font-size: 0.7rem;
239
+ line-height: 1.5;
240
+ color: rgb(220, 220, 220);
241
+ overflow-x: auto;
242
+ white-space: pre-wrap;
243
+ word-break: break-word;
244
+ }
245
+ </style>
@@ -0,0 +1,10 @@
1
+ export interface RecipeBlockAction {
2
+ /** Glyph or HTML entity. Examples: '▶', '+', '⧉', '✓' */
3
+ icon: string;
4
+ /** Tooltip text. Defaults to icon. */
5
+ label?: string;
6
+ /** Visual variant. */
7
+ variant?: 'default' | 'success' | 'error';
8
+ /** Click handler. Receives the (possibly edited) code + lang. */
9
+ onclick: (code: string, lang: string) => void;
10
+ }
@@ -72,6 +72,7 @@
72
72
  import './notebook/notebook.svelte';
73
73
  // Agent browsers (registered as widgets for widget_display)
74
74
  import '../agent/RecipeBrowser.svelte';
75
+ import '../agent/ToolBrowser.svelte';
75
76
 
76
77
  /** Native widget types served as custom elements (`<auto-${type}>`). */
77
78
  const NATIVE_CUSTOM_ELEMENTS = new Set<string>([
@@ -85,6 +86,7 @@
85
86
  'notebook',
86
87
  // Agent browsers
87
88
  'recipe-browser',
89
+ 'tool-browser',
88
90
  ]);
89
91
 
90
92
  /** A vanilla renderer: returns cleanup or Promise thereof. Still used for
@@ -1,13 +1,16 @@
1
- import { callToolViaPostMessage } from '@webmcp-auto-ui/core';
2
- import type { CellExecutor, CellExecContext, CellResult, DataServerDescriptor } from '../shared.js';
1
+ import { canvas } from '@webmcp-auto-ui/sdk/canvas';
2
+ import { findCodeParamName, buildToolArgs } from '@webmcp-auto-ui/sdk';
3
+ import type { CellExecutor, CellExecContext, CellResult, DataServerDescriptor, DataServerTool } from '../shared.js';
3
4
 
4
5
  const PATTERN_PRIMARY = /^.*query_sql$/i;
5
6
  const PATTERN_FALLBACK = /^(query|run|execute)(_sql)?$/i;
6
7
 
7
- function findSqlTool(servers: DataServerDescriptor[]): string | null {
8
+ interface SqlHit { srv: DataServerDescriptor; tool: DataServerTool; }
9
+
10
+ function findSqlTool(servers: DataServerDescriptor[]): SqlHit | null {
8
11
  for (const p of [PATTERN_PRIMARY, PATTERN_FALLBACK]) {
9
12
  for (const srv of servers) {
10
- for (const t of srv.tools ?? []) if (p.test(t.name)) return t.name;
13
+ for (const t of srv.tools ?? []) if (p.test(t.name)) return { srv, tool: t };
11
14
  }
12
15
  }
13
16
  return null;
@@ -16,16 +19,27 @@ function findSqlTool(servers: DataServerDescriptor[]): string | null {
16
19
  export function createSqlExecutor(getServers: () => DataServerDescriptor[]): CellExecutor {
17
20
  return async (ctx: CellExecContext): Promise<CellResult> => {
18
21
  const startedAt = Date.now();
19
- const toolName = findSqlTool(getServers());
20
- if (!toolName) {
22
+ const hit = findSqlTool(getServers());
23
+ if (!hit) {
21
24
  return { ok: false, error: 'No SQL tool available on connected servers.', errorKind: 'schema', durationMs: Date.now() - startedAt };
22
25
  }
26
+ const { srv, tool } = hit;
23
27
  const sql = (ctx.cell.content ?? '').trim();
24
28
  if (!sql) return { ok: true, kind: 'empty', durationMs: Date.now() - startedAt };
25
29
 
30
+ // Build args from the tool's inputSchema:
31
+ // 1. Pick the code-carrying param (query / sql / statement / ...) via findCodeParamName.
32
+ // 2. Auto-infer required params (e.g. `schema` enum from FROM/JOIN regex).
33
+ // 3. Merge cell-level overrides from cell.args (parsed from `-- @meta {...}` line).
34
+ const codeParam = findCodeParamName(tool.inputSchema) ?? 'sql';
35
+ const auto = buildToolArgs(tool.inputSchema, codeParam, sql, 'sql');
36
+ const args: Record<string, unknown> = { ...auto, ...(ctx.cell.args ?? {}) };
37
+ // Code param is owned by the cell content, never overridable via @meta
38
+ args[codeParam] = sql;
39
+
26
40
  let raw: unknown;
27
41
  try {
28
- raw = await callToolViaPostMessage(toolName, { sql });
42
+ raw = await canvas.callTool(srv.name, tool.name, args);
29
43
  } catch (err) {
30
44
  return { ok: false, error: String((err as { message?: unknown })?.message ?? err), errorKind: 'runtime', durationMs: Date.now() - startedAt };
31
45
  }