1use 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
17pub 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
26pub 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
56pub 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
85pub 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
348pub 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
443pub 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
603pub 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
722pub 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
855pub 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
865fn 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 #[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}