@deepsql/mcp 0.10.2 → 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,165 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * `deepsql analyze` — AI-enriched query plan analysis.
5
+ *
6
+ * Hits the canonical Editor endpoint `/api/explain/analyze`. The response is
7
+ * an ExplainPlanAnalysis carrying:
8
+ * - the parsed plan tree (planTree), raw plan text/JSON
9
+ * - performance issues (slow nodes, missing index hints, bad estimates)
10
+ * - index recommendations
11
+ * - an LLM-written summary that takes the connection's schema, business
12
+ * rules, and detected anti-patterns into account
13
+ *
14
+ * Two modes:
15
+ * - default (`useAnalyze=false`) Plain EXPLAIN, no execution. Safe for any
16
+ * actor with read access to the connection.
17
+ * - `--analyze` (`useAnalyze=true`) EXPLAIN ANALYZE — actually runs the
18
+ * query. For mutations this requires the
19
+ * same admin role + WHERE + confirmation
20
+ * gate as `deepsql query`.
21
+ *
22
+ * Pass `--write` to confirm an EXPLAIN ANALYZE of a mutation upfront (skips
23
+ * the interactive prompt).
24
+ */
25
+
26
+ const fs = require("node:fs");
27
+ const { request } = require("../api/client");
28
+ const { resolveSession } = require("./_session");
29
+ const { resolveConnectionId } = require("./_connections");
30
+ const ui = require("../ui/prompts");
31
+
32
+ async function run(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
33
+ const sql = readSqlInput(opts);
34
+ const session = resolveSession(opts);
35
+ const connectionId = await resolveConnectionId(session, opts.connection);
36
+
37
+ const body = {
38
+ connectionId,
39
+ query: sql,
40
+ useAnalyze: !!opts.analyze,
41
+ mutationConfirmed: !!opts.write,
42
+ };
43
+
44
+ let response;
45
+ try {
46
+ response = await runOnce(session, body);
47
+ } catch (err) {
48
+ // Two-step mutation: the policy gate throws when useAnalyze=true with
49
+ // a mutation and no confirm. The server packs requiresConfirmation
50
+ // into the error response body.
51
+ if (err.body && err.body.requiresConfirmation && !opts.write) {
52
+ const accepted = await promptForConfirmation(err.body, { stderr });
53
+ if (!accepted) {
54
+ stderr.write("Aborted.\n");
55
+ return;
56
+ }
57
+ response = await runOnce(session, { ...body, mutationConfirmed: true });
58
+ } else {
59
+ throw err;
60
+ }
61
+ }
62
+
63
+ if (opts.json) {
64
+ stdout.write(`${JSON.stringify(response, null, 2)}\n`);
65
+ return;
66
+ }
67
+ renderAnalysis(stdout, response);
68
+ }
69
+
70
+ async function runOnce(session, body) {
71
+ return request(session.baseUrl, "/explain/analyze", {
72
+ method: "POST",
73
+ token: session.token,
74
+ json: body,
75
+ });
76
+ }
77
+
78
+ async function promptForConfirmation(errorBody, { stderr = process.stderr }) {
79
+ stderr.write(
80
+ `\n⚠ ${errorBody.message || "EXPLAIN ANALYZE will actually execute this statement."}\n`,
81
+ );
82
+ if (errorBody.queryType) stderr.write(` statement: ${errorBody.queryType}\n`);
83
+ const warnings = Array.isArray(errorBody.warnings) ? errorBody.warnings : [];
84
+ for (const w of warnings) {
85
+ stderr.write(` • ${w}\n`);
86
+ }
87
+ stderr.write("\n");
88
+ return ui.confirm({ message: "Run EXPLAIN ANALYZE on the statement?", default: false });
89
+ }
90
+
91
+ function renderAnalysis(stdout, response) {
92
+ if (!response || typeof response !== "object") {
93
+ stdout.write(`${JSON.stringify(response, null, 2)}\n`);
94
+ return;
95
+ }
96
+
97
+ // Plain EXPLAIN responses set wasExecuted=false; EXPLAIN ANALYZE sets it
98
+ // to true. Surface the distinction at the top.
99
+ const heading = response.wasExecuted
100
+ ? "Plan (executed via EXPLAIN ANALYZE):"
101
+ : "Plan (EXPLAIN, not executed):";
102
+ stdout.write(`${heading}\n`);
103
+
104
+ if (response.planText) {
105
+ stdout.write(`${String(response.planText).trim()}\n\n`);
106
+ } else if (response.planJson) {
107
+ stdout.write(`${response.planJson}\n\n`);
108
+ }
109
+
110
+ const numbers = [];
111
+ if (response.totalTimeMs != null) numbers.push(`total ${response.totalTimeMs}ms`);
112
+ if (response.executionTimeMs != null) numbers.push(`exec ${response.executionTimeMs}ms`);
113
+ if (response.planningTimeMs != null) numbers.push(`plan ${response.planningTimeMs}ms`);
114
+ if (response.estimatedRows != null) numbers.push(`est ${response.estimatedRows} rows`);
115
+ if (response.actualRows != null) numbers.push(`actual ${response.actualRows} rows`);
116
+ if (response.nodeCount != null) numbers.push(`${response.nodeCount} nodes`);
117
+ if (numbers.length) stdout.write(`Timings: ${numbers.join(", ")}\n\n`);
118
+
119
+ if (response.aiSummary) {
120
+ stdout.write(`Summary:\n${response.aiSummary.trim()}\n\n`);
121
+ }
122
+
123
+ const issues = Array.isArray(response.issues) ? response.issues : [];
124
+ if (issues.length > 0) {
125
+ stdout.write(`Issues (${issues.length}):\n`);
126
+ for (const issue of issues) {
127
+ const label = issue.severity ? `[${issue.severity}] ` : "";
128
+ stdout.write(` • ${label}${issue.title || issue.message || JSON.stringify(issue)}\n`);
129
+ if (issue.message && issue.title) stdout.write(` ${issue.message}\n`);
130
+ }
131
+ stdout.write("\n");
132
+ }
133
+
134
+ const recs = Array.isArray(response.indexRecommendations) ? response.indexRecommendations : [];
135
+ if (recs.length > 0) {
136
+ stdout.write(`Suggested indexes (${recs.length}):\n`);
137
+ for (const r of recs) {
138
+ const cols = Array.isArray(r.columns) ? r.columns.join(", ") : (r.columnNames || "?");
139
+ stdout.write(` • ${r.tableName || "?"}(${cols})`);
140
+ if (r.estimatedImpact != null) stdout.write(` impact≈${r.estimatedImpact}`);
141
+ stdout.write("\n");
142
+ if (r.suggestedSql) stdout.write(` ${r.suggestedSql.trim()}\n`);
143
+ }
144
+ stdout.write("\n");
145
+ }
146
+
147
+ const tips = Array.isArray(response.optimizationSuggestions) ? response.optimizationSuggestions : [];
148
+ if (tips.length > 0) {
149
+ stdout.write(`Suggestions:\n`);
150
+ for (const t of tips) stdout.write(` • ${t}\n`);
151
+ }
152
+
153
+ if (response.aiOptimization) {
154
+ stdout.write(`\nOptimization narrative:\n${response.aiOptimization.trim()}\n`);
155
+ }
156
+ }
157
+
158
+ function readSqlInput(opts) {
159
+ if (opts.file) return fs.readFileSync(opts.file, "utf8");
160
+ if (opts.positional.length > 0) return opts.positional.join(" ");
161
+ if (!process.stdin.isTTY) return fs.readFileSync(0, "utf8");
162
+ throw new Error("Pass SQL as an argument, via --file <path>, or pipe it to stdin.");
163
+ }
164
+
165
+ module.exports = { run };
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+
3
+ const test = require("node:test");
4
+ const assert = require("node:assert/strict");
5
+
6
+ const { parseArgs, buildOpts } = require("../cli");
7
+
8
+ function opts(argv) {
9
+ return buildOpts(parseArgs(argv));
10
+ }
11
+
12
+ function loadWithStubs({ responses = [], onRequest = () => {}, errors = [], confirmAnswer = false }) {
13
+ for (const k of [
14
+ require.resolve("../api/client"),
15
+ require.resolve("./_session"),
16
+ require.resolve("./_connections"),
17
+ require.resolve("../ui/prompts"),
18
+ require.resolve("./analyze"),
19
+ ]) {
20
+ delete require.cache[k];
21
+ }
22
+
23
+ const apiKey = require.resolve("../api/client");
24
+ let i = 0;
25
+ class FakeApiError extends Error {
26
+ constructor(message, { status, body } = {}) { super(message); this.status = status; this.body = body; }
27
+ }
28
+ require.cache[apiKey] = {
29
+ id: apiKey, filename: apiKey, loaded: true,
30
+ exports: {
31
+ ApiError: FakeApiError,
32
+ async request(_base, path, body) {
33
+ onRequest(path, body);
34
+ if (errors[i]) {
35
+ const e = errors[i];
36
+ i++;
37
+ throw new FakeApiError(e.message || "fail", { status: e.status, body: e.body });
38
+ }
39
+ const r = responses[i] ?? {};
40
+ i++;
41
+ return r;
42
+ },
43
+ setClientContext() {},
44
+ getClientContext() { return null; },
45
+ },
46
+ };
47
+
48
+ const sessKey = require.resolve("./_session");
49
+ require.cache[sessKey] = {
50
+ id: sessKey, filename: sessKey, loaded: true,
51
+ exports: { resolveSession: () => ({ baseUrl: "http://test", token: "t" }) },
52
+ };
53
+
54
+ const connKey = require.resolve("./_connections");
55
+ require.cache[connKey] = {
56
+ id: connKey, filename: connKey, loaded: true,
57
+ exports: { resolveConnectionId: async () => "00000000-0000-0000-0000-000000000001" },
58
+ };
59
+
60
+ const promptsKey = require.resolve("../ui/prompts");
61
+ require.cache[promptsKey] = {
62
+ id: promptsKey, filename: promptsKey, loaded: true,
63
+ exports: { confirm: async () => confirmAnswer, input: async () => "", password: async () => "", select: async () => "" },
64
+ };
65
+
66
+ return require("./analyze");
67
+ }
68
+
69
+ function captureStdout() {
70
+ let out = ""; let err = "";
71
+ return {
72
+ stream: { write: (s) => { out += s; } },
73
+ errStream: { write: (s) => { err += s; } },
74
+ out: () => out, err: () => err,
75
+ };
76
+ }
77
+
78
+ test("analyze hits /explain/analyze with useAnalyze=false by default", async () => {
79
+ const seen = [];
80
+ const analyze = loadWithStubs({
81
+ onRequest: (path, body) => seen.push({ path, body: body && body.json }),
82
+ responses: [{ planText: "Seq Scan on orders" }],
83
+ });
84
+ const io = captureStdout();
85
+ await analyze.run(opts(["SELECT * FROM orders"]), { stdout: io.stream, stderr: io.errStream });
86
+ assert.equal(seen.length, 1);
87
+ assert.match(seen[0].path, /\/explain\/analyze$/);
88
+ assert.equal(seen[0].body.useAnalyze, false);
89
+ assert.match(io.out(), /Plan \(EXPLAIN, not executed\)/);
90
+ assert.match(io.out(), /Seq Scan on orders/);
91
+ });
92
+
93
+ test("analyze --analyze flips useAnalyze=true on the request", async () => {
94
+ const seen = [];
95
+ const analyze = loadWithStubs({
96
+ onRequest: (path, body) => seen.push(body && body.json),
97
+ responses: [{ wasExecuted: true, planText: "executed" }],
98
+ });
99
+ const io = captureStdout();
100
+ await analyze.run(opts(["SELECT * FROM orders", "--analyze"]), { stdout: io.stream });
101
+ assert.equal(seen[0].useAnalyze, true);
102
+ assert.match(io.out(), /Plan \(executed via EXPLAIN ANALYZE\)/);
103
+ });
104
+
105
+ test("analyze on a mutation triggers two-step confirmation flow on 4xx requiresConfirmation", async () => {
106
+ const seen = [];
107
+ const analyze = loadWithStubs({
108
+ onRequest: (_path, body) => seen.push(body && body.json),
109
+ errors: [{
110
+ status: 200,
111
+ body: { requiresConfirmation: true, message: "ANALYZE will execute this mutation.", queryType: "DELETE", warnings: ["no WHERE"] },
112
+ message: "confirmation required",
113
+ }, null],
114
+ responses: [null, { wasExecuted: true, planText: "deleted 1" }],
115
+ confirmAnswer: true,
116
+ });
117
+ const io = captureStdout();
118
+ await analyze.run(opts(["DELETE FROM users WHERE id=1", "--analyze"]), { stdout: io.stream, stderr: io.errStream });
119
+ assert.equal(seen.length, 2);
120
+ assert.equal(seen[0].mutationConfirmed, false);
121
+ assert.equal(seen[1].mutationConfirmed, true);
122
+ assert.match(io.err(), /ANALYZE will execute/);
123
+ });
124
+
125
+ test("analyze --write skips the prompt and bakes confirmMutation into the first call", async () => {
126
+ const seen = [];
127
+ const analyze = loadWithStubs({
128
+ onRequest: (_path, body) => seen.push(body && body.json),
129
+ responses: [{ wasExecuted: true, planText: "ok" }],
130
+ });
131
+ const io = captureStdout();
132
+ await analyze.run(opts(["DELETE FROM users WHERE id=1", "--analyze", "--write"]), { stdout: io.stream });
133
+ assert.equal(seen.length, 1);
134
+ assert.equal(seen[0].mutationConfirmed, true);
135
+ });
136
+
137
+ test("analyze --json emits the raw analysis payload", async () => {
138
+ const analyze = loadWithStubs({
139
+ responses: [{ aiSummary: "fast", nodeCount: 3, issues: [], indexRecommendations: [] }],
140
+ });
141
+ const io = captureStdout();
142
+ // SQL first so `--json` doesn't eat it as a value.
143
+ await analyze.run(opts(["SELECT 1", "--json"]), { stdout: io.stream });
144
+ const parsed = JSON.parse(io.out());
145
+ assert.equal(parsed.nodeCount, 3);
146
+ assert.equal(parsed.aiSummary, "fast");
147
+ });
148
+
149
+ test("analyze renders AI summary, issues, and index recommendations from the response", async () => {
150
+ const analyze = loadWithStubs({
151
+ responses: [{
152
+ planText: "Seq Scan",
153
+ aiSummary: "Sequential scan on a large table; consider an index on customer_id.",
154
+ issues: [{ severity: "HIGH", title: "Sequential scan", message: "10M rows scanned" }],
155
+ indexRecommendations: [{ tableName: "orders", columns: ["customer_id"], estimatedImpact: 80 }],
156
+ optimizationSuggestions: ["Add WHERE customer_id = ?"],
157
+ }],
158
+ });
159
+ const io = captureStdout();
160
+ await analyze.run(opts(["SELECT * FROM orders"]), { stdout: io.stream });
161
+ const out = io.out();
162
+ assert.match(out, /Summary:/);
163
+ assert.match(out, /Sequential scan on a large table/);
164
+ assert.match(out, /\[HIGH\] Sequential scan/);
165
+ assert.match(out, /orders\(customer_id\)/);
166
+ assert.match(out, /impact≈80/);
167
+ assert.match(out, /Add WHERE customer_id/);
168
+ });
169
+
170
+ test.after(() => {
171
+ for (const k of [
172
+ require.resolve("../api/client"),
173
+ require.resolve("./_session"),
174
+ require.resolve("./_connections"),
175
+ require.resolve("../ui/prompts"),
176
+ require.resolve("./analyze"),
177
+ ]) {
178
+ delete require.cache[k];
179
+ }
180
+ });
@@ -1,41 +1,25 @@
1
1
  "use strict";
2
2
 
3
- const fs = require("node:fs");
4
- const { request } = require("../api/client");
5
- const { validateReadOnlySql } = require("../../deepsql-phase1-lib");
6
- const { resolveSession } = require("./_session");
7
- const { resolveConnectionId } = require("./_connections");
3
+ /**
4
+ * `deepsql explain` deprecated alias for `deepsql analyze`.
5
+ *
6
+ * In 0.13.0 we consolidated SQL execution and plan analysis under two
7
+ * canonical commands: `deepsql query` (executes anything) and
8
+ * `deepsql analyze` (AI-enriched plan analysis, with optional ANALYZE).
9
+ * `explain` was a thin read-only-locked subset of analyze, so it lives on
10
+ * for one cycle as a forwarder while users migrate; it will be removed in
11
+ * 0.14.0.
12
+ */
8
13
 
9
- async function run(opts, { stdout = process.stdout } = {}) {
10
- const sql = readSqlInput(opts);
11
- // EXPLAIN ANALYZE is mutating; require plain EXPLAIN.
12
- const validation = validateReadOnlySql(sql, { allowExplain: false });
13
- if (!validation.ok) throw new Error(validation.reason);
14
+ const analyze = require("./analyze");
14
15
 
15
- const session = resolveSession(opts);
16
- const connectionId = await resolveConnectionId(session, opts.connection);
17
- const response = await request(session.baseUrl, "/mcp/explain-readonly", {
18
- method: "POST",
19
- token: session.token,
20
- json: { connectionId, query: validation.normalizedQuery },
21
- });
22
-
23
- if (opts.json) {
24
- stdout.write(`${JSON.stringify(response, null, 2)}\n`);
25
- return;
26
- }
27
- if (response?.plan) {
28
- stdout.write(typeof response.plan === "string" ? `${response.plan}\n` : `${JSON.stringify(response.plan, null, 2)}\n`);
29
- return;
30
- }
31
- stdout.write(`${JSON.stringify(response, null, 2)}\n`);
32
- }
33
-
34
- function readSqlInput(opts) {
35
- if (opts.file) return fs.readFileSync(opts.file, "utf8");
36
- if (opts.positional.length > 0) return opts.positional.join(" ");
37
- if (!process.stdin.isTTY) return fs.readFileSync(0, "utf8");
38
- throw new Error("Pass SQL as an argument, via --file <path>, or pipe it to stdin.");
16
+ async function run(opts, io = {}) {
17
+ const stderr = io.stderr || process.stderr;
18
+ stderr.write(
19
+ "[deepsql] `deepsql explain` is deprecated and will be removed in 0.14.0. "
20
+ + "Use `deepsql analyze` (same behavior; add `--analyze` for EXPLAIN ANALYZE).\n",
21
+ );
22
+ return analyze.run(opts, io);
39
23
  }
40
24
 
41
25
  module.exports = { run };