dsh-db-mcp 0.6.4

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/src/mcp.js ADDED
@@ -0,0 +1,264 @@
1
+ /**
2
+ * A very small MCP client over stdio.
3
+ *
4
+ * The plugin owns the Python server instead of going through the Loader's
5
+ * `@deepseek-ai/dsh-mcp-client` row: the interpreter and the script live at
6
+ * machine-specific absolute paths that a static bundle patch cannot express, and
7
+ * owning the process lets the panel show its state and restart it after a
8
+ * datasource change.
9
+ *
10
+ * Wire format: newline-delimited JSON-RPC 2.0, one message per line, exactly what
11
+ * the official Python SDK speaks on stdio.
12
+ */
13
+
14
+ import { spawn } from 'node:child_process';
15
+
16
+ const DEFAULT_TIMEOUT_MS = 30_000;
17
+ const INIT_TIMEOUT_MS = 60_000;
18
+
19
+ export class McpError extends Error {
20
+ constructor(message, { code = 'mcp_error', data } = {}) {
21
+ super(message);
22
+ this.name = 'McpError';
23
+ this.code = code;
24
+ this.data = data;
25
+ }
26
+ }
27
+
28
+ export class McpClient {
29
+ #child = null;
30
+ #buffer = '';
31
+ #nextId = 1;
32
+ #pending = new Map();
33
+ #stderr = [];
34
+ #closed = false;
35
+ #exitInfo = null;
36
+ #onExit = null;
37
+
38
+ constructor({ command, args = [], cwd, env = {}, label = 'db-mcp' }) {
39
+ this.command = command;
40
+ this.args = args;
41
+ this.cwd = cwd;
42
+ this.env = env;
43
+ this.label = label;
44
+ this.serverInfo = null;
45
+ this.tools = [];
46
+ }
47
+
48
+ get running() {
49
+ return this.#child !== null && !this.#closed;
50
+ }
51
+
52
+ get exitInfo() {
53
+ return this.#exitInfo;
54
+ }
55
+
56
+ get stderrTail() {
57
+ return this.#stderr.slice(-40).join('');
58
+ }
59
+
60
+ async start() {
61
+ if (this.#child) return this;
62
+ const child = spawn(this.command, this.args, {
63
+ cwd: this.cwd,
64
+ env: { ...process.env, ...this.env },
65
+ stdio: ['pipe', 'pipe', 'pipe'],
66
+ windowsHide: true,
67
+ });
68
+ this.#child = child;
69
+ this.#closed = false;
70
+ this.#exitInfo = null;
71
+
72
+ child.stdout.setEncoding('utf8');
73
+ child.stdout.on('data', (chunk) => this.#onStdout(chunk));
74
+ child.stderr.setEncoding('utf8');
75
+ child.stderr.on('data', (chunk) => {
76
+ this.#stderr.push(String(chunk));
77
+ if (this.#stderr.length > 200) this.#stderr.shift();
78
+ });
79
+ child.on('error', (error) => {
80
+ this.#exitInfo = { code: null, signal: null, error: String(error) };
81
+ this.#failAll(new McpError(`cannot start ${this.command}: ${error.message}`, { code: 'spawn_error' }));
82
+ });
83
+ child.on('exit', (code, signal) => {
84
+ this.#closed = true;
85
+ this.#exitInfo = { code, signal };
86
+ this.#failAll(
87
+ new McpError(`${this.label} exited (code ${code ?? 'null'}${signal ? `, signal ${signal}` : ''})`, {
88
+ code: 'server_exited',
89
+ }),
90
+ );
91
+ const onExit = this.#onExit;
92
+ this.#onExit = null;
93
+ if (onExit) onExit(this.#exitInfo);
94
+ });
95
+
96
+ const init = await this.#request(
97
+ 'initialize',
98
+ {
99
+ protocolVersion: '2024-11-05',
100
+ capabilities: { tools: {} },
101
+ clientInfo: { name: 'dsh-db-mcp', version: '0.3.0' },
102
+ },
103
+ INIT_TIMEOUT_MS,
104
+ );
105
+ this.serverInfo = init?.serverInfo ?? null;
106
+ this.#notify('notifications/initialized', {});
107
+ const listed = await this.#request('tools/list', {}, INIT_TIMEOUT_MS);
108
+ this.tools = Array.isArray(listed?.tools) ? listed.tools : [];
109
+ return this;
110
+ }
111
+
112
+ async callTool(name, args = {}, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
113
+ const result = await this.#request('tools/call', { name, arguments: args }, timeoutMs);
114
+ const content = Array.isArray(result?.content) ? result.content : [];
115
+ const text = content
116
+ .filter((item) => item && item.type === 'text' && typeof item.text === 'string')
117
+ .map((item) => item.text)
118
+ .join('\n');
119
+ let parsed;
120
+ if (text) {
121
+ try {
122
+ parsed = JSON.parse(text);
123
+ } catch {
124
+ parsed = undefined;
125
+ }
126
+ }
127
+ return { ...result, text, parsed };
128
+ }
129
+
130
+ onExit(handler) {
131
+ this.#onExit = handler;
132
+ }
133
+
134
+ async stop() {
135
+ const child = this.#child;
136
+ if (!child) return;
137
+ this.#child = null;
138
+ this.#closed = true;
139
+ this.#failAll(new McpError(`${this.label} stopped`, { code: 'stopped' }));
140
+ await new Promise((resolve) => {
141
+ const timer = setTimeout(() => {
142
+ try {
143
+ child.kill('SIGKILL');
144
+ } catch {
145
+ /* already gone */
146
+ }
147
+ resolve();
148
+ }, 3000);
149
+ child.once('exit', () => {
150
+ clearTimeout(timer);
151
+ resolve();
152
+ });
153
+ try {
154
+ child.kill();
155
+ } catch {
156
+ clearTimeout(timer);
157
+ resolve();
158
+ }
159
+ });
160
+ }
161
+
162
+ // ------------------------------------------------------------------ wire
163
+
164
+ #onStdout(chunk) {
165
+ this.#buffer += chunk;
166
+ let index = this.#buffer.indexOf('\n');
167
+ while (index >= 0) {
168
+ const line = this.#buffer.slice(0, index).trim();
169
+ this.#buffer = this.#buffer.slice(index + 1);
170
+ if (line) this.#onLine(line);
171
+ index = this.#buffer.indexOf('\n');
172
+ }
173
+ }
174
+
175
+ #onLine(line) {
176
+ let message;
177
+ try {
178
+ message = JSON.parse(line);
179
+ } catch {
180
+ this.#stderr.push(`[non-json stdout] ${line}\n`);
181
+ return;
182
+ }
183
+ if (message && typeof message.id !== 'undefined' && !message.method) {
184
+ const pending = this.#pending.get(message.id);
185
+ if (!pending) return;
186
+ this.#pending.delete(message.id);
187
+ clearTimeout(pending.timer);
188
+ if (message.error) {
189
+ pending.reject(
190
+ new McpError(message.error.message || 'MCP request failed', {
191
+ code: message.error.code ?? 'mcp_error',
192
+ data: message.error.data,
193
+ }),
194
+ );
195
+ } else {
196
+ pending.resolve(message.result);
197
+ }
198
+ return;
199
+ }
200
+ if (message && message.method && typeof message.id !== 'undefined') {
201
+ this.#answerServerRequest(message);
202
+ }
203
+ // notifications are ignored
204
+ }
205
+
206
+ #answerServerRequest(message) {
207
+ const reply = (payload) => this.#send({ jsonrpc: '2.0', id: message.id, ...payload });
208
+ switch (message.method) {
209
+ case 'ping':
210
+ reply({ result: {} });
211
+ break;
212
+ case 'roots/list':
213
+ reply({ result: { roots: [] } });
214
+ break;
215
+ case 'sampling/createMessage':
216
+ reply({ error: { code: -32601, message: 'sampling is not supported by dsh-db-mcp' } });
217
+ break;
218
+ default:
219
+ reply({ error: { code: -32601, message: `unsupported method: ${message.method}` } });
220
+ }
221
+ }
222
+
223
+ #send(message) {
224
+ const child = this.#child;
225
+ if (!child || this.#closed || !child.stdin.writable) {
226
+ throw new McpError(`${this.label} is not running`, { code: 'not_running' });
227
+ }
228
+ child.stdin.write(`${JSON.stringify(message)}\n`);
229
+ }
230
+
231
+ #notify(method, params) {
232
+ try {
233
+ this.#send({ jsonrpc: '2.0', method, params });
234
+ } catch {
235
+ /* the exit handler reports the real failure */
236
+ }
237
+ }
238
+
239
+ #request(method, params, timeoutMs) {
240
+ const id = this.#nextId++;
241
+ return new Promise((resolve, reject) => {
242
+ const timer = setTimeout(() => {
243
+ this.#pending.delete(id);
244
+ reject(new McpError(`${method} timed out after ${timeoutMs}ms`, { code: 'timeout' }));
245
+ }, timeoutMs);
246
+ this.#pending.set(id, { resolve, reject, timer });
247
+ try {
248
+ this.#send({ jsonrpc: '2.0', id, method, params });
249
+ } catch (error) {
250
+ this.#pending.delete(id);
251
+ clearTimeout(timer);
252
+ reject(error);
253
+ }
254
+ });
255
+ }
256
+
257
+ #failAll(error) {
258
+ for (const [, pending] of this.#pending) {
259
+ clearTimeout(pending.timer);
260
+ pending.reject(error);
261
+ }
262
+ this.#pending.clear();
263
+ }
264
+ }
package/src/tools.js ADDED
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Bridges the Python MCP server into DSH tools.
3
+ *
4
+ * The Python process is spawned lazily on first use, its `tools/list` becomes
5
+ * `mcp__db__<name>` on the host `tools` service, and every call is forwarded over
6
+ * MCP. When a datasource changes the bridge can be restarted; tool registrations
7
+ * are disposed and rebuilt around the new process.
8
+ */
9
+
10
+ import { existsSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ // Same cache-busting marker as host.js, so editing this file takes effect on the
14
+ // next `POST /api/reload` without restarting DSH.
15
+ const GENERATION = new URL(import.meta.url).searchParams.get('v') || '0';
16
+ const { McpClient, McpError } = await import(new URL(`./mcp.js?v=${GENERATION}`, import.meta.url).href);
17
+
18
+ export const TOOL_PREFIX = 'mcp__db__';
19
+ const TOOL_TIMEOUT_MS = 60_000;
20
+
21
+ /** Candidate interpreters, most specific first. */
22
+ export function pythonCandidates(config, runtimeDir) {
23
+ const list = [];
24
+ if (config.pythonPath) list.push(config.pythonPath);
25
+ list.push(join(runtimeDir, '.venv', 'Scripts', 'python.exe')); // Windows venv
26
+ list.push(join(runtimeDir, '.venv', 'bin', 'python')); // POSIX venv
27
+ list.push('python');
28
+ list.push('python3');
29
+ return list.filter((entry, index) => entry && list.indexOf(entry) === index);
30
+ }
31
+
32
+ export function serverEntry(packageDir) {
33
+ return join(packageDir, 'server', 'main.py');
34
+ }
35
+
36
+ /** Render one MCP tool result as readable text. */
37
+ export function renderResult(value) {
38
+ if (!value || typeof value !== 'object') return typeof value === 'string' ? value : JSON.stringify(value);
39
+ if (value.ok === false && value.error) {
40
+ const { code = 'error', message = 'unknown error', hint } = value.error;
41
+ return `error [${code}]: ${message}${hint ? `\n${hint}` : ''}`;
42
+ }
43
+ if (Array.isArray(value.columns) && Array.isArray(value.rows)) {
44
+ const head = value.columns.join(' | ');
45
+ const body = value.rows
46
+ .slice(0, 50)
47
+ .map((row) => row.map((cell) => (cell === null ? 'NULL' : String(cell))).join(' | '))
48
+ .join('\n');
49
+ const more = value.rows.length > 50 ? `\n… ${value.rows.length - 50} more row(s)` : '';
50
+ const note = value.truncated ? `\ntruncated at ${value.rowCount} row(s)` : '';
51
+ return `${head}\n${'-'.repeat(Math.min(head.length, 120))}\n${body}${more}${note}\n(${value.rowCount} row(s), ${value.elapsedMs}ms)`;
52
+ }
53
+ if (Array.isArray(value.tables)) {
54
+ const lines = value.tables.map((table) => `${table.schema ? `${table.schema}.` : ''}${table.name} [${table.type}]`);
55
+ const more = value.truncated ? `\n… truncated (${value.total} total)` : '';
56
+ return lines.join('\n') + more || 'no tables';
57
+ }
58
+ if (Array.isArray(value.datasources)) {
59
+ return (
60
+ value.datasources
61
+ .map((source) => `${source.id} ${source.kind} ${source.target}${source.readOnly ? ' [read-only]' : ''}${source.isDefault ? ' [default]' : ''}`)
62
+ .join('\n') || 'no datasources configured'
63
+ );
64
+ }
65
+ if (typeof value.ddl === 'string' && value.ddl.trim()) {
66
+ return value.ddl.trim();
67
+ }
68
+ if (Array.isArray(value.columns) && value.table) {
69
+ return value.columns.map((column) => `${column.name} ${column.type}${column.primaryKey ? ' PK' : ''}${column.nullable ? '' : ' NOT NULL'}`).join('\n');
70
+ }
71
+ return JSON.stringify(value, null, 2);
72
+ }
73
+
74
+ /**
75
+ * MCP emits `anyOf: [{type:'string'}, {type:'null'}]` plus `default: null` and a
76
+ * `title` for every optional argument. That is valid JSON Schema but noisy for the
77
+ * model, so each property is reduced to its real branch before registration.
78
+ */
79
+ function simplifyProperty(property) {
80
+ if (!property || typeof property !== 'object') return property;
81
+ const { title: _title, default: _default, ...rest } = property;
82
+ if (Array.isArray(rest.anyOf)) {
83
+ const branch = rest.anyOf.find((item) => item && item.type && item.type !== 'null') ?? { type: 'string' };
84
+ const merged = { ...branch };
85
+ if (rest.description) merged.description = rest.description;
86
+ return simplifyProperty(merged);
87
+ }
88
+ if (Array.isArray(rest.oneOf)) {
89
+ const branch = rest.oneOf.find((item) => item && item.type && item.type !== 'null') ?? { type: 'string' };
90
+ const merged = { ...branch };
91
+ if (rest.description) merged.description = rest.description;
92
+ return simplifyProperty(merged);
93
+ }
94
+ if (rest.type === 'array' && rest.items) rest.items = simplifyProperty(rest.items);
95
+ return rest;
96
+ }
97
+
98
+ function safeParameters(inputSchema) {
99
+ const schema = inputSchema && typeof inputSchema === 'object' ? inputSchema : {};
100
+ const source = schema.properties && typeof schema.properties === 'object' ? schema.properties : {};
101
+ const properties = {};
102
+ for (const [key, value] of Object.entries(source)) properties[key] = simplifyProperty(value);
103
+ const parameters = { type: 'object', properties, additionalProperties: schema.additionalProperties === true };
104
+ if (Array.isArray(schema.required) && schema.required.length > 0) {
105
+ parameters.required = schema.required.filter((name) => Object.prototype.hasOwnProperty.call(properties, name));
106
+ }
107
+ return parameters;
108
+ }
109
+
110
+ /**
111
+ * @param {object} options
112
+ * @param {any} options.ctx host context (needs `tools` and `logger`/`console`)
113
+ * @param {object} options.config plugin config
114
+ * @param {string} options.packageDir plugin package root (holds `server/`)
115
+ * @param {string} options.runtimeDir data directory (holds `.venv`, registry)
116
+ */
117
+ export function createBridge({
118
+ ctx,
119
+ config = {},
120
+ packageDir,
121
+ runtimeDir,
122
+ log = () => {},
123
+ settings = null,
124
+ audit = null,
125
+ }) {
126
+ let client = null;
127
+ let starting = null;
128
+ let disposers = [];
129
+ // Names of the tools currently published for this bridge instance.
130
+ let registeredNames = [];
131
+ // Bumped by restart()/dispose(): an in-flight boot started under an older epoch
132
+ // must not register its tools into a host that is already being torn down.
133
+ let bootEpoch = 0;
134
+ let state = { phase: 'idle', error: null, tools: [], serverInfo: null, startedAt: null };
135
+
136
+ function toolsService() {
137
+ if (!ctx) return undefined;
138
+ try {
139
+ if (typeof ctx.get === 'function') return ctx.get('tools');
140
+ return ctx.tools;
141
+ } catch {
142
+ return undefined;
143
+ }
144
+ }
145
+
146
+ function status() {
147
+ return {
148
+ phase: state.phase,
149
+ error: state.error,
150
+ toolCount: state.tools.length,
151
+ tools: state.tools,
152
+ serverInfo: state.serverInfo,
153
+ startedAt: state.startedAt,
154
+ command: state.command ?? null,
155
+ python: state.python ?? null,
156
+ exit: client?.exitInfo ?? null,
157
+ stderr: client?.stderrTail ?? '',
158
+ };
159
+ }
160
+
161
+ function auditEntry(name, args, startedAt, outcome, error) {
162
+ if (!audit) return;
163
+ const call = args && typeof args === 'object' ? args : {};
164
+ const entry = {
165
+ at: new Date().toISOString(),
166
+ tool: `${TOOL_PREFIX}${name}`,
167
+ datasource: typeof call.datasource === 'string' ? call.datasource : null,
168
+ ok: !error && !(outcome && outcome.isError === true),
169
+ ms: Date.now() - startedAt,
170
+ };
171
+ if (typeof call.sql === 'string') entry.sql = call.sql.slice(0, 2000);
172
+ if (typeof call.table === 'string') entry.table = call.table;
173
+ if (error) entry.error = String((error && error.message) || error);
174
+ else {
175
+ const parsed = outcome && outcome.parsed;
176
+ const rows = parsed && (parsed.rowCount ?? (Array.isArray(parsed.rows) ? parsed.rows.length : undefined));
177
+ if (typeof rows === 'number') entry.rows = rows;
178
+ if (parsed && parsed.error && parsed.error.code) entry.code = parsed.error.code;
179
+ }
180
+ try {
181
+ void audit(entry);
182
+ } catch {
183
+ // never let auditing break a call
184
+ }
185
+ }
186
+
187
+ async function invoke(name, args = {}, options = {}) {
188
+ const startedAt = Date.now();
189
+ const audited = options.audit !== false;
190
+ try {
191
+ await ensure();
192
+ const active = client;
193
+ if (!active || !active.running) {
194
+ throw new McpError('the database MCP server is not running', { code: 'not_running' });
195
+ }
196
+ const result = await active.callTool(name, args, { timeoutMs: TOOL_TIMEOUT_MS });
197
+ if (audited) auditEntry(name, args, startedAt, result, null);
198
+ return result;
199
+ } catch (error) {
200
+ if (audited) auditEntry(name, args, startedAt, null, error);
201
+ throw error;
202
+ }
203
+ }
204
+
205
+ function registerTools(tools) {
206
+ const service = toolsService();
207
+ if (!service || typeof service.register !== 'function') {
208
+ throw new McpError(
209
+ 'the host `tools` service is unavailable, so the MCP tools cannot be published',
210
+ { code: 'no_tools_service' },
211
+ );
212
+ }
213
+ const names = tools.map((tool) => `${TOOL_PREFIX}${tool.name}`);
214
+ // Restarting the Python process must not churn the tool catalog: the agent
215
+ // resolves these names through the registry, so a dispose/re-register pair
216
+ // would make a live call fail with `unknown tool`. Keep the registrations
217
+ // and let `execute` follow the current client instead.
218
+ if (disposers.length === names.length && names.every((name, index) => registeredNames[index] === name)) {
219
+ state.tools = tools.map((tool) => tool.name);
220
+ return;
221
+ }
222
+ disposeTools();
223
+ disposers = tools.map((tool) => {
224
+ const name = `${TOOL_PREFIX}${tool.name}`;
225
+ const description = `${tool.description || tool.name}\n\n(PostgreSQL/MySQL/Oracle/SQLite via the DSH Database panel; source is chosen with the \`datasource\` argument.)`;
226
+ return service.register({
227
+ name,
228
+ description,
229
+ parameters: safeParameters(tool.inputSchema),
230
+ timeoutMs: TOOL_TIMEOUT_MS,
231
+ isConcurrencySafe: () => true,
232
+ output: {
233
+ schema: { type: 'object', additionalProperties: true },
234
+ render: (_args, value) => [{ type: 'text', text: renderResult(value) }],
235
+ },
236
+ execute: async (args) => {
237
+ const result = await invoke(tool.name, args ?? {});
238
+ if (result.parsed) return result.parsed;
239
+ if (result.text) return { ok: true, text: result.text };
240
+ return { ok: true, content: result.content ?? [] };
241
+ },
242
+ });
243
+ });
244
+ registeredNames = names;
245
+ state.tools = tools.map((tool) => tool.name);
246
+ }
247
+
248
+ function disposeTools() {
249
+ registeredNames = [];
250
+ for (const dispose of disposers.splice(0)) {
251
+ try {
252
+ dispose();
253
+ } catch (error) {
254
+ log(`dispose tool failed: ${error}`);
255
+ }
256
+ }
257
+ state.tools = [];
258
+ }
259
+
260
+ async function boot() {
261
+ const myEpoch = ++bootEpoch;
262
+ const entry = serverEntry(packageDir);
263
+ if (!existsSync(entry)) {
264
+ throw new McpError(`server/main.py not found under ${packageDir}`, { code: 'server_missing' });
265
+ }
266
+ const current = settings ? await settings().catch(() => ({ allowWrite: false })) : { allowWrite: false };
267
+ const allowWrite = current && current.allowWrite === true;
268
+ const candidates = pythonCandidates(config, runtimeDir);
269
+ let lastError = null;
270
+ for (const candidate of candidates) {
271
+ if (myEpoch !== bootEpoch) throw new McpError('start superseded', { code: 'superseded' });
272
+ if (candidate.includes('\\') || candidate.includes('/')) {
273
+ if (!existsSync(candidate)) continue;
274
+ }
275
+ const next = new McpClient({
276
+ command: candidate,
277
+ args: [entry],
278
+ cwd: join(packageDir, 'server'),
279
+ env: {
280
+ PYTHONIOENCODING: 'utf-8',
281
+ PYTHONUTF8: '1',
282
+ DB_MCP_RUNTIME: runtimeDir,
283
+ DB_MCP_ALLOW_WRITE: allowWrite ? '1' : '0',
284
+ },
285
+ label: `db-mcp (${candidate})`,
286
+ });
287
+ try {
288
+ await next.start();
289
+ } catch (error) {
290
+ lastError = error;
291
+ await next.stop();
292
+ log(`start failed with ${candidate}: ${error?.message ?? error}`);
293
+ continue;
294
+ }
295
+ if (myEpoch !== bootEpoch) {
296
+ await next.stop();
297
+ throw new McpError('start superseded', { code: 'superseded' });
298
+ }
299
+ try {
300
+ registerTools(next.tools);
301
+ } catch (error) {
302
+ disposeTools();
303
+ await next.stop();
304
+ state = {
305
+ ...state,
306
+ phase: 'failed',
307
+ error: `tool registration failed: ${error?.message ?? error}`,
308
+ python: candidate,
309
+ command: [candidate, entry].join(' '),
310
+ serverInfo: next.serverInfo,
311
+ };
312
+ throw new McpError(`tool registration failed: ${error?.message ?? error}`, { code: 'tool_registration' });
313
+ }
314
+ client = next;
315
+ state = {
316
+ phase: 'ready',
317
+ error: null,
318
+ tools: next.tools.map((tool) => `${TOOL_PREFIX}${tool.name}`),
319
+ serverInfo: next.serverInfo,
320
+ startedAt: new Date().toISOString(),
321
+ command: [candidate, entry].join(' '),
322
+ python: candidate,
323
+ };
324
+ next.onExit((info) => {
325
+ state = { ...state, phase: 'exited', error: `server exited (code ${info?.code ?? 'null'})` };
326
+ // Keep the published tools: the agent keeps a stable catalog and the
327
+ // next execute() call restarts the process through ensure().
328
+ });
329
+ log(`started ${next.tools.length} tool(s) with ${candidate}`);
330
+ return status();
331
+ }
332
+ state = { ...state, phase: 'failed', error: lastError ? String(lastError.message ?? lastError) : 'no usable Python interpreter' };
333
+ throw lastError ?? new McpError('no usable Python interpreter', { code: 'python_missing' });
334
+ }
335
+
336
+ async function ensure() {
337
+ if (client?.running && state.phase === 'ready') return status();
338
+ if (starting) return starting;
339
+ starting = boot().finally(() => {
340
+ starting = null;
341
+ });
342
+ return starting;
343
+ }
344
+
345
+ async function restart() {
346
+ bootEpoch += 1;
347
+ if (client) {
348
+ await client.stop();
349
+ client = null;
350
+ }
351
+ state = { ...state, phase: 'idle', error: null };
352
+ return ensure();
353
+ }
354
+
355
+ async function call(name, args = {}, options = {}) {
356
+ const result = await invoke(name, args, options);
357
+ return { tool: name, value: result.parsed ?? null, text: result.text, isError: result.isError === true };
358
+ }
359
+
360
+ async function dispose() {
361
+ bootEpoch += 1;
362
+ starting = null;
363
+ disposeTools();
364
+ if (client) {
365
+ await client.stop();
366
+ client = null;
367
+ }
368
+ state = { ...state, phase: 'idle', tools: [] };
369
+ }
370
+
371
+ return { ensure, restart, dispose, status, call };
372
+ }