@deepsql/mcp 0.13.0 → 0.13.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/AGENT-SETUP.md +15 -6
- package/README.md +109 -25
- package/claude_desktop_config.customer.example.json +3 -11
- package/codex_config.customer.example.toml +12 -8
- package/deepsql-phase1-lib.js +47 -2
- package/deepsql-phase1-server.js +1 -1
- package/package.json +1 -1
- package/src/commands/mcp.js +162 -10
- package/src/commands/mcp.test.js +145 -0
package/AGENT-SETUP.md
CHANGED
|
@@ -193,7 +193,8 @@ can switch later with another `connections use`.
|
|
|
193
193
|
Pick the user's editor and run one of:
|
|
194
194
|
|
|
195
195
|
```bash
|
|
196
|
-
deepsql mcp config --install --for claude-code # MCP:
|
|
196
|
+
deepsql mcp config --install --for claude-code # MCP: `claude mcp add --scope user`
|
|
197
|
+
# (falls back to ~/.claude.json if claude CLI missing)
|
|
197
198
|
# Skill: ~/.claude/skills/deepsql/SKILL.md
|
|
198
199
|
deepsql mcp config --install --for claude-desktop # MCP: ~/Library/Application Support/Claude/...
|
|
199
200
|
# (no skill — Claude Desktop has no skills surface)
|
|
@@ -227,11 +228,19 @@ Each invocation does **two** things in one shot:
|
|
|
227
228
|
|
|
228
229
|
The installer:
|
|
229
230
|
|
|
230
|
-
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
231
|
+
- For **Claude Code**, delegates to `claude mcp add --scope user deepsql deepsql mcp`
|
|
232
|
+
(the official CLI that handles user-vs-project-vs-local scope correctly).
|
|
233
|
+
Falls back to writing `~/.claude.json` directly if the `claude` CLI isn't
|
|
234
|
+
on PATH (e.g. SSH boxes, CI). The older installer wrote to
|
|
235
|
+
`~/.claude/settings.json` which is the wrong file — that's for
|
|
236
|
+
permissions/hooks, not MCP. If a stale entry exists from a manual setup
|
|
237
|
+
or older installer, use `--force` to remove + re-add it.
|
|
238
|
+
- For the other editors, creates each target file + parent directory if
|
|
239
|
+
missing, merges into the existing MCP server list without touching
|
|
240
|
+
siblings, refuses to overwrite an existing `deepsql` entry unless
|
|
241
|
+
`--force` is set, and backs up the existing file to
|
|
242
|
+
`<path>.bak.<timestamp>` before any change.
|
|
243
|
+
- For Codex's `AGENTS.md`, wraps the skill in guarded
|
|
235
244
|
`<!-- BEGIN DEEPSQL ... -->` / `<!-- END DEEPSQL ... -->` markers and
|
|
236
245
|
preserves the user's surrounding content,
|
|
237
246
|
- emits the destination path and any backup path so the user can revert.
|
package/README.md
CHANGED
|
@@ -1,42 +1,126 @@
|
|
|
1
|
-
# DeepSQL MCP
|
|
1
|
+
# DeepSQL CLI + MCP server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The `@deepsql/mcp` package ships two things in one binary:
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- **`deepsql`** — a CLI for talking to a self-hosted DeepSQL backend from a
|
|
6
|
+
terminal (auth, connections, SQL execution, plan analysis, index
|
|
7
|
+
suggestions, slow-query analyses, admin ops).
|
|
8
|
+
- **`deepsql mcp`** — a stdio MCP server that exposes the same backend to
|
|
9
|
+
editor agents (Claude Code, Cursor, Codex, Claude Desktop) so they can
|
|
10
|
+
query and reason about the user's databases.
|
|
6
11
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
- Read-only SQL execution and explain through backend-enforced MCP endpoints
|
|
12
|
+
Both share one auth file (`~/.config/deepsql/auth.json`, mode 0600). Log
|
|
13
|
+
in once with `deepsql login`; the MCP server uses the same token
|
|
14
|
+
automatically — no token needs to be embedded in your editor's config.
|
|
11
15
|
|
|
12
|
-
|
|
16
|
+
## Install
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
| `DEEPSQL_API_BASE_URL` | yes | `https://customer-deepsql.example.com/api/` |
|
|
19
|
-
| `DEEPSQL_AUTH_TOKEN` | yes | `dsql_mcp_...` |
|
|
20
|
-
| `DEEPSQL_MCP_TIMEOUT_MS` | no | `120000` |
|
|
21
|
-
| `DEEPSQL_MCP_USER_ID` | no | `codex-mcp` |
|
|
22
|
-
| `DEEPSQL_MCP_PROJECT_ID` | no | `codex-mcp` |
|
|
18
|
+
```bash
|
|
19
|
+
npm install -g @deepsql/mcp@latest
|
|
20
|
+
deepsql --version
|
|
21
|
+
```
|
|
23
22
|
|
|
24
|
-
|
|
23
|
+
Requires Node ≥ 20.
|
|
25
24
|
|
|
26
|
-
|
|
25
|
+
## Quick start
|
|
27
26
|
|
|
28
|
-
|
|
27
|
+
```bash
|
|
28
|
+
deepsql login --url https://your-deepsql-host.example.com
|
|
29
|
+
deepsql connections list
|
|
30
|
+
deepsql connections use <connection-name>
|
|
31
|
+
deepsql query "SELECT 1 AS ok"
|
|
32
|
+
```
|
|
29
33
|
|
|
30
|
-
|
|
34
|
+
## Wire into your editor (one command per editor)
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
The installer writes the MCP server entry into the editor's config AND
|
|
37
|
+
installs a "DBA consult" skill that auto-triggers when the user asks
|
|
38
|
+
the agent to do database work:
|
|
33
39
|
|
|
34
40
|
```bash
|
|
35
|
-
|
|
41
|
+
deepsql mcp config --install --for claude-code # via `claude mcp add --scope user`
|
|
42
|
+
deepsql mcp config --install --for claude-desktop # macOS ~/Library/.../Claude/...
|
|
43
|
+
deepsql mcp config --install --for cursor # ~/.cursor/mcp.json + ~/.cursor/rules/deepsql.mdc
|
|
44
|
+
deepsql mcp config --install --for codex # ~/.codex/config.toml + ~/.codex/AGENTS.md
|
|
36
45
|
```
|
|
37
46
|
|
|
38
|
-
|
|
47
|
+
Pass `--print` to see what would be written without touching disk.
|
|
48
|
+
Pass `--no-skill` to install only the MCP entry. Pass `--force` to
|
|
49
|
+
overwrite a stale entry. See `deepsql mcp --help` for details.
|
|
50
|
+
|
|
51
|
+
Restart the editor for the entry to load.
|
|
52
|
+
|
|
53
|
+
## What the MCP server exposes
|
|
54
|
+
|
|
55
|
+
10 tools, all read-only at the schema/retrieval layer and policy-gated
|
|
56
|
+
at the SQL layer:
|
|
57
|
+
|
|
58
|
+
| Tool | Purpose |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `list_connections` | Connections this token has access to |
|
|
61
|
+
| `get_schema` | Cached schema metadata (tables, columns, FKs, types) |
|
|
62
|
+
| `get_database_objects` | Tables, views, functions, procedures |
|
|
63
|
+
| `get_brain_context` | Retrieval brain: tables/columns/FKs/training docs/rules for a question |
|
|
64
|
+
| `list_business_rules` | Active business rules and SQL guardrails for a connection |
|
|
65
|
+
| `get_relationships` | Inferred + validated foreign keys with confidence scores |
|
|
66
|
+
| `get_anti_patterns` | Schema-level or query-level anti-patterns |
|
|
67
|
+
| `analyze_slow_queries` | Recent slow queries with fingerprints, durations, examples |
|
|
68
|
+
| `execute_sql` | Run any SQL — backend enforces role-based policy (developers read-only, admins can mutate with two-step confirm) |
|
|
69
|
+
| `analyze_query_plan` | AI-enriched plan analysis (parsed plan tree, performance issues, index recommendations, written summary that uses the connection's schema + business rules) |
|
|
70
|
+
|
|
71
|
+
EXPLAIN and EXPLAIN ANALYZE are just SQL — pass them as the query to
|
|
72
|
+
`execute_sql`. For the AI-enriched plan analysis with the LLM-written
|
|
73
|
+
summary, use `analyze_query_plan`.
|
|
74
|
+
|
|
75
|
+
## Runtime guidance for agents
|
|
76
|
+
|
|
77
|
+
`CLAUDE.md` (bundled in this package, at
|
|
78
|
+
`node_modules/@deepsql/mcp/CLAUDE.md` after install) is the runtime
|
|
79
|
+
guide for editor agents that have these tools loaded. It covers the
|
|
80
|
+
"DBA consult" pattern, decision tree, hard rules around the role-gated
|
|
81
|
+
mutation flow, and common foot-guns. The installer also drops a
|
|
82
|
+
shortened, trigger-focused version of that guide as a native skill so
|
|
83
|
+
the pattern fires automatically on phrases like "add a table", "write
|
|
84
|
+
a migration", "design a schema", "query the database".
|
|
85
|
+
|
|
86
|
+
## Agent-driven setup (one paste)
|
|
87
|
+
|
|
88
|
+
For a fresh customer install, paste `AGENT-SETUP.md` (bundled at
|
|
89
|
+
`node_modules/@deepsql/mcp/AGENT-SETUP.md`) into Claude Code / Cursor /
|
|
90
|
+
Codex. The agent walks the user through install, login, connection
|
|
91
|
+
registration, editor integration, and end-to-end validation in ~5
|
|
92
|
+
minutes.
|
|
93
|
+
|
|
94
|
+
## Manual install (only if you don't want the CLI shim)
|
|
95
|
+
|
|
96
|
+
If you'd rather skip the `deepsql mcp config --install` flow and wire
|
|
97
|
+
the editor config by hand, see the example files bundled with this
|
|
98
|
+
package: `claude_desktop_config.customer.example.json` and
|
|
99
|
+
`codex_config.customer.example.toml`. The token-embedded shape in
|
|
100
|
+
those examples still works, but `deepsql mcp config --install` is the
|
|
101
|
+
recommended path now.
|
|
102
|
+
|
|
103
|
+
## Run the server directly (advanced)
|
|
39
104
|
|
|
40
105
|
```bash
|
|
41
|
-
|
|
106
|
+
deepsql mcp # uses the saved profile + auth token
|
|
107
|
+
npx -y @deepsql/mcp # one-off invocation without install
|
|
42
108
|
```
|
|
109
|
+
|
|
110
|
+
Both work; the CLI shim is preferred because it shares the saved
|
|
111
|
+
profile and never asks you to paste a token into editor config.
|
|
112
|
+
|
|
113
|
+
## Environment variables
|
|
114
|
+
|
|
115
|
+
| Variable | Required | Used by | Example |
|
|
116
|
+
|----------|----------|---------|---------|
|
|
117
|
+
| `DEEPSQL_API_BASE_URL` | for npx-style invocation | `deepsql mcp` if no saved profile | `https://customer-deepsql.example.com/api/` |
|
|
118
|
+
| `DEEPSQL_AUTH_TOKEN` | for npx-style invocation | bearer for backend; `deepsql login` writes one to the profile file | `dsql_mcp_…` |
|
|
119
|
+
| `DEEPSQL_MCP_USER_ID` | no | identifies the editor that invoked the MCP server in audit logs | `claude-desktop` / `cursor-mcp` / `codex-mcp` |
|
|
120
|
+
| `DEEPSQL_CALLER_AGENT` | no | overrides the CLI's audit identity when an agent shells out to `deepsql` | `claude-code` |
|
|
121
|
+
| `DEEPSQL_MCP_TIMEOUT_MS` | no | per-request timeout for MCP server calls | `120000` |
|
|
122
|
+
|
|
123
|
+
If `deepsql login` has been run, both the CLI and the spawned MCP
|
|
124
|
+
server pick up `DEEPSQL_API_BASE_URL` + `DEEPSQL_AUTH_TOKEN` from
|
|
125
|
+
`~/.config/deepsql/auth.json` automatically — you don't need to set
|
|
126
|
+
the env vars for either.
|
|
@@ -1,17 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
+
"_comment": "Manual Claude Desktop config example. Most users should run `deepsql mcp config --install --for claude-desktop` instead — that handles the OS-specific path, backup, and the DBA-consult skill in one shot. Use this template only if you need a custom layout (per-workspace, embedded token, etc.).",
|
|
2
3
|
"mcpServers": {
|
|
3
4
|
"deepsql": {
|
|
4
|
-
"command": "
|
|
5
|
-
"args": [
|
|
6
|
-
"-y",
|
|
7
|
-
"@deepsql/mcp"
|
|
8
|
-
],
|
|
9
|
-
"env": {
|
|
10
|
-
"DEEPSQL_API_BASE_URL": "https://customer-deepsql.example.com/api/",
|
|
11
|
-
"DEEPSQL_AUTH_TOKEN": "dsql_mcp_replace_me",
|
|
12
|
-
"DEEPSQL_MCP_USER_ID": "claude-desktop",
|
|
13
|
-
"DEEPSQL_MCP_PROJECT_ID": "claude-desktop"
|
|
14
|
-
}
|
|
5
|
+
"command": "deepsql",
|
|
6
|
+
"args": ["mcp"]
|
|
15
7
|
}
|
|
16
8
|
}
|
|
17
9
|
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# Manual Codex CLI MCP config example. Most users should run
|
|
2
|
+
# `deepsql mcp config --install --for codex`
|
|
3
|
+
# instead — that handles the file path, backup, and the DBA-consult skill
|
|
4
|
+
# in one shot. Use this template only if you need a custom layout (e.g.
|
|
5
|
+
# embedded token, alternate `DEEPSQL_*` env vars, non-default profile).
|
|
6
|
+
#
|
|
7
|
+
# The form below uses the `deepsql mcp` shim, which reads auth from
|
|
8
|
+
# ~/.config/deepsql/auth.json (written by `deepsql login`) — no token
|
|
9
|
+
# needs to be embedded in the config.
|
|
4
10
|
|
|
5
|
-
[mcp_servers.deepsql
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
DEEPSQL_MCP_USER_ID = "codex-mcp"
|
|
9
|
-
DEEPSQL_MCP_PROJECT_ID = "codex-mcp"
|
|
11
|
+
[mcp_servers.deepsql]
|
|
12
|
+
command = "deepsql"
|
|
13
|
+
args = ["mcp"]
|
package/deepsql-phase1-lib.js
CHANGED
|
@@ -552,11 +552,56 @@ function summarizeAntiPatterns(payload, kind) {
|
|
|
552
552
|
}
|
|
553
553
|
|
|
554
554
|
function summarizeSlowQueries(payload) {
|
|
555
|
-
|
|
555
|
+
// Backend returns SlowQueryAnalysis with `topSlowQueries` (the field name
|
|
556
|
+
// varies; tolerate both `queries` and `topSlowQueries`).
|
|
557
|
+
const list = Array.isArray(payload?.topSlowQueries)
|
|
558
|
+
? payload.topSlowQueries
|
|
559
|
+
: Array.isArray(payload?.queries) ? payload.queries : [];
|
|
556
560
|
const total = payload?.totalCount ?? list.length;
|
|
557
561
|
const avg = payload?.avgDurationMs;
|
|
558
562
|
const max = payload?.maxDurationMs;
|
|
559
|
-
|
|
563
|
+
|
|
564
|
+
// Three counts matter to a calling agent that's about to EXPLAIN one of
|
|
565
|
+
// these queries:
|
|
566
|
+
//
|
|
567
|
+
// recovered = sourceTruncated AND queryTextRecoveredFromLogs
|
|
568
|
+
// → the live stats source truncated this query, but DeepSQL recovered
|
|
569
|
+
// the full SQL from previously-ingested slow-log data in
|
|
570
|
+
// query_lineage. EXPLAIN will work.
|
|
571
|
+
//
|
|
572
|
+
// stillTruncated = sourceTruncated AND NOT queryTextRecoveredFromLogs
|
|
573
|
+
// → still truncated; EXPLAIN will fail or return a partial plan.
|
|
574
|
+
//
|
|
575
|
+
// neither → normal full-text query, no warning needed.
|
|
576
|
+
const recovered = list.filter((q) =>
|
|
577
|
+
q && q.sourceTruncated === true && q.queryTextRecoveredFromLogs === true
|
|
578
|
+
).length;
|
|
579
|
+
const stillTruncated = list.filter((q) =>
|
|
580
|
+
q && q.sourceTruncated === true && q.queryTextRecoveredFromLogs !== true
|
|
581
|
+
).length;
|
|
582
|
+
|
|
583
|
+
const parts = [];
|
|
584
|
+
if (recovered > 0) {
|
|
585
|
+
parts.push(
|
|
586
|
+
` ℹ ${recovered} ${recovered === 1 ? "query was" : "queries were"} truncated by `
|
|
587
|
+
+ `the database server (default 1024B) but DeepSQL recovered the full SQL `
|
|
588
|
+
+ `from previously-ingested slow-log data — EXPLAIN against \`queryText\` will work.`
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
if (stillTruncated > 0) {
|
|
592
|
+
parts.push(
|
|
593
|
+
` ⚠ ${stillTruncated} ${stillTruncated === 1 ? "query is" : "queries are"} still `
|
|
594
|
+
+ `truncated and DeepSQL has no full-text copy on file. EXPLAIN will be unreliable. `
|
|
595
|
+
+ `Fix: ingest the slow query log file for this connection, OR raise `
|
|
596
|
+
+ `\`pg_stat_statements.track_activity_query_size\` (PG) / `
|
|
597
|
+
+ `\`performance_schema_max_sql_text_length\` (MySQL) and restart, then re-collect.`
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return `${total} slow query/queries`
|
|
602
|
+
+ `${avg != null ? `, avg=${avg}ms` : ""}`
|
|
603
|
+
+ `${max != null ? `, max=${max}ms` : ""}.`
|
|
604
|
+
+ parts.join("");
|
|
560
605
|
}
|
|
561
606
|
|
|
562
607
|
function summarizeQueryResult(payload) {
|
package/deepsql-phase1-server.js
CHANGED
|
@@ -193,7 +193,7 @@ class DeepSqlPhase1McpServer {
|
|
|
193
193
|
},
|
|
194
194
|
serverInfo: SERVER_INFO,
|
|
195
195
|
instructions:
|
|
196
|
-
"DeepSQL
|
|
196
|
+
"DeepSQL MCP exposes the user's database catalogs plus DeepSQL's retrieval brain (relevant tables/columns/FKs, business rules, inferred relationships, anti-patterns, slow-query analysis). Workflow: call list_connections first to get UUIDs; call get_brain_context with the user's question to ground generation in retrieved schema; call execute_sql to run the query (admins can run DDL/DML with a two-step confirmMutation flow, developers are server-enforced read-only); call analyze_query_plan for AI-enriched plan analysis with the connection's schema + business rules in scope. EXPLAIN and EXPLAIN ANALYZE are valid SQL — pass them as the query to execute_sql; use analyze_query_plan when you want the LLM-written summary, not raw plan rows. Always pass connectionId (UUID), not connection names.",
|
|
197
197
|
});
|
|
198
198
|
return;
|
|
199
199
|
}
|
package/package.json
CHANGED
package/src/commands/mcp.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
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
|
|
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
|
};
|
package/src/commands/mcp.test.js
CHANGED
|
@@ -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", () => {
|