@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.
@@ -0,0 +1,384 @@
1
+ "use strict";
2
+
3
+ const test = require("node:test");
4
+ const assert = require("node:assert/strict");
5
+ const fs = require("node:fs");
6
+ const os = require("node:os");
7
+ const path = require("node:path");
8
+
9
+ const {
10
+ mergeJson,
11
+ mergeToml,
12
+ renderSnippet,
13
+ mergeConfig,
14
+ installSkill,
15
+ upsertGuardedSection,
16
+ renderSkill,
17
+ loadSkillBody,
18
+ EDITORS,
19
+ } = require("./mcp");
20
+
21
+ function withTempDir(fn) {
22
+ return async () => {
23
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "deepsql-mcp-skill-test-"));
24
+ try {
25
+ await fn(dir);
26
+ } finally {
27
+ fs.rmSync(dir, { recursive: true, force: true });
28
+ }
29
+ };
30
+ }
31
+
32
+ // ─── snippet rendering ──────────────────────────────────────────────────────
33
+
34
+ test("renderSnippet emits valid JSON for the JSON editors", () => {
35
+ const text = renderSnippet("json", "mcpServers");
36
+ const parsed = JSON.parse(text);
37
+ assert.deepEqual(parsed, {
38
+ mcpServers: { deepsql: { command: "deepsql", args: ["mcp"] } },
39
+ });
40
+ });
41
+
42
+ test("renderSnippet emits a parseable [mcp_servers.deepsql] section for codex", () => {
43
+ const text = renderSnippet("toml", "mcp_servers");
44
+ assert.match(text, /^\[mcp_servers\.deepsql\]/m);
45
+ assert.match(text, /command = "deepsql"/);
46
+ assert.match(text, /args = \["mcp"\]/);
47
+ });
48
+
49
+ // ─── JSON merge ─────────────────────────────────────────────────────────────
50
+
51
+ test("mergeJson creates the file when nothing exists", () => {
52
+ const r = mergeJson(null, "mcpServers", false);
53
+ const parsed = JSON.parse(r.text);
54
+ assert.equal(parsed.mcpServers.deepsql.command, "deepsql");
55
+ });
56
+
57
+ test("mergeJson preserves siblings under mcpServers", () => {
58
+ const original = JSON.stringify({
59
+ mcpServers: {
60
+ "other-tool": { command: "node", args: ["server.js"] },
61
+ },
62
+ });
63
+ const r = mergeJson(original, "mcpServers", false);
64
+ const parsed = JSON.parse(r.text);
65
+ assert.equal(parsed.mcpServers["other-tool"].command, "node");
66
+ assert.equal(parsed.mcpServers.deepsql.command, "deepsql");
67
+ });
68
+
69
+ test("mergeJson preserves top-level siblings (e.g. permissions, env)", () => {
70
+ const original = JSON.stringify({ env: { FOO: "bar" }, permissions: ["x"] });
71
+ const r = mergeJson(original, "mcpServers", false);
72
+ const parsed = JSON.parse(r.text);
73
+ assert.deepEqual(parsed.env, { FOO: "bar" });
74
+ assert.deepEqual(parsed.permissions, ["x"]);
75
+ assert.ok(parsed.mcpServers.deepsql);
76
+ });
77
+
78
+ test("mergeJson skips when an existing deepsql entry exists and --force is off", () => {
79
+ const original = JSON.stringify({
80
+ mcpServers: { deepsql: { command: "stale", args: [] } },
81
+ });
82
+ const r = mergeJson(original, "mcpServers", false);
83
+ assert.equal(r.skipped, true);
84
+ });
85
+
86
+ test("mergeJson overwrites with --force", () => {
87
+ const original = JSON.stringify({
88
+ mcpServers: { deepsql: { command: "stale", args: [] } },
89
+ });
90
+ const r = mergeJson(original, "mcpServers", true);
91
+ const parsed = JSON.parse(r.text);
92
+ assert.equal(parsed.mcpServers.deepsql.command, "deepsql");
93
+ });
94
+
95
+ test("mergeJson refuses to silently overwrite malformed JSON", () => {
96
+ assert.throws(() => mergeJson("{this is not json", "mcpServers", false), /malformed JSON|config/);
97
+ });
98
+
99
+ test("mergeJson rejects a top-level array", () => {
100
+ assert.throws(() => mergeJson("[1,2,3]", "mcpServers", false), /must be a JSON object/);
101
+ });
102
+
103
+ // ─── TOML merge ─────────────────────────────────────────────────────────────
104
+
105
+ test("mergeToml appends when no section exists", () => {
106
+ const r = mergeToml("", "mcp_servers", false);
107
+ assert.match(r.text, /^\[mcp_servers\.deepsql\]/m);
108
+ });
109
+
110
+ test("mergeToml preserves unrelated sections above and below", () => {
111
+ const original = [
112
+ "[runtime]",
113
+ "verbose = true",
114
+ "",
115
+ "[mcp_servers.other]",
116
+ 'command = "elsewhere"',
117
+ "",
118
+ ].join("\n");
119
+ const r = mergeToml(original, "mcp_servers", false);
120
+ assert.match(r.text, /\[runtime\]/);
121
+ assert.match(r.text, /verbose = true/);
122
+ assert.match(r.text, /\[mcp_servers\.other\]/);
123
+ assert.match(r.text, /\[mcp_servers\.deepsql\]/);
124
+ });
125
+
126
+ test("mergeToml skips an existing deepsql section without --force", () => {
127
+ const original = '[mcp_servers.deepsql]\ncommand = "stale"\nargs = []\n';
128
+ const r = mergeToml(original, "mcp_servers", false);
129
+ assert.equal(r.skipped, true);
130
+ });
131
+
132
+ test("mergeToml replaces an existing deepsql section with --force, leaving neighbors alone", () => {
133
+ const original = [
134
+ "[mcp_servers.deepsql]",
135
+ 'command = "stale"',
136
+ "args = []",
137
+ "",
138
+ "[mcp_servers.other]",
139
+ 'command = "elsewhere"',
140
+ "",
141
+ ].join("\n");
142
+ const r = mergeToml(original, "mcp_servers", true);
143
+ assert.match(r.text, /command = "deepsql"/);
144
+ assert.equal(/command = "stale"/.test(r.text), false);
145
+ assert.match(r.text, /\[mcp_servers\.other\]/, "neighbor section must survive");
146
+ assert.match(r.text, /command = "elsewhere"/);
147
+ });
148
+
149
+ // ─── filesystem integration ────────────────────────────────────────────────
150
+
151
+ test("mergeConfig writes the JSON file (no original, no backup)", withTempDir((dir) => {
152
+ const configPath = path.join(dir, "mcp.json");
153
+ const result = mergeConfig({
154
+ format: "json", key: "mcpServers", configPath, force: false,
155
+ });
156
+ assert.equal(result.written, true);
157
+ assert.equal(result.backupPath, null);
158
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
159
+ assert.equal(parsed.mcpServers.deepsql.command, "deepsql");
160
+ }));
161
+
162
+ test("mergeConfig backs up the existing file when it actually changes", withTempDir((dir) => {
163
+ const configPath = path.join(dir, "mcp.json");
164
+ fs.writeFileSync(configPath, JSON.stringify({ mcpServers: { other: { command: "x" } } }));
165
+ const result = mergeConfig({
166
+ format: "json", key: "mcpServers", configPath, force: false,
167
+ });
168
+ assert.equal(result.written, true);
169
+ assert.ok(result.backupPath, "backup path must be set when content changed");
170
+ assert.ok(fs.existsSync(result.backupPath));
171
+ const restored = JSON.parse(fs.readFileSync(result.backupPath, "utf8"));
172
+ assert.equal(restored.mcpServers.other.command, "x", "backup must contain pre-merge content");
173
+ }));
174
+
175
+ test("mergeConfig returns skipped=true when deepsql already there and --force off", withTempDir((dir) => {
176
+ const configPath = path.join(dir, "mcp.json");
177
+ fs.writeFileSync(configPath, JSON.stringify({
178
+ mcpServers: { deepsql: { command: "deepsql", args: ["mcp"] } },
179
+ }));
180
+ const result = mergeConfig({
181
+ format: "json", key: "mcpServers", configPath, force: false,
182
+ });
183
+ assert.equal(result.skipped, true);
184
+ }));
185
+
186
+ test("mergeConfig writes TOML to ~/.codex/config.toml layout", withTempDir((dir) => {
187
+ const configPath = path.join(dir, "config.toml");
188
+ fs.writeFileSync(configPath, "[runtime]\nverbose = true\n");
189
+ const result = mergeConfig({
190
+ format: "toml", key: "mcp_servers", configPath, force: false,
191
+ });
192
+ assert.equal(result.written, true);
193
+ const text = fs.readFileSync(configPath, "utf8");
194
+ assert.match(text, /\[runtime\]/);
195
+ assert.match(text, /\[mcp_servers\.deepsql\]/);
196
+ }));
197
+
198
+ test("mergeConfig creates parent directories that don't exist yet", withTempDir((dir) => {
199
+ const configPath = path.join(dir, "deep", "nested", "mcp.json");
200
+ const result = mergeConfig({
201
+ format: "json", key: "mcpServers", configPath, force: false,
202
+ });
203
+ assert.equal(result.written, true);
204
+ assert.ok(fs.existsSync(configPath));
205
+ }));
206
+
207
+ // ─── editor catalog ────────────────────────────────────────────────────────
208
+
209
+ test("EDITORS catalog exposes the four supported targets with sensible defaults", () => {
210
+ for (const editor of ["claude-code", "claude-desktop", "cursor", "codex"]) {
211
+ const e = EDITORS[editor];
212
+ assert.ok(e, `missing editor: ${editor}`);
213
+ assert.match(e.format, /^(json|toml)$/);
214
+ assert.ok(typeof e.path === "function");
215
+ const p = e.path();
216
+ assert.ok(p && typeof p === "string" && p.length > 0);
217
+ }
218
+ assert.equal(EDITORS.codex.format, "toml");
219
+ for (const editor of ["claude-code", "claude-desktop", "cursor"]) {
220
+ assert.equal(EDITORS[editor].format, "json");
221
+ }
222
+ });
223
+
224
+ // ─── skill body + per-editor metadata ──────────────────────────────────────
225
+
226
+ test("loadSkillBody returns the bundled SKILL_BODY.md content", () => {
227
+ const body = loadSkillBody();
228
+ assert.ok(body.length > 500, "skill body should not be empty");
229
+ assert.match(body, /DBA consult/i, "body should mention the DBA consult framing");
230
+ assert.match(body, /get_brain_context/, "body should reference the brain-context tool call");
231
+ assert.match(body, /confirmMutation/, "body should reference the mutation confirm flow");
232
+ });
233
+
234
+ test("each supported editor either has a skill descriptor or explicitly opts out", () => {
235
+ for (const [name, editor] of Object.entries(EDITORS)) {
236
+ if (editor.skill === null) {
237
+ // Explicit opt-out (claude-desktop today).
238
+ assert.equal(name, "claude-desktop", `unexpected null skill on ${name}`);
239
+ continue;
240
+ }
241
+ assert.ok(editor.skill, `${name} needs a skill descriptor or explicit null`);
242
+ assert.match(editor.skill.kind, /^(file|agents-append)$/);
243
+ assert.ok(typeof editor.skill.path === "function");
244
+ assert.ok(typeof editor.skill.frontmatter === "function");
245
+ }
246
+ });
247
+
248
+ test("renderSkill prepends Claude-Code-style YAML frontmatter naming the skill", () => {
249
+ const text = renderSkill(EDITORS["claude-code"].skill);
250
+ assert.match(text, /^---\nname: deepsql\n/);
251
+ assert.match(text, /description:.*DBA consult/i);
252
+ assert.match(text, /\n---\n\n#/, "frontmatter must be terminated before the body");
253
+ });
254
+
255
+ test("renderSkill prepends Cursor-style frontmatter with globs and alwaysApply=false", () => {
256
+ const text = renderSkill(EDITORS["cursor"].skill);
257
+ assert.match(text, /^---\n/);
258
+ assert.match(text, /alwaysApply: false/);
259
+ assert.match(text, /globs:/);
260
+ assert.match(text, /migrations/);
261
+ assert.match(text, /\.prisma/);
262
+ });
263
+
264
+ test("renderSkill for codex emits the bare body (the AGENTS.md wrapper adds guards)", () => {
265
+ const text = renderSkill(EDITORS["codex"].skill);
266
+ // No frontmatter for codex — AGENTS.md is plain markdown, the
267
+ // upsertGuardedSection wrapper adds the BEGIN/END markers.
268
+ assert.equal(/^---/.test(text), false);
269
+ assert.match(text, /^# DeepSQL/);
270
+ });
271
+
272
+ // ─── installSkill (standalone-file flavor: claude-code, cursor) ────────────
273
+
274
+ test("installSkill writes Claude Code SKILL.md at the configured path", withTempDir(async (dir) => {
275
+ const skillPath = path.join(dir, "claude", "skills", "deepsql", "SKILL.md");
276
+ const skill = {
277
+ kind: "file",
278
+ path: () => skillPath,
279
+ frontmatter: EDITORS["claude-code"].skill.frontmatter,
280
+ };
281
+ const r = installSkill({ skill, force: false });
282
+ assert.equal(r.written, true);
283
+ assert.equal(r.backupPath, null, "no backup when file didn't exist before");
284
+ const written = fs.readFileSync(skillPath, "utf8");
285
+ assert.match(written, /^---\nname: deepsql\n/);
286
+ assert.match(written, /DBA consult/);
287
+ }));
288
+
289
+ test("installSkill is idempotent — second call without --force is a no-op", withTempDir(async (dir) => {
290
+ const skillPath = path.join(dir, "SKILL.md");
291
+ const skill = {
292
+ kind: "file",
293
+ path: () => skillPath,
294
+ frontmatter: EDITORS["claude-code"].skill.frontmatter,
295
+ };
296
+ const r1 = installSkill({ skill, force: false });
297
+ assert.equal(r1.written, true);
298
+ const r2 = installSkill({ skill, force: false });
299
+ assert.equal(r2.skipped, true, "should not rewrite identical content");
300
+ }));
301
+
302
+ test("installSkill backs up a divergent skill file before overwriting with --force", withTempDir(async (dir) => {
303
+ const skillPath = path.join(dir, "SKILL.md");
304
+ fs.writeFileSync(skillPath, "stale content the user wrote by hand\n");
305
+ const skill = {
306
+ kind: "file",
307
+ path: () => skillPath,
308
+ frontmatter: EDITORS["claude-code"].skill.frontmatter,
309
+ };
310
+ const r = installSkill({ skill, force: true });
311
+ assert.equal(r.written, true);
312
+ assert.ok(r.backupPath, "must back up the file when content differs");
313
+ const backup = fs.readFileSync(r.backupPath, "utf8");
314
+ assert.match(backup, /stale content/);
315
+ }));
316
+
317
+ test("installSkill writes Cursor .mdc with globs", withTempDir(async (dir) => {
318
+ const rulePath = path.join(dir, "rules", "deepsql.mdc");
319
+ const skill = {
320
+ kind: "file",
321
+ path: () => rulePath,
322
+ frontmatter: EDITORS["cursor"].skill.frontmatter,
323
+ };
324
+ installSkill({ skill, force: false });
325
+ const written = fs.readFileSync(rulePath, "utf8");
326
+ assert.match(written, /alwaysApply: false/);
327
+ assert.match(written, /migrations/);
328
+ }));
329
+
330
+ // ─── upsertGuardedSection (codex AGENTS.md flavor) ─────────────────────────
331
+
332
+ test("upsertGuardedSection appends a guarded section to a new AGENTS.md", withTempDir(async (dir) => {
333
+ const file = path.join(dir, "AGENTS.md");
334
+ const r = upsertGuardedSection({ filePath: file, content: "hello world", force: false });
335
+ assert.equal(r.written, true);
336
+ const text = fs.readFileSync(file, "utf8");
337
+ assert.match(text, /<!-- BEGIN DEEPSQL DBA CONSULT SKILL -->/);
338
+ assert.match(text, /<!-- END DEEPSQL DBA CONSULT SKILL -->/);
339
+ assert.match(text, /hello world/);
340
+ }));
341
+
342
+ test("upsertGuardedSection preserves the user's existing AGENTS.md content", withTempDir(async (dir) => {
343
+ const file = path.join(dir, "AGENTS.md");
344
+ fs.writeFileSync(file, "# My agent instructions\n\nDon't bug me on weekends.\n");
345
+ upsertGuardedSection({ filePath: file, content: "deepsql skill content", force: false });
346
+ const text = fs.readFileSync(file, "utf8");
347
+ assert.match(text, /# My agent instructions/, "user's pre-existing content must be preserved");
348
+ assert.match(text, /Don't bug me on weekends/);
349
+ assert.match(text, /deepsql skill content/);
350
+ }));
351
+
352
+ test("upsertGuardedSection replaces only the guarded section on re-install", withTempDir(async (dir) => {
353
+ const file = path.join(dir, "AGENTS.md");
354
+ fs.writeFileSync(file, "# Top of file\n\nUser stuff.\n");
355
+ upsertGuardedSection({ filePath: file, content: "v1 content", force: false });
356
+ upsertGuardedSection({ filePath: file, content: "v2 content", force: true });
357
+ const text = fs.readFileSync(file, "utf8");
358
+ assert.equal(/v1 content/.test(text), false, "old guarded content must be replaced");
359
+ assert.match(text, /v2 content/);
360
+ assert.match(text, /# Top of file/, "user content above the guarded section stays");
361
+ assert.match(text, /User stuff\./);
362
+ }));
363
+
364
+ test("upsertGuardedSection re-installs the same content as a no-op (skipped)", withTempDir(async (dir) => {
365
+ const file = path.join(dir, "AGENTS.md");
366
+ upsertGuardedSection({ filePath: file, content: "stable content", force: false });
367
+ const r = upsertGuardedSection({ filePath: file, content: "stable content", force: false });
368
+ assert.equal(r.skipped, true);
369
+ }));
370
+
371
+ test("upsertGuardedSection refuses to write if a BEGIN marker exists without a matching END", withTempDir(async (dir) => {
372
+ const file = path.join(dir, "AGENTS.md");
373
+ fs.writeFileSync(file, "# Header\n\n<!-- BEGIN DEEPSQL DBA CONSULT SKILL -->\nhalf-edited\n");
374
+ assert.throws(
375
+ () => upsertGuardedSection({ filePath: file, content: "anything", force: false }),
376
+ /half-edited state/,
377
+ );
378
+ }));
379
+
380
+ // ─── per-editor skill_off opt-out via skill === null ───────────────────────
381
+
382
+ test("claude-desktop intentionally has no skill descriptor (no skills surface)", () => {
383
+ assert.equal(EDITORS["claude-desktop"].skill, null);
384
+ });
@@ -1,36 +1,118 @@
1
1
  "use strict";
2
2
 
3
+ /**
4
+ * `deepsql query` — execute a SQL statement against a connection.
5
+ *
6
+ * In 0.13.0 this stopped being a read-only-only command. It now hits the same
7
+ * canonical Editor endpoint (`/api/connections/{id}/query`) that the web UI
8
+ * uses, so policy decisions are uniform across all DeepSQL surfaces:
9
+ *
10
+ * - Developer + SELECT/WITH/SHOW/EXPLAIN → runs immediately
11
+ * - Developer + DML/DDL → backend returns 403 with a
12
+ * clear EDITOR_MUTATION_FORBIDDEN
13
+ * - Admin + DML/DDL (no --write) → server returns
14
+ * requiresConfirmation; we print
15
+ * the warnings, prompt y/N, and
16
+ * re-send with confirmMutation=true
17
+ * - Admin + DML/DDL + --write → confirmation flag is set
18
+ * upfront, no prompt; useful in
19
+ * scripts / CI
20
+ *
21
+ * `EXPLAIN` and `EXPLAIN ANALYZE` are just SQL — no special flag needed.
22
+ * For the AI-enriched plan analysis, use `deepsql analyze "<sql>"`.
23
+ *
24
+ * The old phase-1 read-only parser has been removed; the backend is the
25
+ * single source of truth on policy now. This also closes the
26
+ * "client says no but server would have said yes" mismatches we kept
27
+ * hitting in CI.
28
+ */
29
+
3
30
  const fs = require("node:fs");
4
31
  const { request } = require("../api/client");
5
- const { validateReadOnlySql } = require("../../deepsql-phase1-lib");
6
32
  const { resolveSession } = require("./_session");
7
33
  const { resolveConnectionId } = require("./_connections");
34
+ const ui = require("../ui/prompts");
8
35
 
9
- async function run(opts, { stdout = process.stdout } = {}) {
36
+ async function run(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
10
37
  const sql = readSqlInput(opts);
11
- const validation = validateReadOnlySql(sql, { allowExplain: true });
12
- if (!validation.ok) throw new Error(validation.reason);
13
38
 
14
39
  const session = resolveSession(opts);
15
40
  const connectionId = await resolveConnectionId(session, opts.connection);
16
41
  const limit = clampInt(opts.limit, 1, 1000, 100);
17
- const timeout = opts.timeoutSeconds == null ? null : clampInt(opts.timeoutSeconds, 1, 60, null);
42
+ const timeoutSeconds = opts.timeoutSeconds == null ? null : clampInt(opts.timeoutSeconds, 1, 60, null);
43
+
44
+ const response = await runOnce(session, connectionId, {
45
+ query: sql,
46
+ limit,
47
+ timeoutSeconds,
48
+ mutationConfirmed: !!opts.write,
49
+ });
18
50
 
19
- const response = await request(session.baseUrl, "/mcp/query-readonly", {
20
- method: "POST",
21
- token: session.token,
22
- json: {
23
- connectionId,
24
- query: validation.normalizedQuery,
51
+ // Two-step mutation flow: server returns 200 with requiresConfirmation=true
52
+ // when the statement is a mutation and confirmMutation wasn't set. We show
53
+ // the warning list, ask the human, then resubmit.
54
+ if (response && response.success === false && response.requiresConfirmation) {
55
+ if (opts.write) {
56
+ // --write was passed but server still wants confirmation. That means
57
+ // the request lost the flag somewhere — surface it.
58
+ throw new Error(
59
+ `Server still asked for confirmation despite --write. Message: ${response.message}`,
60
+ );
61
+ }
62
+ const accepted = await promptForConfirmation(response, { stderr });
63
+ if (!accepted) {
64
+ stderr.write("Aborted.\n");
65
+ return;
66
+ }
67
+ const confirmed = await runOnce(session, connectionId, {
68
+ query: sql,
25
69
  limit,
26
- timeoutSeconds: timeout,
70
+ timeoutSeconds,
71
+ mutationConfirmed: true,
72
+ });
73
+ printOutcome(confirmed, opts, { stdout, stderr });
74
+ return;
75
+ }
76
+
77
+ printOutcome(response, opts, { stdout, stderr });
78
+ }
79
+
80
+ async function runOnce(session, connectionId, body) {
81
+ return request(
82
+ session.baseUrl,
83
+ `/connections/${encodeURIComponent(connectionId)}/query`,
84
+ {
85
+ method: "POST",
86
+ token: session.token,
87
+ json: body,
27
88
  },
28
- });
89
+ );
90
+ }
91
+
92
+ async function promptForConfirmation(response, { stderr = process.stderr }) {
93
+ stderr.write(`\n⚠ ${response.message || "This statement will modify the database."}\n`);
94
+ if (response.queryType) stderr.write(` statement: ${response.queryType}\n`);
95
+ const warnings = Array.isArray(response.warnings) ? response.warnings : [];
96
+ for (const w of warnings) {
97
+ stderr.write(` • ${w}\n`);
98
+ }
99
+ stderr.write("\n");
100
+ return ui.confirm({ message: "Execute the statement?", default: false });
101
+ }
29
102
 
103
+ function printOutcome(response, opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
30
104
  if (opts.json) {
31
105
  stdout.write(`${JSON.stringify(response, null, 2)}\n`);
32
106
  return;
33
107
  }
108
+ if (response && response.success === false) {
109
+ // Block or failure that didn't go via the throw path. Print message
110
+ // + error code so a script can grep.
111
+ const code = response.errorCode ? ` [${response.errorCode}]` : "";
112
+ stderr.write(`${response.message || "Query failed."}${code}\n`);
113
+ process.exitCode = 1;
114
+ return;
115
+ }
34
116
  printRows(stdout, response);
35
117
  }
36
118