Skip to main content

nexql_tools/
schema.rs

1//! Tool descriptors for the active MCP surface (Phase 2–4).
2
3use serde_json::{Value, json};
4
5use crate::registry::ToolName;
6
7#[derive(Debug, Clone)]
8pub struct ToolSpec {
9    pub name: ToolName,
10    pub description: &'static str,
11    pub input_schema: Value,
12}
13
14/// Phase 2 catalog tools (live Postgres; no index required).
15pub fn phase2_catalog_tools() -> Vec<ToolSpec> {
16    vec![
17        ToolSpec {
18            name: ToolName::ListConnections,
19            description: "List configured connection profiles (never includes passwords).",
20            input_schema: object_schema(&[]),
21        },
22        ToolSpec {
23            name: ToolName::ListDatabases,
24            description: "List databases available for a connection profile.",
25            input_schema: object_schema(&[("connectionId", "string", true)]),
26        },
27        ToolSpec {
28            name: ToolName::ListSchemas,
29            description: "List non-system schemas in the currently selected database.",
30            input_schema: object_schema(&[]),
31        },
32        ToolSpec {
33            name: ToolName::ListObjects,
34            description: "List objects (tables, views, …) in a schema.",
35            input_schema: object_schema(&[("schema", "string", false), ("kind", "string", false)]),
36        },
37        ToolSpec {
38            name: ToolName::GetCurrentContext,
39            description: "Return the active profile, database, and access mode.",
40            input_schema: object_schema(&[]),
41        },
42        ToolSpec {
43            name: ToolName::SwitchConnection,
44            description: "Switch the session to another connection profile / database.",
45            input_schema: object_schema(&[
46                ("connectionId", "string", true),
47                ("database", "string", false),
48            ]),
49        },
50        ToolSpec {
51            name: ToolName::RunSelect,
52            description: "Run a read-only SELECT or WITH query. DML/DDL are rejected. Only reference tables/columns confirmed via list_schemas / list_objects.",
53            input_schema: object_schema(&[("sql", "string", true)]),
54        },
55        ToolSpec {
56            name: ToolName::ExplainQuery,
57            description: "Run EXPLAIN (no ANALYZE execute) for a SELECT/WITH query.",
58            input_schema: object_schema(&[("sql", "string", true)]),
59        },
60    ]
61}
62
63/// Phase 3 index tools (require `nexql-mcp index build`).
64pub fn phase3_index_tools() -> Vec<ToolSpec> {
65    vec![
66        ToolSpec {
67            name: ToolName::SearchSchema,
68            description: "Search the live, auto-indexed database schema using natural language or keywords to find tables, views, materialized views, and functions matching the query. Call this FIRST before writing any SQL — do not assume a table exists without finding it here.",
69            input_schema: object_schema(&[("query", "string", true)]),
70        },
71        ToolSpec {
72            name: ToolName::DescribeObject,
73            description: "Get structural details of a specific database object (table, view, or materialized view) including columns, data types, constraints, and indexes.",
74            input_schema: object_schema(&[("ref", "string", true)]),
75        },
76        ToolSpec {
77            name: ToolName::GetJoinPath,
78            description: "Find the shortest path of join relationships and foreign keys between two database tables.",
79            input_schema: object_schema(&[("a", "string", true), ("b", "string", true)]),
80        },
81        ToolSpec {
82            name: ToolName::SampleValues,
83            description: "Retrieve a list of sample values from a specific table column to inspect its contents. Only works on read-only SELECT queries.",
84            input_schema: object_schema(&[("ref", "string", true), ("col", "string", true)]),
85        },
86    ]
87}
88
89/// Phase 4 monitoring / DDL tools (descriptions from ToolSpec.ts where available).
90pub fn phase4_tools() -> Vec<ToolSpec> {
91    vec![
92        ToolSpec {
93            name: ToolName::GetDdl,
94            description: "Get the DDL / definition of a database object. Views, materialized views, functions, and indexes return their CREATE statement; tables return structured DDL (columns, constraints, indexes).",
95            input_schema: object_schema(&[("ref", "string", true), ("kind", "string", false)]),
96        },
97        ToolSpec {
98            name: ToolName::TableStats,
99            description: "Get size, row-count, activity (scans, inserts/updates/deletes, dead tuples, vacuum/analyze times) and per-column statistics for a specific table.",
100            input_schema: object_schema(&[("ref", "string", true)]),
101        },
102        ToolSpec {
103            name: ToolName::IndexUsage,
104            description: "Get index usage statistics (scan counts, size, definition, type) for a specific table's indexes. Useful for finding unused or missing indexes.",
105            input_schema: object_schema(&[("ref", "string", true)]),
106        },
107        ToolSpec {
108            name: ToolName::ListRunningQueries,
109            description: "List currently executing (non-idle) queries in the connected database with pid, user, state, wait events, and duration.",
110            input_schema: object_schema(&[]),
111        },
112        ToolSpec {
113            name: ToolName::FindBlockingLocks,
114            description: "Find lock contention: which queries are blocked waiting on locks and which pids/queries are blocking them.",
115            input_schema: object_schema(&[]),
116        },
117        ToolSpec {
118            name: ToolName::SlowQueries,
119            description: "List the slowest statements by mean execution time from pg_stat_statements (requires the extension; returns a hint if not installed).",
120            input_schema: object_schema(&[("limit", "number", false)]),
121        },
122        ToolSpec {
123            name: ToolName::DbHealthCheck,
124            description: "Run a database health overview: size/connection stats, cache hit ratio, tables with dead tuples needing vacuum, active connections, and blocking-lock count. Sections that fail are reported individually; partial results are still returned.",
125            input_schema: object_schema(&[]),
126        },
127        ToolSpec {
128            name: ToolName::ExplainAnalyze,
129            description: "Run EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) on a SELECT/WITH query inside a read-only transaction that is always rolled back. WARNING: the query actually executes (volatile functions run), so expect real query runtime.",
130            input_schema: object_schema(&[("sql", "string", true)]),
131        },
132        ToolSpec {
133            name: ToolName::AnalyzeQueryPlan,
134            description: "Run EXPLAIN (FORMAT JSON) on a SELECT/WITH query and return parsed plan metrics (scan counts, bottlenecks, buffer stats) plus performance recommendations. Set analyze=true to also execute the query for actual timings.",
135            input_schema: object_schema(&[("sql", "string", true), ("analyze", "boolean", false)]),
136        },
137        ToolSpec {
138            name: ToolName::GetIndexStatus,
139            description: "Return schema-index status for the active connection/database: indexed_at, fingerprint, object counts, and optional live fingerprint drift.",
140            input_schema: object_schema(&[]),
141        },
142        ToolSpec {
143            name: ToolName::ListExtensions,
144            description: "List installed PostgreSQL extensions (name, version, schema).",
145            input_schema: object_schema(&[]),
146        },
147        ToolSpec {
148            name: ToolName::ServerSettings,
149            description: "Return key PostgreSQL server settings from pg_settings (memory, connections, timeouts, autovacuum, version).",
150            input_schema: object_schema(&[]),
151        },
152        ToolSpec {
153            name: ToolName::SuggestIndexes,
154            description: "Suggest indexes from high sequential-scan tables, unindexed FK columns, and optional pg_stat_statements / EXPLAIN plan heuristics. Pass sql to analyze a specific query plan.",
155            input_schema: object_schema(&[("limit", "number", false), ("sql", "string", false)]),
156        },
157        ToolSpec {
158            name: ToolName::FindUnusedIndexes,
159            description: "List indexes with idx_scan = 0 (never used since stats reset), excluding primary keys, unique indexes, and constraint-backed indexes.",
160            input_schema: object_schema(&[("limit", "number", false)]),
161        },
162        ToolSpec {
163            name: ToolName::BloatReport,
164            description: "Approximate table bloat via dead-tuple ratio from pg_stat_user_tables (simplified estimate — not physical page bloat). Tables with >1000 dead tuples, ordered by bloat %.",
165            input_schema: object_schema(&[("limit", "number", false)]),
166        },
167        ToolSpec {
168            name: ToolName::FindMissingFks,
169            description: "Find likely missing foreign keys: prefers schema-index join-graph inferred edges; falls back to catalog naming (*_id columns without an FK matching a PK).",
170            input_schema: object_schema(&[("limit", "number", false)]),
171        },
172    ]
173}
174
175/// Phase 4b read-only breadth (export / role introspection).
176pub fn phase4b_tools() -> Vec<ToolSpec> {
177    vec![
178        ToolSpec {
179            name: ToolName::ExportQuery,
180            description: "Run a read-only SELECT/WITH and format results as CSV, JSON, or SQL INSERT statements. Honors max-row / max-char caps. For sqlinsert, pass table as schema.name.",
181            input_schema: object_schema(&[
182                ("sql", "string", true),
183                ("format", "string", false),
184                ("table", "string", false),
185            ]),
186        },
187        ToolSpec {
188            name: ToolName::ListRoles,
189            description: "List PostgreSQL roles (attributes). Pass role to get memberships and table privileges for one role.",
190            input_schema: object_schema(&[("role", "string", false)]),
191        },
192        ToolSpec {
193            name: ToolName::DbDashboard,
194            description: "One-shot live metrics bundle: DB size/owner, connection-state breakdown, top tables by size, object counts, active queries, and blocking locks. Soft-fails per section.",
195            input_schema: object_schema(&[]),
196        },
197        ToolSpec {
198            name: ToolName::DeepPlanAnalysis,
199            description: "Run EXPLAIN (ANALYZE by default) and return severity-graded findings: estimate skew, expensive function/CTE/subquery nodes, and recommendations. Set analyze=false for plan-only (no execution).",
200            input_schema: object_schema(&[("sql", "string", true), ("analyze", "boolean", false)]),
201        },
202        ToolSpec {
203            name: ToolName::SchemaDiff,
204            description: "Compare two schemas in the current database (or sourceSchema vs targetSchema). Returns structured table/column/constraint/index diffs. Read-only — does not apply changes.",
205            input_schema: object_schema(&[
206                ("sourceSchema", "string", true),
207                ("targetSchema", "string", true),
208            ]),
209        },
210        ToolSpec {
211            name: ToolName::GenerateMigration,
212            description: "Emit migration SQL to evolve sourceSchema toward targetSchema (from a live schema_diff). Read-only — returns SQL text, never executes it. Destructive drops are commented out.",
213            input_schema: object_schema(&[
214                ("sourceSchema", "string", true),
215                ("targetSchema", "string", true),
216            ]),
217        },
218    ]
219}
220
221/// Phase 9 write/admin tools (always listed; access-gated at dispatch).
222pub fn phase9_write_tools() -> Vec<ToolSpec> {
223    vec![
224        ToolSpec {
225            name: ToolName::ExecuteSql,
226            description: "Execute DML (and DDL in admin mode) inside an explicit transaction. Set dry_run=true to roll back after execution. Errors always roll back.",
227            input_schema: object_schema(&[("sql", "string", true), ("dry_run", "boolean", false)]),
228        },
229        ToolSpec {
230            name: ToolName::EditRow,
231            description: "Structured insert, update, or delete by primary key. The server builds parameterized SQL — pass table (schema.name), action, values, and pk for update/delete.",
232            input_schema: object_schema(&[
233                ("table", "string", true),
234                ("action", "string", true),
235                ("values", "object", false),
236                ("pk", "object", false),
237            ]),
238        },
239        ToolSpec {
240            name: ToolName::ImportData,
241            description: "Batch INSERT rows from a JSON array of objects into a table. Optional columns array fixes column order; otherwise keys from the first row are used.",
242            input_schema: object_schema(&[
243                ("table", "string", true),
244                ("rows", "array", true),
245                ("columns", "array", false),
246            ]),
247        },
248        ToolSpec {
249            name: ToolName::ApplyDdl,
250            description: "Apply a DDL statement (CREATE, ALTER, DROP, TRUNCATE, …) in admin mode inside a transaction. Set dry_run=true to roll back.",
251            input_schema: object_schema(&[("sql", "string", true), ("dry_run", "boolean", false)]),
252        },
253        ToolSpec {
254            name: ToolName::CreateIndexConcurrently,
255            description: "Run CREATE INDEX CONCURRENTLY outside a transaction (non-blocking index build). Admin mode only.",
256            input_schema: object_schema(&[("sql", "string", true)]),
257        },
258        ToolSpec {
259            name: ToolName::RunMaintenance,
260            description: "Run VACUUM, ANALYZE, or REINDEX outside a transaction. Admin mode only. Optional table (schema.name); vacuum supports full=true.",
261            input_schema: object_schema(&[
262                ("action", "string", true),
263                ("table", "string", false),
264                ("full", "boolean", false),
265            ]),
266        },
267        ToolSpec {
268            name: ToolName::TerminateQuery,
269            description: "Cancel (pg_cancel_backend) or force-terminate (pg_terminate_backend) a backend by pid. Admin mode only. Refuses superuser targets and the current session.",
270            input_schema: object_schema(&[("pid", "number", true), ("force", "boolean", false)]),
271        },
272    ]
273}
274
275/// Full tools/list surface for the current phase (catalog + index + Phase 4 + 4b + 9).
276pub fn active_tools() -> Vec<ToolSpec> {
277    let mut specs = phase2_catalog_tools();
278    specs.extend(phase3_index_tools());
279    specs.extend(phase4_tools());
280    specs.extend(phase4b_tools());
281    specs.extend(phase9_write_tools());
282    specs
283}
284
285fn object_schema(props: &[(&str, &str, bool)]) -> Value {
286    let mut properties = serde_json::Map::new();
287    let mut required = Vec::new();
288    for (name, ty, req) in props {
289        let prop_val = match *ty {
290            "array" => match *name {
291                "columns" => json!({ "type": "array", "items": { "type": "string" } }),
292                "rows" => json!({ "type": "array", "items": { "type": "object" } }),
293                _ => json!({ "type": "array", "items": {} }),
294            },
295            _ => json!({ "type": *ty }),
296        };
297        properties.insert((*name).into(), prop_val);
298        if *req {
299            required.push(json!(*name));
300        }
301    }
302    json!({
303        "type": "object",
304        "properties": properties,
305        "required": required
306    })
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::registry::ToolName;
313
314    #[test]
315    fn active_tools_lists_forty_one() {
316        let specs = active_tools();
317        assert_eq!(specs.len(), 41);
318        assert_eq!(specs.len(), ToolName::ACTIVE.len());
319        for (spec, name) in specs.iter().zip(ToolName::ACTIVE.iter()) {
320            assert_eq!(spec.name, *name);
321        }
322    }
323
324    #[test]
325    fn phase9_write_tools_count() {
326        assert_eq!(phase9_write_tools().len(), ToolName::PHASE9.len());
327    }
328
329    #[test]
330    fn array_properties_have_items() {
331        for tool in active_tools() {
332            if let Some(props) = tool
333                .input_schema
334                .get("properties")
335                .and_then(|p| p.as_object())
336            {
337                for (prop_name, prop_val) in props {
338                    if prop_val.get("type").and_then(|t| t.as_str()) == Some("array") {
339                        assert!(
340                            prop_val.get("items").is_some(),
341                            "Tool '{}' parameter '{}' is array type but missing 'items'",
342                            tool.name.as_str(),
343                            prop_name
344                        );
345                    }
346                }
347            }
348        }
349    }
350}