@deepsql/mcp 0.13.0 → 0.14.0

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.
@@ -25,7 +25,7 @@
25
25
  const fs = require("node:fs");
26
26
  const os = require("node:os");
27
27
  const path = require("node:path");
28
- const { spawn } = require("node:child_process");
28
+ const { spawn, spawnSync } = require("node:child_process");
29
29
 
30
30
  const { resolveSession } = require("./_session");
31
31
 
@@ -35,8 +35,28 @@ const SKILL_FOOTER_MARKER = "<!-- END DEEPSQL DBA CONSULT SKILL -->";
35
35
  const EDITORS = {
36
36
  "claude-code": {
37
37
  format: "json",
38
- path: () => path.join(os.homedir(), ".claude", "settings.json"),
38
+ // Claude Code's user-scope MCP server list lives at the top of
39
+ // ~/.claude.json under `mcpServers`. The older ~/.claude/settings.json
40
+ // path that this installer used to target is for permissions/hooks/
41
+ // model settings — Claude Code never reads MCP entries from there.
42
+ //
43
+ // Preferred install path: shell out to `claude mcp add --scope user`
44
+ // (the official CLI handles future storage changes for us). If
45
+ // `claude` isn't on PATH (e.g. CI, SSH boxes), we fall back to
46
+ // writing ~/.claude.json directly via the standard JSON merge.
47
+ path: () => path.join(os.homedir(), ".claude.json"),
39
48
  key: "mcpServers",
49
+ cli: {
50
+ binary: "claude",
51
+ // `claude mcp list` is the smoke test: exit 0 means we have a
52
+ // working Claude Code CLI we can delegate to. We resolve the
53
+ // command/args via builder fns so SERVER_ENTRY_NAME isn't
54
+ // forward-referenced.
55
+ detect: () => ["mcp", "list"],
56
+ // `claude mcp add --scope user <name> <cmd> [args…]`
57
+ add: () => ["mcp", "add", "--scope", "user", SERVER_ENTRY_NAME, SERVER_ENTRY.command, ...SERVER_ENTRY.args],
58
+ remove: () => ["mcp", "remove", SERVER_ENTRY_NAME, "--scope", "user"],
59
+ },
40
60
  skill: {
41
61
  kind: "file",
42
62
  path: () => path.join(os.homedir(), ".claude", "skills", "deepsql", "SKILL.md"),
@@ -168,20 +188,44 @@ async function runConfig(opts, { stdout = process.stdout, stderr = process.stder
168
188
  return;
169
189
  }
170
190
 
191
+ // Editor-CLI delegation: if the editor ships its own MCP CLI (Claude
192
+ // Code's `claude mcp add`) AND it's on PATH, use it. Falls back to
193
+ // direct file write if the CLI isn't available — useful for CI and
194
+ // SSH boxes that have @deepsql/mcp installed but no editor.
171
195
  const configPath = opts.path || target.path();
172
- const configResult = mergeConfig({
173
- format: target.format,
174
- key: target.key,
175
- configPath,
176
- force: !!opts.force,
177
- });
196
+ let configResult;
197
+ let writtenVia = configPath;
198
+ const usingCli = target.cli && !opts.path && hasEditorCli(target.cli);
199
+ if (usingCli) {
200
+ try {
201
+ configResult = installViaCli({ cli: target.cli, force: !!opts.force });
202
+ writtenVia = `\`${target.cli.binary} mcp add --scope user\` (user-scope in ~/.claude.json)`;
203
+ } catch (err) {
204
+ stderr.write(
205
+ `\`${target.cli.binary} mcp add\` failed: ${err.message}\nFalling back to direct file write at ${configPath}.\n`,
206
+ );
207
+ configResult = mergeConfig({
208
+ format: target.format,
209
+ key: target.key,
210
+ configPath,
211
+ force: !!opts.force,
212
+ });
213
+ }
214
+ } else {
215
+ configResult = mergeConfig({
216
+ format: target.format,
217
+ key: target.key,
218
+ configPath,
219
+ force: !!opts.force,
220
+ });
221
+ }
178
222
 
179
223
  if (configResult.skipped) {
180
224
  stderr.write(
181
- `DeepSQL is already configured in ${configPath}. Re-run with --force to overwrite, or --print to see the snippet.\n`,
225
+ `DeepSQL MCP entry already present (${writtenVia}). Re-run with --force to overwrite, or --print to see the snippet.\n`,
182
226
  );
183
227
  } else {
184
- stdout.write(`Installed DeepSQL MCP entry in ${configPath}.\n`);
228
+ stdout.write(`Installed DeepSQL MCP entry: ${writtenVia}.\n`);
185
229
  if (configResult.backupPath) {
186
230
  stdout.write(` backup: ${configResult.backupPath}\n`);
187
231
  }
@@ -374,6 +418,111 @@ function mergeConfig({ format, key, configPath, force }) {
374
418
  return { written: true, backupPath };
375
419
  }
376
420
 
421
+ // ─── Editor-CLI delegation (Claude Code) ───────────────────────────────────
422
+ //
423
+ // For editors that ship their own MCP-config CLI (currently just Claude
424
+ // Code via `claude mcp add`), we prefer delegating instead of writing
425
+ // config files ourselves. Three reasons:
426
+ //
427
+ // 1. The on-disk format is documented as private and has changed once
428
+ // already (settings.json → .claude.json at the top level under
429
+ // mcpServers, vs. local-scope's keyed-by-project layout).
430
+ // 2. The CLI handles scopes (user / project / local) correctly. Writing
431
+ // to top-level mcpServers in ~/.claude.json mostly works for user
432
+ // scope but it's brittle; the CLI is the contract.
433
+ // 3. If Anthropic changes the layout again, the CLI keeps working
434
+ // without a deepsql release.
435
+ //
436
+ // We only delegate when `claude mcp list` actually exits 0 — that's our
437
+ // proof of life. Otherwise we fall back to direct JSON write.
438
+
439
+ /**
440
+ * Return true if `claude mcp list` works on this machine. Tested by
441
+ * spawning `claude` with `mcp list` and a 5-second timeout — fast enough
442
+ * to keep the installer snappy, slow enough that a normal cold start
443
+ * (loading config, listing servers) reliably completes.
444
+ *
445
+ * `spawnFn` is overridable for tests.
446
+ */
447
+ function hasEditorCli({ binary, detect }, { spawnFn = spawnSync } = {}) {
448
+ if (!binary) return false;
449
+ try {
450
+ const result = spawnFn(binary, detect(), {
451
+ stdio: ["ignore", "pipe", "pipe"],
452
+ timeout: 5000,
453
+ });
454
+ return result && result.status === 0;
455
+ } catch {
456
+ return false;
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Run `claude mcp list` and check whether `deepsql` is already in the
462
+ * output. Used to decide between "no-op", "remove + add" (force), and
463
+ * "add" paths.
464
+ */
465
+ function isAlreadyConfiguredViaCli({ binary, detect }, { spawnFn = spawnSync } = {}) {
466
+ try {
467
+ const result = spawnFn(binary, detect(), {
468
+ stdio: ["ignore", "pipe", "pipe"],
469
+ timeout: 5000,
470
+ encoding: "utf8",
471
+ });
472
+ if (!result || result.status !== 0) return false;
473
+ const stdout = String(result.stdout || "");
474
+ // `claude mcp list` lines look like:
475
+ // deepsql: deepsql mcp - ✓ Connected
476
+ // deepsql: /path/... - ✗ Failed to connect
477
+ // We match the entry name at the start of a line, followed by `:`,
478
+ // which is stable across both states.
479
+ return new RegExp(`(?:^|\\n)\\s*${SERVER_ENTRY_NAME}\\s*:`).test(stdout);
480
+ } catch {
481
+ return false;
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Install the DeepSQL MCP entry by delegating to the editor's CLI.
487
+ *
488
+ * Returns:
489
+ * { written: true, viaCli: "<binary>" } on success
490
+ * { skipped: true } when already present and !force
491
+ *
492
+ * On --force: remove first (best-effort; ignore "not found" exits), then
493
+ * add. The CLI handles backup/atomicity for us.
494
+ */
495
+ function installViaCli({ cli, force }, { spawnFn = spawnSync } = {}) {
496
+ if (isAlreadyConfiguredViaCli(cli, { spawnFn }) && !force) {
497
+ return { skipped: true };
498
+ }
499
+
500
+ if (force && isAlreadyConfiguredViaCli(cli, { spawnFn })) {
501
+ // Best-effort remove — if the entry was in a different scope, the
502
+ // remove may fail, but that's the user's problem to disambiguate.
503
+ spawnFn(cli.binary, cli.remove(), {
504
+ stdio: ["ignore", "pipe", "pipe"],
505
+ timeout: 5000,
506
+ });
507
+ }
508
+
509
+ const addResult = spawnFn(cli.binary, cli.add(), {
510
+ stdio: ["ignore", "pipe", "pipe"],
511
+ timeout: 10000,
512
+ encoding: "utf8",
513
+ });
514
+ if (!addResult || addResult.status !== 0) {
515
+ const stderr = String(addResult && addResult.stderr ? addResult.stderr : "").trim();
516
+ throw new Error(
517
+ `\`${cli.binary} ${cli.add().join(" ")}\` exited with status ${addResult ? addResult.status : "?"}.${
518
+ stderr ? ` stderr: ${stderr}` : ""
519
+ }`,
520
+ );
521
+ }
522
+
523
+ return { written: true, viaCli: cli.binary };
524
+ }
525
+
377
526
  function mergeJson(originalText, key, force) {
378
527
  let parsed = {};
379
528
  if (originalText && originalText.trim()) {
@@ -459,5 +608,8 @@ module.exports = {
459
608
  upsertGuardedSection,
460
609
  renderSkill,
461
610
  loadSkillBody,
611
+ hasEditorCli,
612
+ isAlreadyConfiguredViaCli,
613
+ installViaCli,
462
614
  EDITORS,
463
615
  };
@@ -15,6 +15,9 @@ const {
15
15
  upsertGuardedSection,
16
16
  renderSkill,
17
17
  loadSkillBody,
18
+ hasEditorCli,
19
+ isAlreadyConfiguredViaCli,
20
+ installViaCli,
18
21
  EDITORS,
19
22
  } = require("./mcp");
20
23
 
@@ -221,6 +224,148 @@ test("EDITORS catalog exposes the four supported targets with sensible defaults"
221
224
  }
222
225
  });
223
226
 
227
+ test("claude-code falls back to ~/.claude.json (not ~/.claude/settings.json) for user-scope MCP", () => {
228
+ // ~/.claude/settings.json is for permissions/hooks/model settings;
229
+ // Claude Code reads MCP from ~/.claude.json at the top level. Older
230
+ // installer versions had this wrong, which surfaced as
231
+ // "deepsql_* tools aren't loaded in this session" reports from users.
232
+ const p = EDITORS["claude-code"].path();
233
+ assert.ok(p.endsWith(".claude.json"), `unexpected claude-code fallback path: ${p}`);
234
+ assert.equal(/\.claude\/settings\.json$/.test(p), false);
235
+ });
236
+
237
+ test("claude-code prefers CLI delegation (cli descriptor present)", () => {
238
+ const cli = EDITORS["claude-code"].cli;
239
+ assert.ok(cli, "claude-code must carry a CLI delegation descriptor");
240
+ assert.equal(cli.binary, "claude");
241
+ assert.deepEqual(cli.detect(), ["mcp", "list"]);
242
+ // `claude mcp add --scope user deepsql deepsql mcp`
243
+ assert.deepEqual(cli.add(), ["mcp", "add", "--scope", "user", "deepsql", "deepsql", "mcp"]);
244
+ assert.deepEqual(cli.remove(), ["mcp", "remove", "deepsql", "--scope", "user"]);
245
+ });
246
+
247
+ test("cursor and codex do not carry a CLI delegation descriptor (no equivalent tooling)", () => {
248
+ assert.equal(EDITORS["cursor"].cli, undefined);
249
+ assert.equal(EDITORS["codex"].cli, undefined);
250
+ assert.equal(EDITORS["claude-desktop"].cli, undefined);
251
+ });
252
+
253
+ // ─── CLI delegation helpers ────────────────────────────────────────────────
254
+
255
+ function fakeSpawn(table) {
256
+ // table is an array of { match: { args: [...] }, result: { status, stdout?, stderr? } }
257
+ // First match wins; unmatched calls fail loudly so tests catch silent fallthroughs.
258
+ return (binary, args) => {
259
+ for (const entry of table) {
260
+ if (!entry.match) return entry.result;
261
+ const a = entry.match.args;
262
+ if (a.length === args.length && a.every((v, i) => v === args[i])) {
263
+ return entry.result;
264
+ }
265
+ }
266
+ throw new Error(`fakeSpawn: no match for ${binary} ${args.join(" ")}`);
267
+ };
268
+ }
269
+
270
+ test("hasEditorCli returns true when the detect command exits 0", () => {
271
+ const spawnFn = fakeSpawn([{ match: { args: ["mcp", "list"] }, result: { status: 0, stdout: "" } }]);
272
+ assert.equal(hasEditorCli(EDITORS["claude-code"].cli, { spawnFn }), true);
273
+ });
274
+
275
+ test("hasEditorCli returns false when the binary errors / isn't found", () => {
276
+ const spawnFn = () => { throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); };
277
+ assert.equal(hasEditorCli(EDITORS["claude-code"].cli, { spawnFn }), false);
278
+ });
279
+
280
+ test("hasEditorCli returns false when the binary exits non-zero", () => {
281
+ const spawnFn = fakeSpawn([{ match: { args: ["mcp", "list"] }, result: { status: 1 } }]);
282
+ assert.equal(hasEditorCli(EDITORS["claude-code"].cli, { spawnFn }), false);
283
+ });
284
+
285
+ test("isAlreadyConfiguredViaCli matches 'deepsql:' line in claude mcp list output", () => {
286
+ const stdout = [
287
+ "claude.ai Gmail: https://gmailmcp.googleapis.com/mcp/v1 - ✓ Connected",
288
+ "claude.ai Slack: https://mcp.slack.com/mcp - ✓ Connected",
289
+ "deepsql: deepsql mcp - ✓ Connected",
290
+ "",
291
+ ].join("\n");
292
+ const spawnFn = fakeSpawn([{ match: { args: ["mcp", "list"] }, result: { status: 0, stdout } }]);
293
+ assert.equal(isAlreadyConfiguredViaCli(EDITORS["claude-code"].cli, { spawnFn }), true);
294
+ });
295
+
296
+ test("isAlreadyConfiguredViaCli also matches a failed-to-connect deepsql entry (the bug we hit)", () => {
297
+ // The user's actual diagnostic showed this exact form. The detector
298
+ // must still classify it as "already configured" so --force can clean
299
+ // it up before re-adding.
300
+ const stdout = "deepsql: node /old/stale/path/server.js - ✗ Failed to connect\n";
301
+ const spawnFn = fakeSpawn([{ match: { args: ["mcp", "list"] }, result: { status: 0, stdout } }]);
302
+ assert.equal(isAlreadyConfiguredViaCli(EDITORS["claude-code"].cli, { spawnFn }), true);
303
+ });
304
+
305
+ test("isAlreadyConfiguredViaCli returns false when deepsql isn't in the list", () => {
306
+ const stdout = "claude.ai Gmail: ... - ✓ Connected\n";
307
+ const spawnFn = fakeSpawn([{ match: { args: ["mcp", "list"] }, result: { status: 0, stdout } }]);
308
+ assert.equal(isAlreadyConfiguredViaCli(EDITORS["claude-code"].cli, { spawnFn }), false);
309
+ });
310
+
311
+ test("installViaCli runs `claude mcp add --scope user` when not already present", () => {
312
+ const calls = [];
313
+ const spawnFn = (binary, args) => {
314
+ calls.push({ binary, args });
315
+ if (args[0] === "mcp" && args[1] === "list") return { status: 0, stdout: "" };
316
+ if (args[0] === "mcp" && args[1] === "add") return { status: 0, stdout: "Added MCP server deepsql" };
317
+ throw new Error(`unexpected: ${args.join(" ")}`);
318
+ };
319
+ const result = installViaCli({ cli: EDITORS["claude-code"].cli, force: false }, { spawnFn });
320
+ assert.equal(result.written, true);
321
+ assert.equal(result.viaCli, "claude");
322
+ // First call: list (the isAlreadyConfigured check). Second: add.
323
+ assert.equal(calls.length, 2);
324
+ assert.deepEqual(calls[1].args, ["mcp", "add", "--scope", "user", "deepsql", "deepsql", "mcp"]);
325
+ });
326
+
327
+ test("installViaCli skips when deepsql is already configured and --force is off", () => {
328
+ const calls = [];
329
+ const spawnFn = (binary, args) => {
330
+ calls.push(args);
331
+ return { status: 0, stdout: "deepsql: deepsql mcp - ✓ Connected\n" };
332
+ };
333
+ const result = installViaCli({ cli: EDITORS["claude-code"].cli, force: false }, { spawnFn });
334
+ assert.equal(result.skipped, true);
335
+ // We should not have attempted `mcp add` after detecting the entry.
336
+ assert.equal(calls.some((a) => a[0] === "mcp" && a[1] === "add"), false);
337
+ });
338
+
339
+ test("installViaCli with --force removes the existing entry then re-adds", () => {
340
+ const calls = [];
341
+ const spawnFn = (binary, args) => {
342
+ calls.push(args);
343
+ if (args[0] === "mcp" && args[1] === "list") return { status: 0, stdout: "deepsql: foo - ✗ Failed\n" };
344
+ if (args[0] === "mcp" && args[1] === "remove") return { status: 0 };
345
+ if (args[0] === "mcp" && args[1] === "add") return { status: 0 };
346
+ throw new Error(`unexpected: ${args.join(" ")}`);
347
+ };
348
+ installViaCli({ cli: EDITORS["claude-code"].cli, force: true }, { spawnFn });
349
+ // Sequence: list (skip check) → list (force check) → remove → add.
350
+ const ops = calls.map((a) => `${a[0]} ${a[1]}`);
351
+ assert.ok(ops.includes("mcp remove"), `expected mcp remove, got: ${ops.join(", ")}`);
352
+ assert.ok(ops.includes("mcp add"));
353
+ // Remove must come before add.
354
+ assert.ok(ops.indexOf("mcp remove") < ops.indexOf("mcp add"));
355
+ });
356
+
357
+ test("installViaCli throws a clean error when `claude mcp add` exits non-zero (surfaces stderr)", () => {
358
+ const spawnFn = (binary, args) => {
359
+ if (args[1] === "list") return { status: 0, stdout: "" };
360
+ if (args[1] === "add") return { status: 1, stderr: "permission denied: ~/.claude.json" };
361
+ throw new Error("unexpected");
362
+ };
363
+ assert.throws(
364
+ () => installViaCli({ cli: EDITORS["claude-code"].cli, force: false }, { spawnFn }),
365
+ /exited with status 1.*permission denied/,
366
+ );
367
+ });
368
+
224
369
  // ─── skill body + per-editor metadata ──────────────────────────────────────
225
370
 
226
371
  test("loadSkillBody returns the bundled SKILL_BODY.md content", () => {