@deepsql/mcp 0.11.0 → 0.13.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.
@@ -1,16 +1,125 @@
1
1
  "use strict";
2
2
 
3
+ /**
4
+ * `deepsql mcp` — two modes:
5
+ *
6
+ * 1. Bare invocation (`deepsql mcp`) runs the stdio MCP server using the
7
+ * saved auth token. Editors point at this command so the token never
8
+ * has to be embedded in their config files.
9
+ *
10
+ * 2. `deepsql mcp config --install --for <editor>` writes a DeepSQL entry
11
+ * into the editor's MCP config file (JSON or TOML, with a `.bak.<ts>`
12
+ * backup of the previous contents) AND installs a "DBA consult" skill
13
+ * so the agent auto-triggers on database work without having to be
14
+ * told. `--print` emits the snippet only. `--force` overwrites an
15
+ * existing entry without complaint. `--no-skill` skips the skill
16
+ * install (config only).
17
+ *
18
+ * Supported editors and skill install paths:
19
+ * claude-code → ~/.claude/settings.json + ~/.claude/skills/deepsql/SKILL.md
20
+ * claude-desktop → ~/Library/.../Claude/... + (no skill — Desktop has no skills surface)
21
+ * cursor → ~/.cursor/mcp.json + ~/.cursor/rules/deepsql.mdc
22
+ * codex → ~/.codex/config.toml + ~/.codex/AGENTS.md (user-global, append-merge)
23
+ */
24
+
25
+ const fs = require("node:fs");
26
+ const os = require("node:os");
3
27
  const path = require("node:path");
4
28
  const { spawn } = require("node:child_process");
5
29
 
6
30
  const { resolveSession } = require("./_session");
7
31
 
8
- /**
9
- * Launch the existing Phase 1 stdio MCP server with the saved auth token
10
- * injected via env vars. The server is unchanged — this command is a thin
11
- * shim that means editor configs no longer need to embed a raw token.
12
- */
13
- async function run(opts) {
32
+ const SKILL_HEADER_MARKER = "<!-- BEGIN DEEPSQL DBA CONSULT SKILL -->";
33
+ const SKILL_FOOTER_MARKER = "<!-- END DEEPSQL DBA CONSULT SKILL -->";
34
+
35
+ const EDITORS = {
36
+ "claude-code": {
37
+ format: "json",
38
+ path: () => path.join(os.homedir(), ".claude", "settings.json"),
39
+ key: "mcpServers",
40
+ skill: {
41
+ kind: "file",
42
+ path: () => path.join(os.homedir(), ".claude", "skills", "deepsql", "SKILL.md"),
43
+ frontmatter: () => [
44
+ "---",
45
+ "name: deepsql",
46
+ "description: Use DeepSQL MCP tools whenever the user is doing database work — adding tables, writing migrations, designing schema, modeling new entities, or running SQL queries. Encodes the \"DBA consult\" pattern: get brain context, schema, business rules, and anti-patterns BEFORE generating any DDL or non-trivial SQL. Triggers on phrases like \"add a table\", \"create a column\", \"write a migration\", \"schema change\", \"design a model\", \"query the database\", \"SQL\", \"ORM model\", \"foreign key\", \"index\".",
47
+ "---",
48
+ "",
49
+ ].join("\n"),
50
+ },
51
+ },
52
+ "claude-desktop": {
53
+ format: "json",
54
+ path: () => claudeDesktopPath(),
55
+ key: "mcpServers",
56
+ skill: null, // Claude Desktop has no skills surface today
57
+ },
58
+ "cursor": {
59
+ format: "json",
60
+ path: () => path.join(os.homedir(), ".cursor", "mcp.json"),
61
+ key: "mcpServers",
62
+ skill: {
63
+ kind: "file",
64
+ path: () => path.join(os.homedir(), ".cursor", "rules", "deepsql.mdc"),
65
+ frontmatter: () => [
66
+ "---",
67
+ "description: DeepSQL DBA consult — call DeepSQL's MCP tools BEFORE generating any DDL, migration, or non-trivial SQL. Get brain context, schema, business rules, and anti-patterns first; then narrate findings to the user before proposing schema.",
68
+ "alwaysApply: false",
69
+ "globs:",
70
+ " - \"**/*.sql\"",
71
+ " - \"**/migrations/**\"",
72
+ " - \"**/schema/**\"",
73
+ " - \"**/models/**\"",
74
+ " - \"**/*.prisma\"",
75
+ " - \"**/entities/**\"",
76
+ "---",
77
+ "",
78
+ ].join("\n"),
79
+ },
80
+ },
81
+ "codex": {
82
+ format: "toml",
83
+ path: () => path.join(os.homedir(), ".codex", "config.toml"),
84
+ key: "mcp_servers",
85
+ skill: {
86
+ kind: "agents-append", // Codex reads AGENTS.md; we append a guarded section
87
+ path: () => path.join(os.homedir(), ".codex", "AGENTS.md"),
88
+ frontmatter: () => "",
89
+ },
90
+ },
91
+ };
92
+
93
+ const SERVER_ENTRY_NAME = "deepsql";
94
+ const SERVER_ENTRY = { command: "deepsql", args: ["mcp"] };
95
+
96
+ /** Read the shared skill body from disk. Bundled in the npm tarball. */
97
+ function loadSkillBody() {
98
+ const bodyPath = path.resolve(__dirname, "..", "..", "skills", "SKILL_BODY.md");
99
+ try {
100
+ return fs.readFileSync(bodyPath, "utf8");
101
+ } catch (err) {
102
+ throw new Error(
103
+ `Could not read the bundled skill body at ${bodyPath}: ${err.message}. `
104
+ + "Is this an incomplete install of @deepsql/mcp?",
105
+ );
106
+ }
107
+ }
108
+
109
+ async function run(opts, io = {}) {
110
+ const sub = opts.positional[0];
111
+ if (!sub) return runServer(opts, io);
112
+ if (sub === "config") {
113
+ return runConfig({ ...opts, positional: opts.positional.slice(1) }, io);
114
+ }
115
+ throw new Error(
116
+ `Unknown mcp subcommand: ${sub}. Try \`deepsql mcp\` (run server) or \`deepsql mcp config --install --for <editor>\`.`,
117
+ );
118
+ }
119
+
120
+ // ─── server (bare `deepsql mcp`) ────────────────────────────────────────────
121
+
122
+ function runServer(opts) {
14
123
  const session = resolveSession(opts);
15
124
  const serverPath = path.resolve(__dirname, "..", "..", "deepsql-phase1-server.js");
16
125
  const env = {
@@ -28,4 +137,327 @@ async function run(opts) {
28
137
  });
29
138
  }
30
139
 
31
- module.exports = { run };
140
+ // ─── `deepsql mcp config` ───────────────────────────────────────────────────
141
+
142
+ async function runConfig(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
143
+ const editor = opts.for;
144
+ if (!editor) {
145
+ throw new Error(
146
+ `Pass --for <editor>. Supported: ${Object.keys(EDITORS).join(", ")}.`,
147
+ );
148
+ }
149
+ const target = EDITORS[editor];
150
+ if (!target) {
151
+ throw new Error(
152
+ `Unknown editor "${editor}". Supported: ${Object.keys(EDITORS).join(", ")}.`,
153
+ );
154
+ }
155
+ if (!opts.install && !opts.print) {
156
+ throw new Error("Pass --install (write the config) or --print (emit the snippet only).");
157
+ }
158
+
159
+ if (opts.print) {
160
+ stdout.write(`# MCP server config snippet\n`);
161
+ stdout.write(`${renderSnippet(target.format, target.key)}\n\n`);
162
+ if (target.skill && !opts.noSkill) {
163
+ stdout.write(`# Skill — installed to ${target.skill.path()}\n`);
164
+ stdout.write(`${renderSkill(target.skill)}\n`);
165
+ } else if (!target.skill) {
166
+ stdout.write(`# (no skill — ${editor} has no skills surface)\n`);
167
+ }
168
+ return;
169
+ }
170
+
171
+ 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
+ });
178
+
179
+ if (configResult.skipped) {
180
+ stderr.write(
181
+ `DeepSQL is already configured in ${configPath}. Re-run with --force to overwrite, or --print to see the snippet.\n`,
182
+ );
183
+ } else {
184
+ stdout.write(`Installed DeepSQL MCP entry in ${configPath}.\n`);
185
+ if (configResult.backupPath) {
186
+ stdout.write(` backup: ${configResult.backupPath}\n`);
187
+ }
188
+ }
189
+
190
+ // Skill install: default-on. Users can opt out with --no-skill if they
191
+ // only want the MCP server config wired up.
192
+ if (target.skill && !opts.noSkill) {
193
+ const skillResult = installSkill({
194
+ skill: target.skill,
195
+ force: !!opts.force,
196
+ });
197
+ if (skillResult.skipped) {
198
+ stderr.write(
199
+ `DeepSQL skill already installed at ${target.skill.path()}. Re-run with --force to overwrite.\n`,
200
+ );
201
+ } else {
202
+ stdout.write(`Installed DeepSQL DBA-consult skill at ${target.skill.path()}.\n`);
203
+ if (skillResult.backupPath) {
204
+ stdout.write(` backup: ${skillResult.backupPath}\n`);
205
+ }
206
+ }
207
+ } else if (!target.skill && !opts.noSkill) {
208
+ stderr.write(
209
+ `Note: ${editor} has no skills surface, so no skill was installed. The MCP server config still works.\n`,
210
+ );
211
+ }
212
+
213
+ stdout.write("Restart the editor for the changes to take effect.\n");
214
+ }
215
+
216
+ // ─── skill install ──────────────────────────────────────────────────────────
217
+
218
+ /**
219
+ * Render the skill content (frontmatter + shared body). Used by --print and
220
+ * by installSkill().
221
+ */
222
+ function renderSkill(skill) {
223
+ const body = loadSkillBody();
224
+ return `${skill.frontmatter()}${body}`;
225
+ }
226
+
227
+ /**
228
+ * Install the skill file for an editor.
229
+ *
230
+ * Two flavors:
231
+ *
232
+ * - `kind: "file"` (Claude Code, Cursor)
233
+ * Writes a standalone skill file at `skill.path()`. If a file is
234
+ * already there and the content differs, back up before overwriting.
235
+ * If a file is there and matches, skip silently.
236
+ *
237
+ * - `kind: "agents-append"` (Codex CLI's AGENTS.md)
238
+ * Appends our skill content to the user's AGENTS.md as a guarded
239
+ * section (between `<!-- BEGIN DEEPSQL ... -->` markers) so the user's
240
+ * own instructions are preserved. Re-running replaces only the guarded
241
+ * section.
242
+ *
243
+ * Returns:
244
+ * { written: true, backupPath?: "...bak.<ts>" } on success
245
+ * { skipped: true } on no-op (content matches)
246
+ */
247
+ function installSkill({ skill, force }) {
248
+ const skillPath = skill.path();
249
+ ensureParentDir(skillPath);
250
+ const desiredContent = renderSkill(skill);
251
+
252
+ if (skill.kind === "agents-append") {
253
+ return upsertGuardedSection({
254
+ filePath: skillPath,
255
+ content: desiredContent,
256
+ force,
257
+ });
258
+ }
259
+
260
+ // Standalone-file flavor.
261
+ const exists = fs.existsSync(skillPath);
262
+ const original = exists ? fs.readFileSync(skillPath, "utf8") : null;
263
+ if (exists && original === desiredContent && !force) {
264
+ return { skipped: true };
265
+ }
266
+ let backupPath = null;
267
+ if (exists && original !== desiredContent) {
268
+ backupPath = `${skillPath}.bak.${Date.now()}`;
269
+ fs.writeFileSync(backupPath, original, { mode: 0o600 });
270
+ }
271
+ fs.writeFileSync(skillPath, desiredContent, { mode: 0o600 });
272
+ return { written: true, backupPath };
273
+ }
274
+
275
+ /**
276
+ * Append-or-replace a guarded section in a Markdown file. Preserves the
277
+ * user's content around the section. Re-running rewrites only the section
278
+ * between our markers.
279
+ */
280
+ function upsertGuardedSection({ filePath, content, force }) {
281
+ const exists = fs.existsSync(filePath);
282
+ const original = exists ? fs.readFileSync(filePath, "utf8") : "";
283
+
284
+ const guarded = `${SKILL_HEADER_MARKER}\n${content}\n${SKILL_FOOTER_MARKER}`;
285
+ const startIdx = original.indexOf(SKILL_HEADER_MARKER);
286
+
287
+ let next;
288
+ if (startIdx === -1) {
289
+ // First install: append (with a blank line before if there's existing
290
+ // content).
291
+ const trimmed = original.replace(/\s+$/, "");
292
+ next = trimmed ? `${trimmed}\n\n${guarded}\n` : `${guarded}\n`;
293
+ } else {
294
+ const endIdx = original.indexOf(SKILL_FOOTER_MARKER, startIdx);
295
+ if (endIdx === -1) {
296
+ throw new Error(
297
+ `Found ${SKILL_HEADER_MARKER} in ${filePath} but no matching footer. `
298
+ + "The file is in a half-edited state; fix it by hand and re-run.",
299
+ );
300
+ }
301
+ const before = original.slice(0, startIdx).replace(/\s+$/, "");
302
+ const after = original.slice(endIdx + SKILL_FOOTER_MARKER.length).replace(/^\s+/, "");
303
+ if (!force) {
304
+ // Compare the existing section to the new one.
305
+ const existingSection = original.slice(startIdx, endIdx + SKILL_FOOTER_MARKER.length);
306
+ if (existingSection === guarded) {
307
+ return { skipped: true };
308
+ }
309
+ }
310
+ next = [before, guarded, after].filter(Boolean).join("\n\n").replace(/\s+$/, "") + "\n";
311
+ }
312
+
313
+ let backupPath = null;
314
+ if (exists && original !== next) {
315
+ backupPath = `${filePath}.bak.${Date.now()}`;
316
+ fs.writeFileSync(backupPath, original, { mode: 0o600 });
317
+ }
318
+ fs.writeFileSync(filePath, next, { mode: 0o600 });
319
+ return { written: true, backupPath };
320
+ }
321
+
322
+ // ─── snippet rendering ──────────────────────────────────────────────────────
323
+
324
+ function renderSnippet(format, key) {
325
+ if (format === "toml") {
326
+ return [
327
+ `[${key}.${SERVER_ENTRY_NAME}]`,
328
+ `command = "${SERVER_ENTRY.command}"`,
329
+ `args = ${JSON.stringify(SERVER_ENTRY.args)}`,
330
+ ].join("\n");
331
+ }
332
+ return JSON.stringify(
333
+ { [key]: { [SERVER_ENTRY_NAME]: SERVER_ENTRY } },
334
+ null,
335
+ 2,
336
+ );
337
+ }
338
+
339
+ // ─── merge logic ────────────────────────────────────────────────────────────
340
+
341
+ /**
342
+ * Merge a DeepSQL entry into the editor's config file in-place. JSON files
343
+ * are parsed, mutated, and re-serialized. TOML files use a minimal "find or
344
+ * append" pass on the `[<key>.deepsql]` section header — we don't ship a
345
+ * TOML parser dep just for two lines of config.
346
+ *
347
+ * Returns:
348
+ * { written: true, backupPath: "...bak.<ts>" } on success
349
+ * { skipped: true } if an entry already exists and --force was not set
350
+ */
351
+ function mergeConfig({ format, key, configPath, force }) {
352
+ ensureParentDir(configPath);
353
+
354
+ const exists = fs.existsSync(configPath);
355
+ const original = exists ? fs.readFileSync(configPath, "utf8") : null;
356
+
357
+ let next;
358
+ if (format === "json") {
359
+ next = mergeJson(original, key, force);
360
+ } else if (format === "toml") {
361
+ next = mergeToml(original, key, force);
362
+ } else {
363
+ throw new Error(`Unsupported config format: ${format}`);
364
+ }
365
+
366
+ if (next.skipped) return { skipped: true };
367
+
368
+ let backupPath = null;
369
+ if (exists && original !== next.text) {
370
+ backupPath = `${configPath}.bak.${Date.now()}`;
371
+ fs.writeFileSync(backupPath, original, { mode: 0o600 });
372
+ }
373
+ fs.writeFileSync(configPath, next.text, { mode: 0o600 });
374
+ return { written: true, backupPath };
375
+ }
376
+
377
+ function mergeJson(originalText, key, force) {
378
+ let parsed = {};
379
+ if (originalText && originalText.trim()) {
380
+ try {
381
+ parsed = JSON.parse(originalText);
382
+ } catch (err) {
383
+ throw new Error(
384
+ `Refusing to overwrite ${err.message ? "malformed JSON" : "config"}. Fix the file by hand first or pass --path <other>.`,
385
+ );
386
+ }
387
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
388
+ throw new Error("Top-level value must be a JSON object.");
389
+ }
390
+ }
391
+ if (!parsed[key] || typeof parsed[key] !== "object" || Array.isArray(parsed[key])) {
392
+ parsed[key] = {};
393
+ }
394
+ if (parsed[key][SERVER_ENTRY_NAME] && !force) {
395
+ return { skipped: true };
396
+ }
397
+ parsed[key][SERVER_ENTRY_NAME] = SERVER_ENTRY;
398
+ return { text: `${JSON.stringify(parsed, null, 2)}\n` };
399
+ }
400
+
401
+ function mergeToml(originalText, key, force) {
402
+ const text = originalText || "";
403
+ const header = `[${key}.${SERVER_ENTRY_NAME}]`;
404
+ const block = renderSnippet("toml", key);
405
+
406
+ // Section already present?
407
+ const lines = text.split(/\r?\n/);
408
+ const startIdx = lines.findIndex((l) => l.trim() === header);
409
+ if (startIdx >= 0) {
410
+ if (!force) return { skipped: true };
411
+ // Replace [header ... up-to-next-header).
412
+ let endIdx = lines.length;
413
+ for (let i = startIdx + 1; i < lines.length; i++) {
414
+ if (/^\s*\[/.test(lines[i])) {
415
+ endIdx = i;
416
+ break;
417
+ }
418
+ }
419
+ const before = lines.slice(0, startIdx).join("\n").replace(/\s+$/, "");
420
+ const after = lines.slice(endIdx).join("\n").replace(/^\s+/, "");
421
+ const joined = [before, block, after].filter(Boolean).join("\n\n");
422
+ return { text: `${joined}\n` };
423
+ }
424
+
425
+ // Append. Keep a blank line between any existing content and our block.
426
+ const trimmed = text.replace(/\s+$/, "");
427
+ const joined = trimmed ? `${trimmed}\n\n${block}\n` : `${block}\n`;
428
+ return { text: joined };
429
+ }
430
+
431
+ function ensureParentDir(filePath) {
432
+ const dir = path.dirname(filePath);
433
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
434
+ }
435
+
436
+ function claudeDesktopPath() {
437
+ // macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
438
+ // Windows: %APPDATA%/Claude/claude_desktop_config.json
439
+ // Linux: ~/.config/Claude/claude_desktop_config.json (best-effort; Claude
440
+ // Desktop isn't officially shipped on Linux yet but the pattern
441
+ // matches the XDG default).
442
+ if (process.platform === "darwin") {
443
+ return path.join(os.homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
444
+ }
445
+ if (process.platform === "win32" && process.env.APPDATA) {
446
+ return path.join(process.env.APPDATA, "Claude", "claude_desktop_config.json");
447
+ }
448
+ return path.join(os.homedir(), ".config", "Claude", "claude_desktop_config.json");
449
+ }
450
+
451
+ module.exports = {
452
+ run,
453
+ // Exported for tests — small surface, all pure-ish.
454
+ mergeJson,
455
+ mergeToml,
456
+ renderSnippet,
457
+ mergeConfig,
458
+ installSkill,
459
+ upsertGuardedSection,
460
+ renderSkill,
461
+ loadSkillBody,
462
+ EDITORS,
463
+ };