@deepsql/mcp 0.18.2 → 0.19.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.
package/CLAUDE.md CHANGED
@@ -33,9 +33,28 @@ server, and the statement you ran.** Don't be sloppy.
33
33
 
34
34
  ## The tools you have
35
35
 
36
- The MCP server exposes 21 tools. They all take a `connectionId` (UUID
37
- returned by `list_connections`) except `apply_index_recommendation`,
38
- which takes a server-resolved `recommendationId`.
36
+ The MCP server exposes **41 tools** as of 0.19.0 the original 16 +
37
+ 0.18.x's 5 slow-query tools + 0.19.0's 20 CLI-parity additions. Most
38
+ take a `connectionId` (UUID returned by `list_connections`); a few
39
+ take server-resolved row ids (`apply_index_recommendation` →
40
+ `recommendationId`, `dismiss_index_recommendation` →
41
+ `recommendationId`, `acknowledge_growth_anomaly` → `anomalyId`,
42
+ `get_digest_by_id` → `digestId`); `get_current_user` and
43
+ `list_connections` take no args.
44
+
45
+ The surface mirrors the `deepsql` CLI for almost every read/diagnostic
46
+ operation. **Three CLI capabilities are intentionally NOT in MCP**:
47
+
48
+ 1. **Connection write ops** (`add`, `update`, `remove`) — they take
49
+ plaintext DB credentials, which would land in your conversation
50
+ history. Tell the user to run `deepsql connections add` at a TTY.
51
+ 2. **Auth flows** (`login`, `logout`, `setup`, `mcp config --install`) —
52
+ interactive by nature.
53
+ 3. **Streaming AI optimization** (`slow-queries optimize`) — SSE
54
+ stream, doesn't fit JSON-RPC tools.
55
+
56
+ Per-user admin ops (`users`, `access`, `permissions`) are also CLI-only
57
+ in this version; ask before mid-session admin work.
39
58
 
40
59
  | Tool | Purpose |
41
60
  |---|---|
package/README.md CHANGED
@@ -52,31 +52,99 @@ Restart the editor for the entry to load.
52
52
 
53
53
  ## What the MCP server exposes
54
54
 
55
- 21 tools, all read-only at the schema/retrieval layer and policy-gated
56
- at the SQL layer (only `execute_sql`, `analyze_query_plan` with
57
- `useAnalyze=true`, and `apply_index_recommendation` can write):
55
+ **41 tools** as of 0.19.0. Read-only at the schema/retrieval/diagnostics
56
+ layer; policy-gated at the SQL layer (only `execute_sql`,
57
+ `analyze_query_plan` with `useAnalyze=true`, and
58
+ `apply_index_recommendation` can write — the rest are reads or low-stakes
59
+ triggers like `reinit_connection_brain` and `acknowledge_growth_anomaly`).
60
+
61
+ The surface mirrors the `deepsql` CLI for every read/diagnostic
62
+ operation. **What's intentionally NOT in MCP**: connection write ops
63
+ (`add`/`update`/`remove` — they'd require plaintext DB credentials in
64
+ agent conversation history; manage at a TTY via `deepsql connections
65
+ add` instead), interactive auth flows (`login`/`logout`/`setup`/`mcp
66
+ config --install`), the SSE streaming `slow-queries optimize` flow, and
67
+ per-user admin ops (`users`/`access`/`permissions`).
68
+
69
+ ### Connections + identity (5)
58
70
 
59
71
  | Tool | Purpose |
60
72
  |---|---|
61
73
  | `list_connections` | Connections this token has access to |
74
+ | `get_current_user` | Authenticated user + role + bound DeepSQL host |
75
+ | `show_connection` | One connection's saved config, secrets masked |
76
+ | `test_connection` | Run the privilege report (+ SSH check); reuses saved creds — no plaintext crosses the wire |
77
+ | `reinit_connection_brain` | Trigger a fresh schema scan + brain re-embed |
78
+
79
+ ### Schema + retrieval brain (5)
80
+
81
+ | Tool | Purpose |
82
+ |---|---|
62
83
  | `get_schema` | Cached schema metadata (tables, columns, FKs, types) |
63
84
  | `get_database_objects` | Tables, views, functions, procedures |
64
85
  | `get_brain_context` | Retrieval brain: tables/columns/FKs/training docs/rules for a question |
65
86
  | `list_business_rules` | Active business rules and SQL guardrails for a connection |
66
87
  | `get_relationships` | Inferred + validated foreign keys with confidence scores |
88
+
89
+ ### Anti-patterns + daily digest (4)
90
+
91
+ | Tool | Purpose |
92
+ |---|---|
67
93
  | `get_anti_patterns` | Schema-level or query-level anti-patterns |
94
+ | `get_latest_digest` | Most recent DeepSQL daily digest (slow queries + AI commentary) |
95
+ | `list_digests` | Recent digest metadata (find one by date) |
96
+ | `get_digest_by_id` | Full digest body |
97
+
98
+ ### Index advisor — workload-weighted + apply tool (5)
99
+
100
+ | Tool | Purpose |
101
+ |---|---|
68
102
  | `get_index_recommendations` | Pre-computed top-N workload-weighted index recommendations |
69
103
  | `apply_index_recommendation` | Apply (or dry-run with HypoPG) an index recommendation and measure benefit |
70
- | `analyze_slow_queries` | Recent slow queries with fingerprints, durations, examples |
71
- | `get_slow_query_timeline` | Day-by-day timeline for one fingerprint from the 30-day analytics store |
104
+ | `list_index_recommendations` | Browse the full recommendation history by status |
105
+ | `refresh_index_recommendations` | Force a fresh accumulation cycle (skip 6-hour scheduler wait) |
106
+ | `dismiss_index_recommendation` | Reject a recommendation explicitly |
107
+
108
+ ### Index catalog diagnostics — live pg_stat_* / sys.* probes (5)
109
+
110
+ | Tool | Purpose |
111
+ |---|---|
112
+ | `get_missing_indexes` | Schema-walk view of suspected missing indexes |
113
+ | `get_index_health` | Total/bloated/unused/duplicate/biggest indexes summary |
114
+ | `get_unused_indexes` | Indexes with zero/near-zero scans (drop candidates) |
115
+ | `get_duplicate_indexes` | Redundant prefix-duplicate indexes |
116
+ | `get_table_index_usage` | Per-table scan/read/fetch counts on every index |
117
+
118
+ ### Slow queries (9)
119
+
120
+ | Tool | Purpose |
121
+ |---|---|
122
+ | `analyze_slow_queries` | Recent slow queries with fingerprints, durations, examples (triggers fresh collection) |
123
+ | `get_latest_slow_query_analysis` | Read the most recent persisted analysis (no new work) |
124
+ | `list_slow_query_history` | List past analysis runs (compact metadata) |
125
+ | `get_slow_query_timeline` | Day-by-day timeline for one fingerprint from the 30-day store |
72
126
  | `get_query_regressions` | Slow queries that regressed on the latest daily run |
73
127
  | `list_tracked_queries` | All fingerprints tracked in the 30-day store |
74
128
  | `get_slow_query_customers` | Tenants ranked by total slow-query load |
75
129
  | `get_query_samples` | Literal SQL samples (with bind values) for one fingerprint |
76
130
  | `get_slow_query_insights` | Pre-computed AI insights — hotspots / remediation / tail-risk / plan-drift / skew |
77
- | `optimize_slow_query` | AI optimization recommendations (index DDL + query rewrites) for a specific SQL |
131
+
132
+ (`optimize_slow_query` is also in MCP — see the slow-query family.)
133
+
134
+ ### Growth analytics (5)
135
+
136
+ | Tool | Purpose |
137
+ |---|---|
78
138
  | `get_table_growth` | Per-table size/row growth from persistent stats history |
79
139
  | `get_growth_anomalies` | DeepSQL-flagged sudden growth spikes with severity and root-cause hints |
140
+ | `acknowledge_growth_anomaly` | Mark an anomaly as expected (silences `unacknowledgedOnly` queries) |
141
+ | `get_growth_config` | Read current alert thresholds and sensitivity |
142
+ | `set_growth_config` | Update alert thresholds (admin-gated server-side) |
143
+
144
+ ### Plan + execute (2 — the write-capable pair)
145
+
146
+ | Tool | Purpose |
147
+ |---|---|
80
148
  | `execute_sql` | Run any SQL — backend enforces role-based policy (developers read-only, admins can mutate with two-step confirm) |
81
149
  | `analyze_query_plan` | AI-enriched plan analysis (parsed plan tree, performance issues, index recommendations, written summary that uses the connection's schema + business rules) |
82
150
 
@@ -518,6 +518,352 @@ const TOOL_DEFINITIONS = [
518
518
  additionalProperties: false,
519
519
  },
520
520
  },
521
+
522
+ // ─── Phase A symmetry: tools added to match the `deepsql` CLI surface ───
523
+ //
524
+ // Each tool below mirrors a CLI subcommand that previously had no MCP
525
+ // equivalent. Connection write operations (add/update/remove) are
526
+ // intentionally NOT exposed as MCP tools — they require DB credentials
527
+ // and we don't want secrets crossing the agent's conversation history.
528
+ // Customers manage connections via `deepsql connections add` at a TTY.
529
+
530
+ {
531
+ name: "get_current_user",
532
+ description:
533
+ "Return the authenticated user behind the current MCP token: username, role, "
534
+ + "and the DeepSQL host this MCP server is bound to. Use this when the agent "
535
+ + "needs to know whether the caller is admin-capable before suggesting a "
536
+ + "DDL/DML run, or when explaining role-based restrictions to the user.",
537
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
538
+ },
539
+ {
540
+ name: "test_connection",
541
+ description:
542
+ "Validate a saved connection: contacts the database, runs the privilege "
543
+ + "report, and returns whether the connection (and SSH tunnel, if any) is "
544
+ + "reachable. Read-only on the customer's DB. Use when diagnosing a `?:` "
545
+ + "Connected status from `list_connections` or before suggesting a SQL run "
546
+ + "against a connection the agent hasn't touched yet. Takes a saved "
547
+ + "connectionId only — does NOT accept ad-hoc credentials (those go through "
548
+ + "`deepsql connections add` at a terminal, not chat).",
549
+ inputSchema: {
550
+ type: "object",
551
+ properties: {
552
+ connectionId: { type: "string", description: "DeepSQL connection ID." },
553
+ },
554
+ required: ["connectionId"],
555
+ additionalProperties: false,
556
+ },
557
+ },
558
+ {
559
+ name: "show_connection",
560
+ description:
561
+ "Return the full saved configuration for one connection with all secret "
562
+ + "fields masked as `(set)`. Useful for diagnosing connection issues "
563
+ + "(host/port/SSL/SSH config). Will never echo a password back; use "
564
+ + "`deepsql connections show` at a TTY if you genuinely need to see "
565
+ + "a secret value.",
566
+ inputSchema: {
567
+ type: "object",
568
+ properties: {
569
+ connectionId: { type: "string", description: "DeepSQL connection ID." },
570
+ },
571
+ required: ["connectionId"],
572
+ additionalProperties: false,
573
+ },
574
+ },
575
+ {
576
+ name: "reinit_connection_brain",
577
+ description:
578
+ "Trigger a fresh brain initialization for a connection: re-scans the "
579
+ + "schema, re-runs key-column / FK inference, re-embeds training context. "
580
+ + "Use after the user reports DeepSQL's schema knowledge is stale (e.g., "
581
+ + "they just ran a migration). Returns immediately with an init-status "
582
+ + "row; the actual reinit runs in the background — poll connection state "
583
+ + "via `list_connections` to see when it transitions back to COMPLETED.",
584
+ inputSchema: {
585
+ type: "object",
586
+ properties: {
587
+ connectionId: { type: "string", description: "DeepSQL connection ID." },
588
+ force: {
589
+ type: "boolean",
590
+ description: "Restart even if a previous init is currently RUNNING. Defaults to false.",
591
+ },
592
+ },
593
+ required: ["connectionId"],
594
+ additionalProperties: false,
595
+ },
596
+ },
597
+ {
598
+ name: "get_latest_digest",
599
+ description:
600
+ "Return the most recent DeepSQL daily digest for a connection (or the "
601
+ + "workspace if no connectionId). Digests are nightly summaries of slow "
602
+ + "queries + AI commentary written to Slack. Useful for context when the "
603
+ + "user asks 'what changed in the database recently?' without needing a "
604
+ + "fresh slow-query analysis.",
605
+ inputSchema: {
606
+ type: "object",
607
+ properties: {
608
+ connectionId: {
609
+ type: "string",
610
+ description: "Optional. Filter to one connection. Omit for workspace-wide.",
611
+ },
612
+ },
613
+ additionalProperties: false,
614
+ },
615
+ },
616
+ {
617
+ name: "list_digests",
618
+ description:
619
+ "Return the N most recent DeepSQL daily digests (compact metadata, not full "
620
+ + "body). Use to find the digest id for a date the user references "
621
+ + "('what was in yesterday's digest?'), then fetch the full content via "
622
+ + "`get_digest_by_id`.",
623
+ inputSchema: {
624
+ type: "object",
625
+ properties: {
626
+ connectionId: { type: "string", description: "Optional. Filter to one connection." },
627
+ count: { type: "integer", minimum: 1, maximum: 100, description: "Defaults to 10." },
628
+ },
629
+ additionalProperties: false,
630
+ },
631
+ },
632
+ {
633
+ name: "get_digest_by_id",
634
+ description:
635
+ "Return the full body of one DeepSQL daily digest by id, including the AI "
636
+ + "narrative and the slow-query list it was built from.",
637
+ inputSchema: {
638
+ type: "object",
639
+ properties: {
640
+ digestId: { type: "string", description: "Digest id (from list_digests)." },
641
+ connectionId: {
642
+ type: "string",
643
+ description: "Optional. Required only if multiple connections share digest ids.",
644
+ },
645
+ },
646
+ required: ["digestId"],
647
+ additionalProperties: false,
648
+ },
649
+ },
650
+ {
651
+ name: "get_missing_indexes",
652
+ description:
653
+ "Catalog probe: returns indexes the DeepSQL advisor thinks are MISSING "
654
+ + "based on live workload (joins to unindexed columns, sort/group on big "
655
+ + "tables, etc.). This is the schema-walk view — for the workload-weighted "
656
+ + "ROI-ranked recommendations (with HypoPG cost-delta), use "
657
+ + "`get_index_recommendations` instead.",
658
+ inputSchema: {
659
+ type: "object",
660
+ properties: { connectionId: { type: "string", description: "DeepSQL connection ID." } },
661
+ required: ["connectionId"],
662
+ additionalProperties: false,
663
+ },
664
+ },
665
+ {
666
+ name: "get_index_health",
667
+ description:
668
+ "Comprehensive index health report for a connection: total indexes, "
669
+ + "bloated indexes, unused indexes, duplicate-prefix indexes, biggest "
670
+ + "indexes by size. Use as the first read when the user says 'audit my "
671
+ + "indexes' or 'are my indexes healthy?'.",
672
+ inputSchema: {
673
+ type: "object",
674
+ properties: { connectionId: { type: "string", description: "DeepSQL connection ID." } },
675
+ required: ["connectionId"],
676
+ additionalProperties: false,
677
+ },
678
+ },
679
+ {
680
+ name: "get_unused_indexes",
681
+ description:
682
+ "Catalog probe: indexes the DB has reported zero (or near-zero) scans "
683
+ + "against since last reset. Each returned row has the table, index name, "
684
+ + "size, and scan count. Dropping these is a quick storage + write-cost "
685
+ + "win, but verify scan counters aren't recent before suggesting.",
686
+ inputSchema: {
687
+ type: "object",
688
+ properties: { connectionId: { type: "string", description: "DeepSQL connection ID." } },
689
+ required: ["connectionId"],
690
+ additionalProperties: false,
691
+ },
692
+ },
693
+ {
694
+ name: "get_duplicate_indexes",
695
+ description:
696
+ "Catalog probe: indexes that are redundant prefixes of other indexes on "
697
+ + "the same table. Returns groups — each group is a set of indexes the "
698
+ + "optimizer would treat as interchangeable, with the recommendation to "
699
+ + "keep the longest/widest one.",
700
+ inputSchema: {
701
+ type: "object",
702
+ properties: { connectionId: { type: "string", description: "DeepSQL connection ID." } },
703
+ required: ["connectionId"],
704
+ additionalProperties: false,
705
+ },
706
+ },
707
+ {
708
+ name: "get_table_index_usage",
709
+ description:
710
+ "Per-table index usage statistics: every index on the given table with its "
711
+ + "scan count, tuples read, and tuples fetched. Use to diagnose 'why isn't "
712
+ + "my index being used?' or to decide which of several composite indexes "
713
+ + "to drop.",
714
+ inputSchema: {
715
+ type: "object",
716
+ properties: {
717
+ connectionId: { type: "string", description: "DeepSQL connection ID." },
718
+ tableName: { type: "string", description: "Table name (case-sensitive on Postgres)." },
719
+ },
720
+ required: ["connectionId", "tableName"],
721
+ additionalProperties: false,
722
+ },
723
+ },
724
+ {
725
+ name: "list_index_recommendations",
726
+ description:
727
+ "List ALL index recommendations for a connection, optionally filtered by "
728
+ + "status (PENDING / APPLIED / DISMISSED). For just the top-N pending ones "
729
+ + "with full evidence, use `get_index_recommendations` instead — that's the "
730
+ + "agent-facing entry point. This tool is for browsing the recommendation "
731
+ + "history (e.g. 'what did we apply last week?').",
732
+ inputSchema: {
733
+ type: "object",
734
+ properties: {
735
+ connectionId: { type: "string", description: "DeepSQL connection ID." },
736
+ status: {
737
+ type: "string",
738
+ enum: ["PENDING", "APPLIED", "DISMISSED"],
739
+ description: "Optional. Defaults to all statuses.",
740
+ },
741
+ },
742
+ required: ["connectionId"],
743
+ additionalProperties: false,
744
+ },
745
+ },
746
+ {
747
+ name: "refresh_index_recommendations",
748
+ description:
749
+ "Force a fresh accumulation cycle: rescans the slow-query log for the "
750
+ + "lookback window and rebuilds the top-N index recommendations. Use when "
751
+ + "the user just deployed a new query pattern and wants to see if the "
752
+ + "advisor picks it up without waiting for the next 6-hour scheduler tick.",
753
+ inputSchema: {
754
+ type: "object",
755
+ properties: { connectionId: { type: "string", description: "DeepSQL connection ID." } },
756
+ required: ["connectionId"],
757
+ additionalProperties: false,
758
+ },
759
+ },
760
+ {
761
+ name: "dismiss_index_recommendation",
762
+ description:
763
+ "Mark a recommendation as DISMISSED so it stops appearing in `top` / "
764
+ + "default `list` queries. Use when the user explicitly rejects a "
765
+ + "recommendation (not the same as APPLIED — dismissed means 'we decided "
766
+ + "not to'). Reversible by an admin editing the row directly.",
767
+ inputSchema: {
768
+ type: "object",
769
+ properties: {
770
+ recommendationId: { type: "string", description: "Recommendation row id." },
771
+ },
772
+ required: ["recommendationId"],
773
+ additionalProperties: false,
774
+ },
775
+ },
776
+ {
777
+ name: "get_latest_slow_query_analysis",
778
+ description:
779
+ "Return the most recently completed slow-query analysis run for a "
780
+ + "connection (the persisted result, no fresh collection). Faster than "
781
+ + "`analyze_slow_queries` because it doesn't trigger new work — use as "
782
+ + "the first read when investigating 'what's slow right now?', then "
783
+ + "fall back to `analyze_slow_queries` only if the latest is stale.",
784
+ inputSchema: {
785
+ type: "object",
786
+ properties: { connectionId: { type: "string", description: "DeepSQL connection ID." } },
787
+ required: ["connectionId"],
788
+ additionalProperties: false,
789
+ },
790
+ },
791
+ {
792
+ name: "list_slow_query_history",
793
+ description:
794
+ "List past slow-query analysis runs for a connection (compact metadata: "
795
+ + "id, timestamp, count, severity breakdown, AI-summary length). Use to "
796
+ + "find an older analysis to compare current state against, or to spot "
797
+ + "trends in how many slow queries are firing day-over-day.",
798
+ inputSchema: {
799
+ type: "object",
800
+ properties: {
801
+ connectionId: { type: "string", description: "DeepSQL connection ID." },
802
+ limit: {
803
+ type: "integer",
804
+ minimum: 1,
805
+ maximum: 100,
806
+ description: "Number of past analyses to return. Defaults to 10.",
807
+ },
808
+ },
809
+ required: ["connectionId"],
810
+ additionalProperties: false,
811
+ },
812
+ },
813
+ {
814
+ name: "acknowledge_growth_anomaly",
815
+ description:
816
+ "Mark a detected growth anomaly as acknowledged so it stops appearing "
817
+ + "in `get_growth_anomalies(unacknowledgedOnly=true)`. Use after the user "
818
+ + "confirms the growth was expected (e.g., 'yes, we did a big backfill "
819
+ + "yesterday'). Does NOT delete the anomaly — it remains in history for "
820
+ + "audit/timeline purposes.",
821
+ inputSchema: {
822
+ type: "object",
823
+ properties: {
824
+ anomalyId: { type: "string", description: "Anomaly row id (from get_growth_anomalies)." },
825
+ },
826
+ required: ["anomalyId"],
827
+ additionalProperties: false,
828
+ },
829
+ },
830
+ {
831
+ name: "get_growth_config",
832
+ description:
833
+ "Return the alert thresholds and detection sensitivity currently "
834
+ + "configured for table-growth monitoring on a connection. Useful to "
835
+ + "explain to the user why a particular growth event did or didn't fire "
836
+ + "an anomaly.",
837
+ inputSchema: {
838
+ type: "object",
839
+ properties: {
840
+ connectionId: { type: "string", description: "DeepSQL connection ID." },
841
+ },
842
+ required: ["connectionId"],
843
+ additionalProperties: false,
844
+ },
845
+ },
846
+ {
847
+ name: "set_growth_config",
848
+ description:
849
+ "Update the alert thresholds / sensitivity for growth monitoring on a "
850
+ + "connection. Admin-gated. The config body shape matches what "
851
+ + "`get_growth_config` returns — use that first to fetch current values, "
852
+ + "then submit a modified copy.",
853
+ inputSchema: {
854
+ type: "object",
855
+ properties: {
856
+ connectionId: { type: "string", description: "DeepSQL connection ID." },
857
+ config: {
858
+ type: "object",
859
+ description: "Full config object (see get_growth_config for shape).",
860
+ additionalProperties: true,
861
+ },
862
+ },
863
+ required: ["connectionId", "config"],
864
+ additionalProperties: false,
865
+ },
866
+ },
521
867
  ];
522
868
 
523
869
  class DeepSqlApiError extends Error {
@@ -1557,6 +1903,211 @@ async function handleToolCall(config, name, args = {}) {
1557
1903
  return buildToolResult(name, payload);
1558
1904
  }
1559
1905
 
1906
+ // ─── Phase A symmetry — tools added to match the CLI surface ─────────
1907
+
1908
+ case "get_current_user": {
1909
+ const payload = await callDeepSqlApi(config, "/auth/me");
1910
+ return buildToolResult(name, payload);
1911
+ }
1912
+
1913
+ case "test_connection": {
1914
+ const connectionId = String(args.connectionId || "").trim();
1915
+ if (!connectionId) return buildToolError("connectionId is required.");
1916
+ // POST /connections/test with just { id } reuses the saved encrypted
1917
+ // creds server-side — no secrets cross the wire from the MCP client.
1918
+ const payload = await callDeepSqlApi(config, "/connections/test", {
1919
+ method: "POST",
1920
+ json: { id: connectionId },
1921
+ });
1922
+ return buildToolResult(name, payload);
1923
+ }
1924
+
1925
+ case "show_connection": {
1926
+ const connectionId = String(args.connectionId || "").trim();
1927
+ if (!connectionId) return buildToolError("connectionId is required.");
1928
+ // Backend has no GET /connections/{id}; list + filter mirrors CLI behavior.
1929
+ // Server returns secrets already masked.
1930
+ const all = await callDeepSqlApi(config, "/connections");
1931
+ const list = Array.isArray(all) ? all : [];
1932
+ const found = list.find((c) => (c.id || c.connectionId) === connectionId);
1933
+ if (!found) return buildToolError(`Connection ${connectionId} not found.`);
1934
+ return buildToolResult(name, found);
1935
+ }
1936
+
1937
+ case "reinit_connection_brain": {
1938
+ const connectionId = String(args.connectionId || "").trim();
1939
+ if (!connectionId) return buildToolError("connectionId is required.");
1940
+ const payload = await callDeepSqlApi(
1941
+ config,
1942
+ `/connections/${encodeURIComponent(connectionId)}/reinit`,
1943
+ { method: "POST", json: { force: args.force === true } },
1944
+ );
1945
+ return buildToolResult(name, payload);
1946
+ }
1947
+
1948
+ case "get_latest_digest": {
1949
+ const params = ["size=1"];
1950
+ if (args.connectionId) params.push(`connectionId=${encodeURIComponent(args.connectionId)}`);
1951
+ const payload = await callDeepSqlApi(config, `/admin/slack/digests?${params.join("&")}`);
1952
+ // Server returns a Page<>; surface the first element so consumers don't
1953
+ // have to know about Spring Data's content array.
1954
+ const first = Array.isArray(payload?.content) ? payload.content[0] : null;
1955
+ return buildToolResult(name, first || null);
1956
+ }
1957
+
1958
+ case "list_digests": {
1959
+ const count = clampInteger(args.count, 1, 100, 10);
1960
+ const params = [`size=${count}`];
1961
+ if (args.connectionId) params.push(`connectionId=${encodeURIComponent(args.connectionId)}`);
1962
+ const payload = await callDeepSqlApi(config, `/admin/slack/digests?${params.join("&")}`);
1963
+ return buildToolResult(name, Array.isArray(payload?.content) ? payload.content : []);
1964
+ }
1965
+
1966
+ case "get_digest_by_id": {
1967
+ const digestId = String(args.digestId || "").trim();
1968
+ if (!digestId) return buildToolError("digestId is required.");
1969
+ // No GET /admin/slack/digests/{id} — pull recent and filter.
1970
+ const params = ["size=100"];
1971
+ if (args.connectionId) params.push(`connectionId=${encodeURIComponent(args.connectionId)}`);
1972
+ const payload = await callDeepSqlApi(config, `/admin/slack/digests?${params.join("&")}`);
1973
+ const list = Array.isArray(payload?.content) ? payload.content : [];
1974
+ const found = list.find((d) => String(d.id) === digestId);
1975
+ if (!found) return buildToolError(`Digest ${digestId} not in the recent 100 digests.`);
1976
+ return buildToolResult(name, found);
1977
+ }
1978
+
1979
+ case "get_missing_indexes": {
1980
+ const connectionId = String(args.connectionId || "").trim();
1981
+ if (!connectionId) return buildToolError("connectionId is required.");
1982
+ const payload = await callDeepSqlApi(
1983
+ config, `/advisor/indexes/${encodeURIComponent(connectionId)}`);
1984
+ return buildToolResult(name, payload);
1985
+ }
1986
+
1987
+ case "get_index_health": {
1988
+ const connectionId = String(args.connectionId || "").trim();
1989
+ if (!connectionId) return buildToolError("connectionId is required.");
1990
+ const payload = await callDeepSqlApi(
1991
+ config, `/index-advisor/${encodeURIComponent(connectionId)}/health-report`);
1992
+ return buildToolResult(name, payload);
1993
+ }
1994
+
1995
+ case "get_unused_indexes": {
1996
+ const connectionId = String(args.connectionId || "").trim();
1997
+ if (!connectionId) return buildToolError("connectionId is required.");
1998
+ const payload = await callDeepSqlApi(
1999
+ config, `/index-advisor/${encodeURIComponent(connectionId)}/unused`);
2000
+ return buildToolResult(name, payload);
2001
+ }
2002
+
2003
+ case "get_duplicate_indexes": {
2004
+ const connectionId = String(args.connectionId || "").trim();
2005
+ if (!connectionId) return buildToolError("connectionId is required.");
2006
+ const payload = await callDeepSqlApi(
2007
+ config, `/index-advisor/${encodeURIComponent(connectionId)}/duplicates`);
2008
+ return buildToolResult(name, payload);
2009
+ }
2010
+
2011
+ case "get_table_index_usage": {
2012
+ const connectionId = String(args.connectionId || "").trim();
2013
+ const tableName = String(args.tableName || "").trim();
2014
+ if (!connectionId) return buildToolError("connectionId is required.");
2015
+ if (!tableName) return buildToolError("tableName is required.");
2016
+ const payload = await callDeepSqlApi(
2017
+ config,
2018
+ `/index-advisor/${encodeURIComponent(connectionId)}/usage/${encodeURIComponent(tableName)}`,
2019
+ );
2020
+ return buildToolResult(name, payload);
2021
+ }
2022
+
2023
+ case "list_index_recommendations": {
2024
+ const connectionId = String(args.connectionId || "").trim();
2025
+ if (!connectionId) return buildToolError("connectionId is required.");
2026
+ // The "list ALL" path returns every status; we filter client-side
2027
+ // for PENDING/APPLIED/DISMISSED to match the CLI semantics.
2028
+ const all = await callDeepSqlApi(
2029
+ config, `/index-recommendations/${encodeURIComponent(connectionId)}`);
2030
+ const list = Array.isArray(all) ? all : [];
2031
+ const filtered = args.status
2032
+ ? list.filter((r) => String(r.status || "").toUpperCase() === String(args.status).toUpperCase())
2033
+ : list;
2034
+ return buildToolResult(name, filtered);
2035
+ }
2036
+
2037
+ case "refresh_index_recommendations": {
2038
+ const connectionId = String(args.connectionId || "").trim();
2039
+ if (!connectionId) return buildToolError("connectionId is required.");
2040
+ const payload = await callDeepSqlApi(
2041
+ config,
2042
+ `/index-recommendations/generate/${encodeURIComponent(connectionId)}`,
2043
+ { method: "POST" },
2044
+ );
2045
+ return buildToolResult(name, payload);
2046
+ }
2047
+
2048
+ case "dismiss_index_recommendation": {
2049
+ const recommendationId = String(args.recommendationId || "").trim();
2050
+ if (!recommendationId) return buildToolError("recommendationId is required.");
2051
+ const payload = await callDeepSqlApi(
2052
+ config,
2053
+ `/index-recommendations/${encodeURIComponent(recommendationId)}/dismiss`,
2054
+ { method: "PUT" },
2055
+ );
2056
+ return buildToolResult(name, payload);
2057
+ }
2058
+
2059
+ case "get_latest_slow_query_analysis": {
2060
+ const connectionId = String(args.connectionId || "").trim();
2061
+ if (!connectionId) return buildToolError("connectionId is required.");
2062
+ const payload = await callDeepSqlApi(
2063
+ config, `/slow-queries/latest/${encodeURIComponent(connectionId)}`);
2064
+ return buildToolResult(name, payload);
2065
+ }
2066
+
2067
+ case "list_slow_query_history": {
2068
+ const connectionId = String(args.connectionId || "").trim();
2069
+ if (!connectionId) return buildToolError("connectionId is required.");
2070
+ const limit = clampInteger(args.limit, 1, 100, 10);
2071
+ const payload = await callDeepSqlApi(
2072
+ config,
2073
+ `/slow-queries/history/${encodeURIComponent(connectionId)}?limit=${limit}`,
2074
+ );
2075
+ return buildToolResult(name, payload);
2076
+ }
2077
+
2078
+ case "acknowledge_growth_anomaly": {
2079
+ const anomalyId = String(args.anomalyId || "").trim();
2080
+ if (!anomalyId) return buildToolError("anomalyId is required.");
2081
+ const payload = await callDeepSqlApi(
2082
+ config,
2083
+ `/growth-monitoring/anomalies/${encodeURIComponent(anomalyId)}/acknowledge`,
2084
+ { method: "POST" },
2085
+ );
2086
+ return buildToolResult(name, payload);
2087
+ }
2088
+
2089
+ case "get_growth_config": {
2090
+ const connectionId = String(args.connectionId || "").trim();
2091
+ if (!connectionId) return buildToolError("connectionId is required.");
2092
+ const payload = await callDeepSqlApi(
2093
+ config, `/growth-monitoring/config/${encodeURIComponent(connectionId)}`);
2094
+ return buildToolResult(name, payload);
2095
+ }
2096
+
2097
+ case "set_growth_config": {
2098
+ const connectionId = String(args.connectionId || "").trim();
2099
+ if (!connectionId) return buildToolError("connectionId is required.");
2100
+ if (!args.config || typeof args.config !== "object") {
2101
+ return buildToolError("config object is required (see get_growth_config for shape).");
2102
+ }
2103
+ const payload = await callDeepSqlApi(
2104
+ config,
2105
+ "/growth-monitoring/config",
2106
+ { method: "POST", json: { ...args.config, connectionId } },
2107
+ );
2108
+ return buildToolResult(name, payload);
2109
+ }
2110
+
1560
2111
  default:
1561
2112
  return buildToolError(`Unknown tool: ${name}`);
1562
2113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepsql/mcp",
3
- "version": "0.18.2",
3
+ "version": "0.19.0",
4
4
  "description": "DeepSQL CLI and stdio MCP server for self-hosted deployments",
5
5
  "bin": {
6
6
  "deepsql": "bin/deepsql.js",
@@ -2,13 +2,18 @@
2
2
 
3
3
  You have two DeepSQL surfaces available:
4
4
 
5
- 1. **MCP tools** — JSON-RPC tools loaded into your session (21 of them).
6
- Use these for in-session retrieval and SQL execution.
5
+ 1. **MCP tools** — JSON-RPC tools loaded into your session (41 of them
6
+ as of 0.19.0). The MCP surface mirrors the CLI for almost every
7
+ read/diagnostic operation, plus the two execute tools and the index-
8
+ apply tool.
7
9
 
8
10
  2. **`deepsql` CLI** — a shell binary on the user's PATH (~19 commands).
9
- Use this for things the MCP doesn't expose (index health, daily
10
- digest, slow-query streaming, connection management, admin ops, the
11
- plan-analysis `analyze` command, etc.). You can shell out to the CLI
11
+ Use this for the few things still CLI-only: auth (`login`/`logout`),
12
+ the MCP installer (`deepsql mcp config --install`), connection
13
+ CRUD (anything that takes plaintext DB credentials those must
14
+ stay out of your conversation history), the `setup` wizard, the
15
+ streaming `slow-queries optimize` SSE flow, and per-user admin ops
16
+ (`users`/`access`/`permissions`). You can shell out to the CLI
12
17
  yourself when appropriate; you can also point the user at the
13
18
  command if it's interactive.
14
19
 
@@ -98,6 +103,21 @@ usually doesn't know about either; that's exactly why DeepSQL exists.
98
103
  | `get_growth_anomalies(connectionId, tableName?, unacknowledgedOnly?, days?)` | DeepSQL-flagged sudden growth spikes with severity (CRITICAL/WARNING/INFO), anomaly type, before/after sizes, confidence score. Check this BEFORE walking the user through a slow-query plan — a recent growth anomaly is often the real root cause. |
99
104
  | `execute_sql(connectionId, query, ...)` | Run any SQL — SELECT for everyone, DML/DDL for admins (two-step confirm). |
100
105
  | `analyze_query_plan(connectionId, query, useAnalyze=false)` | AI-enriched plan analysis (issues + index recs + summary). |
106
+ | `get_current_user()` | Authenticated user + role. Use to know whether the caller is admin-capable before suggesting DDL. |
107
+ | `test_connection(connectionId)` | Validates a saved connection (privilege report + SSH tunnel check). Read-only on the customer's DB. |
108
+ | `show_connection(connectionId)` | Full saved config with secrets masked. Diagnose host/port/SSL/SSH issues. |
109
+ | `reinit_connection_brain(connectionId, force?)` | Trigger a fresh schema scan + brain re-embedding. Use after the user reports stale schema knowledge. |
110
+ | `get_latest_digest(connectionId?)` / `list_digests(...)` / `get_digest_by_id(digestId, ...)` | Daily DeepSQL digests (slow queries + AI commentary written nightly to Slack). |
111
+ | `get_missing_indexes(connectionId)` | Schema-walk view of suspected missing indexes (complements the workload-weighted `get_index_recommendations`). |
112
+ | `get_index_health(connectionId)` | Total/bloated/unused/duplicate/biggest indexes. Use as first read on "audit my indexes". |
113
+ | `get_unused_indexes(connectionId)` / `get_duplicate_indexes(connectionId)` | Catalog probes for storage + write-cost wins. |
114
+ | `get_table_index_usage(connectionId, tableName)` | Per-table index scan/read/fetch counts. Diagnose "why isn't my index being used?". |
115
+ | `list_index_recommendations(connectionId, status?)` | Browse the full recommendation history (PENDING/APPLIED/DISMISSED). |
116
+ | `refresh_index_recommendations(connectionId)` | Force a fresh accumulation cycle (skips the 6-hour scheduler wait). |
117
+ | `dismiss_index_recommendation(recommendationId)` | Reject a recommendation. Use only after the user explicitly says no. |
118
+ | `get_latest_slow_query_analysis(connectionId)` / `list_slow_query_history(...)` | Read persisted slow-query analysis runs (faster than `analyze_slow_queries` which triggers new work). |
119
+ | `acknowledge_growth_anomaly(anomalyId)` | Mark a growth alert as expected. Use after the user confirms the growth was intentional. |
120
+ | `get_growth_config(connectionId)` / `set_growth_config(connectionId, config)` | View/edit growth-monitoring alert thresholds. |
101
121
 
102
122
  `EXPLAIN` and `EXPLAIN ANALYZE` are just SQL — type them as the query if
103
123
  you want raw plan output. Use `analyze_query_plan` when you want the
package/src/cli.test.js CHANGED
@@ -116,3 +116,45 @@ test("--no-color suppresses ANSI escapes in help output", async () => {
116
116
  // No ESC bytes anywhere.
117
117
  assert.equal(/\x1b\[/.test(io.out()), false, "help output should be plain when --no-color is set");
118
118
  });
119
+
120
+ // ─── help/dispatch drift guard ─────────────────────────────────────────────
121
+ //
122
+ // Every command-module SUBCOMMANDS map must be reflected in COMMAND_HELP so
123
+ // `deepsql <command> -h` actually documents what `deepsql <command> <sub>`
124
+ // will dispatch. A previous regression shipped four new slow-query
125
+ // subcommands that worked but weren't listed in -h — users assumed the
126
+ // install was stale. This test catches that class of bug at PR time.
127
+ //
128
+ // To extend: add a `{ command, modulePath }` entry. The module must export
129
+ // a `SUBCOMMANDS` object whose keys are the dispatchable subcommand names.
130
+ const HELP_DRIFT_TARGETS = [
131
+ { command: "slow-queries", modulePath: "./commands/slow-queries" },
132
+ ];
133
+
134
+ for (const { command, modulePath } of HELP_DRIFT_TARGETS) {
135
+ test(`COMMAND_HELP["${command}"] documents every dispatchable subcommand`, () => {
136
+ const mod = require(modulePath);
137
+ assert.ok(mod.SUBCOMMANDS, `${modulePath} must export SUBCOMMANDS for the drift guard`);
138
+ const help = COMMAND_HELP[command];
139
+ assert.ok(help, `COMMAND_HELP["${command}"] is missing`);
140
+ assert.ok(Array.isArray(help.subcommands), `COMMAND_HELP["${command}"].subcommands must be an array`);
141
+
142
+ const dispatchable = Object.keys(mod.SUBCOMMANDS).sort();
143
+ // Each help row is [usage-string, description]; the first whitespace-
144
+ // separated token of usage-string is the subcommand name.
145
+ const documented = help.subcommands
146
+ .map((row) => String(row[0]).trim().split(/\s+/)[0])
147
+ .sort();
148
+ const missing = dispatchable.filter((s) => !documented.includes(s));
149
+ const stale = documented.filter((s) => !dispatchable.includes(s));
150
+
151
+ assert.deepEqual(
152
+ { missing, stale },
153
+ { missing: [], stale: [] },
154
+ `\`deepsql ${command} -h\` is out of sync with src/commands/${command}.js:\n`
155
+ + ` missing from help (callable but undocumented): ${missing.join(", ") || "none"}\n`
156
+ + ` stale in help (documented but not callable): ${stale.join(", ") || "none"}\n`
157
+ + `Fix: update COMMAND_HELP["${command}"].subcommands in src/cli.js.`,
158
+ );
159
+ });
160
+ }