Skip to main content

nexql_tools/
schema.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 NexQL-OSS Team
3
4//! Tool descriptors for the active MCP surface (Phase 2–4).
5
6use serde_json::{Value, json};
7
8use crate::registry::{ToolName, ToolProfile};
9
10#[derive(Debug, Clone)]
11pub struct ToolSpec {
12    pub name: ToolName,
13    pub description: &'static str,
14    pub input_schema: Value,
15}
16
17/// Tools filtered by the requested `ToolProfile`.
18pub fn tools_for_profile(profile: ToolProfile) -> Vec<ToolSpec> {
19    let names = ToolName::for_profile(profile);
20    active_tools()
21        .into_iter()
22        .filter(|spec| names.contains(&spec.name))
23        .collect()
24}
25
26/// Generate a formatted Mermaid ERD snippet for an object's column & key structure.
27pub fn generate_mermaid_erd_for_object(obj: &serde_json::Map<String, Value>) -> Option<String> {
28    let ref_name = obj.get("ref").and_then(|v| v.as_str()).unwrap_or("table");
29    let safe_table_name = ref_name.replace(['.', '-'], "_");
30    let mut diagram = String::from("erDiagram\n");
31    diagram.push_str(&format!("    {safe_table_name} {{\n"));
32    if let Some(columns) = obj.get("columns").and_then(|v| v.as_array()) {
33        for col in columns {
34            let name = col.get("name").and_then(|v| v.as_str()).unwrap_or("col");
35            let data_type = col.get("type").and_then(|v| v.as_str()).unwrap_or("string");
36            let pk = col.get("is_pk").and_then(|v| v.as_bool()).unwrap_or(false);
37            let fk = col.get("is_fk").and_then(|v| v.as_bool()).unwrap_or(false);
38            let key_str = match (pk, fk) {
39                (true, true) => " PK,FK",
40                (true, false) => " PK",
41                (false, true) => " FK",
42                _ => "",
43            };
44            diagram.push_str(&format!(
45                "        {} {}{}\n",
46                data_type.replace(' ', "_"),
47                name,
48                key_str
49            ));
50        }
51    }
52    diagram.push_str("    }\n");
53    Some(diagram)
54}
55
56/// Generate a formatted Mermaid ERD diagram snippet for a FK join path.
57pub fn generate_mermaid_diagram_for_path(path_val: &Value) -> Option<String> {
58    let edges = path_val
59        .as_array()
60        .or_else(|| path_val.get("path").and_then(|v| v.as_array()))?;
61    if edges.is_empty() {
62        return None;
63    }
64    let mut diagram = String::from("erDiagram\n");
65    for edge in edges {
66        let from = edge
67            .get("from")
68            .and_then(|v| v.as_str())
69            .unwrap_or("A")
70            .replace(['.', '-'], "_");
71        let to = edge
72            .get("to")
73            .and_then(|v| v.as_str())
74            .unwrap_or("B")
75            .replace(['.', '-'], "_");
76        let from_col = edge.get("from_col").and_then(|v| v.as_str()).unwrap_or("");
77        let to_col = edge.get("to_col").and_then(|v| v.as_str()).unwrap_or("");
78        diagram.push_str(&format!(
79            "    {from} }}|--|| {to} : \"{from_col} -> {to_col}\"\n"
80        ));
81    }
82    Some(diagram)
83}
84
85/// Phase 2 catalog tools (live Postgres; no index required).
86pub fn phase2_catalog_tools() -> Vec<ToolSpec> {
87    vec![
88        ToolSpec {
89            name: ToolName::ListConnections,
90            description: "List configured connection profiles (never includes passwords).",
91            input_schema: object_schema(&[]),
92        },
93        ToolSpec {
94            name: ToolName::ListDatabases,
95            description: "List databases available for a connection profile.",
96            input_schema: object_schema(&[(
97                "connectionId",
98                "string",
99                true,
100                "Name of a configured connection profile, as returned by list_connections.",
101            )]),
102        },
103        ToolSpec {
104            name: ToolName::ListSchemas,
105            description: "List non-system schemas in the currently selected database.",
106            input_schema: object_schema(&[]),
107        },
108        ToolSpec {
109            name: ToolName::ListObjects,
110            description: "List objects (tables, views, …) in a schema.",
111            input_schema: object_schema(&[
112                (
113                    "schema",
114                    "string",
115                    false,
116                    "Schema name to list, e.g. \"public\". Defaults to all non-system schemas.",
117                ),
118                (
119                    "kind",
120                    "string",
121                    false,
122                    "Filter by object kind: \"table\", \"view\", or \"materialized_view\".",
123                ),
124            ]),
125        },
126        ToolSpec {
127            name: ToolName::GetCurrentContext,
128            description: "Return the active profile, database, and access mode.",
129            input_schema: object_schema(&[]),
130        },
131        ToolSpec {
132            name: ToolName::SwitchConnection,
133            description: "Switch the session to another connection profile / database.",
134            input_schema: object_schema(&[
135                (
136                    "connectionId",
137                    "string",
138                    true,
139                    "Name of a configured connection profile, as returned by list_connections.",
140                ),
141                (
142                    "database",
143                    "string",
144                    false,
145                    "Database name on that connection. Defaults to the profile's configured database.",
146                ),
147            ]),
148        },
149        ToolSpec {
150            name: ToolName::RunSelect,
151            description: "Run a read-only SELECT or WITH query. DML/DDL are rejected. Only reference tables/columns confirmed via list_schemas / list_objects.",
152            input_schema: object_schema(&[(
153                "sql",
154                "string",
155                true,
156                "A single SELECT or WITH statement, schema-qualify table names where possible.",
157            )]),
158        },
159        ToolSpec {
160            name: ToolName::ExplainQuery,
161            description: "Run EXPLAIN (no ANALYZE execute) for a SELECT/WITH query.",
162            input_schema: object_schema(&[(
163                "sql",
164                "string",
165                true,
166                "A single SELECT or WITH statement to explain (not executed).",
167            )]),
168        },
169        ToolSpec {
170            name: ToolName::DiscoverTools,
171            description: "Dynamically discover and inspect specialized MCP database tools by keyword query (e.g., 'locks', 'bloat', 'index') or category ('query', 'dba', 'write'). Use this when you need specialized tools beyond the core surface.",
172            input_schema: object_schema(&[
173                (
174                    "query",
175                    "string",
176                    false,
177                    "Free-text keyword to search tool names/descriptions, e.g. \"locks\" or \"bloat\".",
178                ),
179                (
180                    "category",
181                    "string",
182                    false,
183                    "Restrict to a tool category: \"query\", \"dba\", \"meta\", or \"write\".",
184                ),
185            ]),
186        },
187        ToolSpec {
188            name: ToolName::RunDoctor,
189            description: "Run diagnostic health checks on active database connection, permissions, session guards, and index status.",
190            input_schema: object_schema(&[]),
191        },
192        ToolSpec {
193            name: ToolName::SetupConnection,
194            description: "Automatically detect or configure a database connection. Scans environment variables, workspace files, and local settings, eliciting missing credentials when supported.",
195            input_schema: object_schema(&[
196                ("name", "string", false, "Profile name to assign/detect."),
197                (
198                    "url",
199                    "string",
200                    false,
201                    "Full postgres:// connection URL; if given, host/port/dbname/user/password are ignored.",
202                ),
203                ("host", "string", false, "Database server hostname."),
204                (
205                    "port",
206                    "number",
207                    false,
208                    "Database server port (default 5432).",
209                ),
210                ("dbname", "string", false, "Database name to connect to."),
211                ("user", "string", false, "Database role/username."),
212                ("password", "string", false, "Database role password."),
213                (
214                    "sslmode",
215                    "string",
216                    false,
217                    "libpq sslmode value, e.g. \"disable\", \"require\", \"verify-full\".",
218                ),
219                (
220                    "interactive",
221                    "boolean",
222                    false,
223                    "Prompt (elicit) for missing credentials instead of failing. Default false.",
224                ),
225            ]),
226        },
227        ToolSpec {
228            name: ToolName::SaveProfile,
229            description: "Save or update a database connection profile in user configuration with atomic backup and dynamic session reload.",
230            input_schema: object_schema(&[
231                ("name", "string", true, "Profile name to save under."),
232                (
233                    "url",
234                    "string",
235                    false,
236                    "Full postgres:// connection URL; if given, host/port/dbname/user/password are ignored.",
237                ),
238                ("host", "string", false, "Database server hostname."),
239                (
240                    "port",
241                    "number",
242                    false,
243                    "Database server port (default 5432).",
244                ),
245                ("dbname", "string", false, "Database name to connect to."),
246                ("user", "string", false, "Database role/username."),
247                ("password", "string", false, "Database role password."),
248                (
249                    "sslmode",
250                    "string",
251                    false,
252                    "libpq sslmode value, e.g. \"disable\", \"require\", \"verify-full\".",
253                ),
254                (
255                    "access_mode",
256                    "string",
257                    false,
258                    "Session access mode: \"read\", \"write\", or \"admin\".",
259                ),
260                (
261                    "max_rows",
262                    "number",
263                    false,
264                    "Row cap applied to run_select/export_query for this profile.",
265                ),
266            ]),
267        },
268        ToolSpec {
269            name: ToolName::TestProfile,
270            description: "Test a database connection profile or inline parameters and return server version, superuser status, and round-trip latency.",
271            input_schema: object_schema(&[
272                (
273                    "name",
274                    "string",
275                    false,
276                    "Existing profile name to test. Omit to test inline parameters instead.",
277                ),
278                (
279                    "url",
280                    "string",
281                    false,
282                    "Full postgres:// connection URL to test inline (alternative to name).",
283                ),
284                (
285                    "host",
286                    "string",
287                    false,
288                    "Database server hostname (inline test).",
289                ),
290                (
291                    "port",
292                    "number",
293                    false,
294                    "Database server port (inline test).",
295                ),
296                ("dbname", "string", false, "Database name (inline test)."),
297                (
298                    "user",
299                    "string",
300                    false,
301                    "Database role/username (inline test).",
302                ),
303                (
304                    "password",
305                    "string",
306                    false,
307                    "Database role password (inline test).",
308                ),
309                (
310                    "sslmode",
311                    "string",
312                    false,
313                    "libpq sslmode value (inline test), e.g. \"require\".",
314                ),
315            ]),
316        },
317        ToolSpec {
318            name: ToolName::ExportProfile,
319            description: "Export a secret-sanitized TOML configuration for team sharing (.nexql/config.toml) with all passwords and credentials stripped.",
320            input_schema: object_schema(&[(
321                "format",
322                "string",
323                false,
324                "Output format, currently only \"toml\" is supported (default).",
325            )]),
326        },
327        ToolSpec {
328            name: ToolName::ImportProfile,
329            description: "Import a team configuration file (.nexql/config.toml) or TOML content into local user configuration.",
330            input_schema: object_schema(&[
331                (
332                    "content",
333                    "string",
334                    false,
335                    "Raw TOML content to import. Provide this or `path`, not both.",
336                ),
337                (
338                    "path",
339                    "string",
340                    false,
341                    "Filesystem path to a .nexql/config.toml file to import.",
342                ),
343            ]),
344        },
345    ]
346}
347
348/// Phase 3 index tools (require `nexql-mcp index build`).
349pub fn phase3_index_tools() -> Vec<ToolSpec> {
350    vec![
351        ToolSpec {
352            name: ToolName::ResolveTarget,
353            description: "Autonomously find which connection/database matches a user's hint (a database name, environment, host fragment) and/or an object hint (a table/view name), searching across ALL configured connections and their indexed schemas. Call this FIRST whenever the request references a database, environment, or object that is not the current session context — before search_schema, before list_connections. When the match is unambiguous it switches the session context automatically and returns the resolved connection/database; only returns `ambiguous: true` with a candidate list when multiple equally-plausible matches exist, in which case surface those candidates to the user rather than guessing.",
354            input_schema: object_schema(&[
355                (
356                    "hint",
357                    "string",
358                    false,
359                    "Free-text hint about the target connection: database name, environment, or host fragment.",
360                ),
361                (
362                    "objectHint",
363                    "string",
364                    false,
365                    "Free-text hint about a table/view name expected to live in the target database.",
366                ),
367            ]),
368        },
369        ToolSpec {
370            name: ToolName::SearchSchema,
371            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.",
372            input_schema: object_schema(&[(
373                "query",
374                "string",
375                true,
376                "Natural-language or keyword search, e.g. \"customer email\".",
377            )]),
378        },
379        ToolSpec {
380            name: ToolName::DescribeObject,
381            description: "Get structural details of a specific database object (table, view, or materialized view) including columns, data types, constraints, and indexes.",
382            input_schema: object_schema(&[(
383                "ref",
384                "string",
385                true,
386                "Object reference. Prefer schema-qualified form \"schema.name\" (e.g. \"public.customers\"); a bare name is resolved if unambiguous.",
387            )]),
388        },
389        ToolSpec {
390            name: ToolName::GetJoinPath,
391            description: "Find the shortest path of join relationships and foreign keys between two database tables.",
392            input_schema: object_schema(&[
393                (
394                    "a",
395                    "string",
396                    true,
397                    "Source table reference. Prefer schema-qualified form \"schema.name\" (e.g. \"public.orders\"); a bare name is resolved if unambiguous.",
398                ),
399                (
400                    "b",
401                    "string",
402                    true,
403                    "Target table reference. Prefer schema-qualified form \"schema.name\" (e.g. \"public.customers\"); a bare name is resolved if unambiguous.",
404                ),
405            ]),
406        },
407        ToolSpec {
408            name: ToolName::SampleValues,
409            description: "Retrieve a list of sample values from a specific table column to inspect its contents. Only works on read-only SELECT queries.",
410            input_schema: object_schema(&[
411                (
412                    "ref",
413                    "string",
414                    true,
415                    "Table/view reference. Prefer schema-qualified form \"schema.name\" (e.g. \"public.orders\"); a bare name is resolved if unambiguous.",
416                ),
417                (
418                    "col",
419                    "string",
420                    true,
421                    "Column name within `ref` to sample values from.",
422                ),
423            ]),
424        },
425        ToolSpec {
426            name: ToolName::RebuildIndex,
427            description: "Rebuild the schema index for the active database connection.",
428            input_schema: object_schema(&[(
429                "depth",
430                "string",
431                false,
432                "Index scope: \"shallow\" (structure only) or \"full\" (structure + sample values). Default \"full\".",
433            )]),
434        },
435        ToolSpec {
436            name: ToolName::RefreshIndex,
437            description: "Refresh the schema index for the active database connection using previous build scope.",
438            input_schema: object_schema(&[]),
439        },
440    ]
441}
442
443/// Phase 4 monitoring / DDL tools (descriptions from ToolSpec.ts where available).
444pub fn phase4_tools() -> Vec<ToolSpec> {
445    vec![
446        ToolSpec {
447            name: ToolName::GetDdl,
448            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).",
449            input_schema: object_schema(&[
450                (
451                    "ref",
452                    "string",
453                    true,
454                    "Object reference. Prefer schema-qualified form \"schema.name\" (e.g. \"public.orders\"); a bare name is resolved if unambiguous.",
455                ),
456                (
457                    "kind",
458                    "string",
459                    false,
460                    "Object kind hint: \"table\", \"view\", \"materialized_view\", \"function\", or \"index\". Auto-detected if omitted.",
461                ),
462            ]),
463        },
464        ToolSpec {
465            name: ToolName::TableStats,
466            description: "Get size, row-count, activity (scans, inserts/updates/deletes, dead tuples, vacuum/analyze times) and per-column statistics for a specific table.",
467            input_schema: object_schema(&[(
468                "ref",
469                "string",
470                true,
471                "Table reference. Prefer schema-qualified form \"schema.name\" (e.g. \"public.orders\"); a bare name is resolved if unambiguous.",
472            )]),
473        },
474        ToolSpec {
475            name: ToolName::IndexUsage,
476            description: "Get index usage statistics (scan counts, size, definition, type) for a specific table's indexes. Useful for finding unused or missing indexes.",
477            input_schema: object_schema(&[(
478                "ref",
479                "string",
480                true,
481                "Table reference. Prefer schema-qualified form \"schema.name\" (e.g. \"public.orders\"); a bare name is resolved if unambiguous.",
482            )]),
483        },
484        ToolSpec {
485            name: ToolName::ListRunningQueries,
486            description: "List currently executing (non-idle) queries in the connected database with pid, user, state, wait events, and duration.",
487            input_schema: object_schema(&[]),
488        },
489        ToolSpec {
490            name: ToolName::FindBlockingLocks,
491            description: "Find lock contention: which queries are blocked waiting on locks and which pids/queries are blocking them.",
492            input_schema: object_schema(&[]),
493        },
494        ToolSpec {
495            name: ToolName::SlowQueries,
496            description: "List the slowest statements by mean execution time from pg_stat_statements (requires the extension; returns a hint if not installed).",
497            input_schema: object_schema(&[(
498                "limit",
499                "number",
500                false,
501                "Maximum number of statements to return. Default 10.",
502            )]),
503        },
504        ToolSpec {
505            name: ToolName::DbHealthCheck,
506            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.",
507            input_schema: object_schema(&[]),
508        },
509        ToolSpec {
510            name: ToolName::ExplainAnalyze,
511            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.",
512            input_schema: object_schema(&[(
513                "sql",
514                "string",
515                true,
516                "A single SELECT or WITH statement to analyze. It executes for real (inside a rolled-back transaction).",
517            )]),
518        },
519        ToolSpec {
520            name: ToolName::AnalyzeQueryPlan,
521            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.",
522            input_schema: object_schema(&[
523                (
524                    "sql",
525                    "string",
526                    true,
527                    "A single SELECT or WITH statement to explain/analyze.",
528                ),
529                (
530                    "analyze",
531                    "boolean",
532                    false,
533                    "If true, actually execute the query for real timings (EXPLAIN ANALYZE) instead of estimate-only. Default false.",
534                ),
535            ]),
536        },
537        ToolSpec {
538            name: ToolName::GetIndexStatus,
539            description: "Return schema-index status for the active connection/database: indexed_at, fingerprint, object counts, and optional live fingerprint drift. Returns status:\"missing\" (not an error) if no index has been built yet.",
540            input_schema: object_schema(&[]),
541        },
542        ToolSpec {
543            name: ToolName::ListExtensions,
544            description: "List installed PostgreSQL extensions (name, version, schema).",
545            input_schema: object_schema(&[]),
546        },
547        ToolSpec {
548            name: ToolName::ServerSettings,
549            description: "Return key PostgreSQL server settings from pg_settings (memory, connections, timeouts, autovacuum, version).",
550            input_schema: object_schema(&[]),
551        },
552        ToolSpec {
553            name: ToolName::SuggestIndexes,
554            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.",
555            input_schema: object_schema(&[
556                (
557                    "limit",
558                    "number",
559                    false,
560                    "Maximum number of suggestions to return. Default 10.",
561                ),
562                (
563                    "sql",
564                    "string",
565                    false,
566                    "Optional SELECT/WITH statement whose plan should inform the suggestions.",
567                ),
568            ]),
569        },
570        ToolSpec {
571            name: ToolName::FindUnusedIndexes,
572            description: "List indexes with idx_scan = 0 (never used since stats reset), excluding primary keys, unique indexes, and constraint-backed indexes.",
573            input_schema: object_schema(&[(
574                "limit",
575                "number",
576                false,
577                "Maximum number of indexes to return. Default 10.",
578            )]),
579        },
580        ToolSpec {
581            name: ToolName::BloatReport,
582            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 %.",
583            input_schema: object_schema(&[(
584                "limit",
585                "number",
586                false,
587                "Maximum number of tables to return. Default 10.",
588            )]),
589        },
590        ToolSpec {
591            name: ToolName::FindMissingFks,
592            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).",
593            input_schema: object_schema(&[(
594                "limit",
595                "number",
596                false,
597                "Maximum number of candidates to return. Default 20.",
598            )]),
599        },
600    ]
601}
602
603/// Phase 4b read-only breadth (export / role introspection).
604pub fn phase4b_tools() -> Vec<ToolSpec> {
605    vec![
606        ToolSpec {
607            name: ToolName::ExportQuery,
608            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.",
609            input_schema: object_schema(&[
610                (
611                    "sql",
612                    "string",
613                    true,
614                    "A single SELECT or WITH statement to run and export.",
615                ),
616                (
617                    "format",
618                    "string",
619                    false,
620                    "Output format: \"csv\", \"json\", or \"sqlinsert\". Default \"csv\".",
621                ),
622                (
623                    "table",
624                    "string",
625                    false,
626                    "Target table as \"schema.name\", required when format=\"sqlinsert\".",
627                ),
628            ]),
629        },
630        ToolSpec {
631            name: ToolName::ListRoles,
632            description: "List PostgreSQL roles (attributes). Pass role to get memberships and table privileges for one role.",
633            input_schema: object_schema(&[(
634                "role",
635                "string",
636                false,
637                "Specific role name to inspect memberships/privileges for. Omit to list all roles.",
638            )]),
639        },
640        ToolSpec {
641            name: ToolName::DbDashboard,
642            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.",
643            input_schema: object_schema(&[]),
644        },
645        ToolSpec {
646            name: ToolName::DeepPlanAnalysis,
647            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).",
648            input_schema: object_schema(&[
649                (
650                    "sql",
651                    "string",
652                    true,
653                    "A single SELECT or WITH statement to analyze.",
654                ),
655                (
656                    "analyze",
657                    "boolean",
658                    false,
659                    "If false, use plan-only estimates without executing the query. Default true.",
660                ),
661            ]),
662        },
663        ToolSpec {
664            name: ToolName::SchemaDiff,
665            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.",
666            input_schema: object_schema(&[
667                (
668                    "sourceSchema",
669                    "string",
670                    true,
671                    "Name of the schema to treat as the baseline, e.g. \"public\".",
672                ),
673                (
674                    "targetSchema",
675                    "string",
676                    true,
677                    "Name of the schema to diff against the baseline, e.g. \"staging\".",
678                ),
679            ]),
680        },
681        ToolSpec {
682            name: ToolName::GenerateMigration,
683            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.",
684            input_schema: object_schema(&[
685                (
686                    "sourceSchema",
687                    "string",
688                    true,
689                    "Name of the schema to migrate from, e.g. \"public\".",
690                ),
691                (
692                    "targetSchema",
693                    "string",
694                    true,
695                    "Name of the schema to migrate towards, e.g. \"staging\".",
696                ),
697            ]),
698        },
699        ToolSpec {
700            name: ToolName::AutoTuneQuery,
701            description: "Autonomous query tuner: executes EXPLAIN ANALYZE, checks table statistics, evaluates missing indexes, and outputs step-by-step performance tuning recommendations.",
702            input_schema: object_schema(&[(
703                "sql",
704                "string",
705                true,
706                "A single SELECT or WITH statement to tune. It executes for real.",
707            )]),
708        },
709        ToolSpec {
710            name: ToolName::CheckDdlSafety,
711            description: "Safety guard for migration DDL: inspects SQL for dangerous exclusive locks (e.g. non-concurrent index builds, column drops, table rewrites) and outputs risk scores and safe zero-downtime alternatives.",
712            input_schema: object_schema(&[(
713                "ddl",
714                "string",
715                true,
716                "One or more DDL statements to inspect for locking risk. Not executed.",
717            )]),
718        },
719    ]
720}
721
722/// Phase 9 write/admin tools (always listed; access-gated at dispatch).
723pub fn phase9_write_tools() -> Vec<ToolSpec> {
724    vec![
725        ToolSpec {
726            name: ToolName::ExecuteSql,
727            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.",
728            input_schema: object_schema(&[
729                (
730                    "sql",
731                    "string",
732                    true,
733                    "A single DML statement (INSERT/UPDATE/DELETE), or DDL if the session is in admin mode.",
734                ),
735                (
736                    "dry_run",
737                    "boolean",
738                    false,
739                    "If true, execute then roll back so no change persists. Default false.",
740                ),
741            ]),
742        },
743        ToolSpec {
744            name: ToolName::EditRow,
745            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.",
746            input_schema: object_schema(&[
747                ("table", "string", true, "Target table as \"schema.name\"."),
748                (
749                    "action",
750                    "string",
751                    true,
752                    "Operation to perform: \"insert\", \"update\", or \"delete\".",
753                ),
754                (
755                    "values",
756                    "object",
757                    false,
758                    "Column name/value pairs to insert or update. Required for insert/update.",
759                ),
760                (
761                    "pk",
762                    "object",
763                    false,
764                    "Primary-key column name/value pairs identifying the row. Required for update/delete.",
765                ),
766            ]),
767        },
768        ToolSpec {
769            name: ToolName::ImportData,
770            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.",
771            input_schema: object_schema(&[
772                ("table", "string", true, "Target table as \"schema.name\"."),
773                (
774                    "rows",
775                    "array",
776                    true,
777                    "Array of row objects, each mapping column name to value.",
778                ),
779                (
780                    "columns",
781                    "array",
782                    false,
783                    "Explicit column order to insert with. Defaults to the keys of the first row.",
784                ),
785            ]),
786        },
787        ToolSpec {
788            name: ToolName::ApplyDdl,
789            description: "Apply a DDL statement (CREATE, ALTER, DROP, TRUNCATE, …) in admin mode inside a transaction. Set dry_run=true to roll back.",
790            input_schema: object_schema(&[
791                ("sql", "string", true, "A single DDL statement to apply."),
792                (
793                    "dry_run",
794                    "boolean",
795                    false,
796                    "If true, execute then roll back so no change persists. Default false.",
797                ),
798            ]),
799        },
800        ToolSpec {
801            name: ToolName::CreateIndexConcurrently,
802            description: "Run CREATE INDEX CONCURRENTLY outside a transaction (non-blocking index build). Admin mode only.",
803            input_schema: object_schema(&[(
804                "sql",
805                "string",
806                true,
807                "A single CREATE INDEX CONCURRENTLY statement.",
808            )]),
809        },
810        ToolSpec {
811            name: ToolName::RunMaintenance,
812            description: "Run VACUUM, ANALYZE, or REINDEX outside a transaction. Admin mode only. Optional table (schema.name); vacuum supports full=true.",
813            input_schema: object_schema(&[
814                (
815                    "action",
816                    "string",
817                    true,
818                    "Maintenance action: \"vacuum\", \"analyze\", or \"reindex\".",
819                ),
820                (
821                    "table",
822                    "string",
823                    false,
824                    "Target table as \"schema.name\". Omit to run against the whole database where supported.",
825                ),
826                (
827                    "full",
828                    "boolean",
829                    false,
830                    "For action=\"vacuum\", run VACUUM FULL (rewrites the table, takes an exclusive lock). Default false.",
831                ),
832            ]),
833        },
834        ToolSpec {
835            name: ToolName::TerminateQuery,
836            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.",
837            input_schema: object_schema(&[
838                (
839                    "pid",
840                    "number",
841                    true,
842                    "Backend process id to cancel/terminate.",
843                ),
844                (
845                    "force",
846                    "boolean",
847                    false,
848                    "If true, force-terminate the backend (pg_terminate_backend) instead of a soft cancel. Default false.",
849                ),
850            ]),
851        },
852    ]
853}
854
855/// Full tools/list surface for the current phase (catalog + index + Phase 4 + 4b + 9).
856pub fn active_tools() -> Vec<ToolSpec> {
857    let mut specs = phase2_catalog_tools();
858    specs.extend(phase3_index_tools());
859    specs.extend(phase4_tools());
860    specs.extend(phase4b_tools());
861    specs.extend(phase9_write_tools());
862    specs
863}
864
865/// Build a JSON Schema object for a tool's input, from `(name, type, required, description)`
866/// tuples. Every property carries a non-empty description (see `all_tools_have_descriptions`
867/// test) and the object forbids unknown properties so a typo'd/hallucinated argument fails
868/// loudly instead of silently vanishing.
869fn object_schema(props: &[(&str, &str, bool, &str)]) -> Value {
870    let mut properties = serde_json::Map::new();
871    let mut required = Vec::new();
872    for (name, ty, req, description) in props {
873        let mut prop_val = match *ty {
874            "array" => match *name {
875                "columns" => json!({ "type": "array", "items": { "type": "string" } }),
876                "rows" => json!({ "type": "array", "items": { "type": "object" } }),
877                _ => json!({ "type": "array", "items": {} }),
878            },
879            _ => json!({ "type": *ty }),
880        };
881        prop_val["description"] = json!(*description);
882        properties.insert((*name).into(), prop_val);
883        if *req {
884            required.push(json!(*name));
885        }
886    }
887    json!({
888        "type": "object",
889        "properties": properties,
890        "required": required,
891        "additionalProperties": false
892    })
893}
894
895#[cfg(test)]
896mod tests {
897    use super::*;
898    use crate::registry::ToolName;
899
900    #[test]
901    fn active_tools_lists_fifty_three() {
902        let specs = active_tools();
903        assert_eq!(specs.len(), 53);
904        assert_eq!(specs.len(), ToolName::ACTIVE.len());
905        for (spec, name) in specs.iter().zip(ToolName::ACTIVE.iter()) {
906            assert_eq!(spec.name, *name);
907        }
908    }
909
910    #[test]
911    fn phase9_write_tools_count() {
912        assert_eq!(phase9_write_tools().len(), ToolName::PHASE9.len());
913    }
914
915    #[test]
916    fn array_properties_have_items() {
917        for tool in active_tools() {
918            if let Some(props) = tool
919                .input_schema
920                .get("properties")
921                .and_then(|p| p.as_object())
922            {
923                for (prop_name, prop_val) in props {
924                    if prop_val.get("type").and_then(|t| t.as_str()) == Some("array") {
925                        assert!(
926                            prop_val.get("items").is_some(),
927                            "Tool '{}' parameter '{}' is array type but missing 'items'",
928                            tool.name.as_str(),
929                            prop_name
930                        );
931                    }
932                }
933            }
934        }
935    }
936
937    #[test]
938    fn profile_tools_filtering() {
939        let query_specs = tools_for_profile(ToolProfile::Query);
940        assert_eq!(query_specs.len(), 18);
941
942        let dba_specs = tools_for_profile(ToolProfile::Dba);
943        assert_eq!(dba_specs.len(), 28);
944
945        let meta_specs = tools_for_profile(ToolProfile::Meta);
946        assert_eq!(meta_specs.len(), 10);
947
948        let full_specs = tools_for_profile(ToolProfile::Full);
949        assert_eq!(full_specs.len(), 53);
950    }
951
952    /// Regression guard for Issue 1: every tool parameter must carry a non-empty
953    /// `description`, and every input schema must forbid unknown properties.
954    #[test]
955    fn all_tools_have_descriptions_and_reject_unknown_properties() {
956        for tool in active_tools() {
957            assert_eq!(
958                tool.input_schema.get("additionalProperties"),
959                Some(&json!(false)),
960                "Tool '{}' input_schema must set additionalProperties: false",
961                tool.name.as_str()
962            );
963            if let Some(props) = tool
964                .input_schema
965                .get("properties")
966                .and_then(|p| p.as_object())
967            {
968                for (prop_name, prop_val) in props {
969                    let desc = prop_val.get("description").and_then(|d| d.as_str());
970                    assert!(
971                        desc.is_some_and(|d| !d.is_empty()),
972                        "Tool '{}' parameter '{}' is missing a non-empty description",
973                        tool.name.as_str(),
974                        prop_name
975                    );
976                }
977            }
978        }
979    }
980
981    #[test]
982    fn generate_mermaid_erd_test() {
983        let obj = json!({
984            "ref": "public.users",
985            "columns": [
986                { "name": "id", "type": "uuid", "is_pk": true, "is_fk": false },
987                { "name": "email", "type": "varchar", "is_pk": false, "is_fk": false },
988                { "name": "org_id", "type": "uuid", "is_pk": false, "is_fk": true }
989            ]
990        });
991        let diagram = generate_mermaid_erd_for_object(obj.as_object().unwrap()).unwrap();
992        assert!(diagram.contains("erDiagram"));
993        assert!(diagram.contains("public_users"));
994        assert!(diagram.contains("uuid id PK"));
995        assert!(diagram.contains("uuid org_id FK"));
996    }
997
998    #[test]
999    fn generate_mermaid_diagram_for_path_test() {
1000        let path = json!([
1001            { "from": "public.orders", "to": "public.users", "from_col": "user_id", "to_col": "id" }
1002        ]);
1003        let diagram = generate_mermaid_diagram_for_path(&path).unwrap();
1004        assert!(diagram.contains("erDiagram"));
1005        assert!(diagram.contains("public_orders }|--|| public_users : \"user_id -> id\""));
1006    }
1007}