1use std::sync::Arc;
4
5use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
6use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime};
7use nexql_index::{
8 CatalogDb, Embedder, IndexQueryService, IndexStore, PgCatalogDb, QueryPolicyFilter,
9 SearchOptions,
10};
11use nexql_policy::{PolicyFilter, SqlDecision, validate_readonly_sql};
12use rust_decimal::Decimal;
13use serde_json::{Value, json};
14use tokio_postgres::types::{FromSql, Kind, Type};
15use uuid::Uuid;
16
17use crate::error::ToolError;
18use crate::export::{ExportFormat, columns_from_rows, rows_to_csv, rows_to_sql_insert};
19use crate::plan::{analyze_deep_plan, build_explain_sql, extract_plan_metrics};
20use crate::registry::ToolName;
21use crate::schema::{ToolSpec, active_tools};
22use crate::session::ToolSession;
23use crate::sql::{self, REPORT_LIMIT_DEFAULT, SLOW_QUERIES_DEFAULT, parse_ref};
24use crate::write::{
25 apply_ddl, create_index_concurrently, edit_row, execute_sql, import_data, run_maintenance,
26 terminate_query,
27};
28
29const SEARCH_SCHEMA_LIMIT: usize = 10;
31
32const NO_INDEX_HINT: &str =
33 "No schema index configured — set NEXQL_MCP_INDEX_DIR or run `nexql-mcp index build`.";
34
35#[derive(Debug, Clone)]
36pub struct ToolOutcome {
37 pub text: String,
38 pub structured: Option<Value>,
39 pub is_error: bool,
40}
41
42impl ToolOutcome {
43 pub fn ok_json(value: Value) -> Self {
49 let value = ensure_structured_object(value);
50 let text = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
51 Self {
52 text,
53 structured: Some(value),
54 is_error: false,
55 }
56 }
57
58 pub fn err(msg: impl Into<String>) -> Self {
59 let message = msg.into();
60 Self {
61 text: message.clone(),
62 structured: Some(json!({ "error": message })),
63 is_error: true,
64 }
65 }
66}
67
68fn ensure_structured_object(value: Value) -> Value {
70 match value {
71 Value::Array(rows) => json!({ "rows": rows }),
72 other => other,
73 }
74}
75
76pub struct ToolRouter {
77 session: Arc<ToolSession>,
78 index_override: Option<Option<IndexStore>>,
80 use_semantic: bool,
82 embedder: Option<Arc<dyn Embedder>>,
83 specs: Vec<ToolSpec>,
84}
85
86impl ToolRouter {
87 pub fn new(session: Arc<ToolSession>) -> Self {
88 Self {
89 session,
90 index_override: None,
91 use_semantic: false,
92 embedder: None,
93 specs: active_tools(),
94 }
95 }
96
97 pub fn with_index_store(session: Arc<ToolSession>, store: Option<IndexStore>) -> Self {
99 Self {
100 session,
101 index_override: Some(store),
102 use_semantic: false,
103 embedder: None,
104 specs: active_tools(),
105 }
106 }
107
108 pub fn with_semantic(
110 mut self,
111 use_semantic: bool,
112 embedder: Option<Arc<dyn Embedder>>,
113 ) -> Self {
114 self.use_semantic = use_semantic;
115 self.embedder = embedder;
116 self
117 }
118
119 pub fn with_profile(mut self, profile: crate::registry::ToolProfile) -> Self {
121 self.specs = crate::schema::tools_for_profile(profile);
122 self
123 }
124
125 pub fn specs(&self) -> &[ToolSpec] {
126 &self.specs
127 }
128
129 fn index_store(&self) -> Option<&IndexStore> {
130 match &self.index_override {
131 Some(inner) => inner.as_ref(),
132 None => self.session.index_store.as_ref(),
133 }
134 }
135
136 fn query_filter(&self) -> QueryPolicyFilter {
137 policy_to_query_filter(&self.session.filter)
138 }
139
140 pub async fn call(&self, name: &str, args: Value) -> ToolOutcome {
141 match self.call_inner(name, args).await {
142 Ok(out) => out,
143 Err(e) => ToolOutcome::err(e.to_string()),
144 }
145 }
146
147 async fn call_inner(&self, name: &str, args: Value) -> Result<ToolOutcome, ToolError> {
148 let tool = ToolName::parse(name).ok_or_else(|| ToolError::Unknown(name.to_string()))?;
149 match tool {
150 ToolName::ListConnections => Ok(self.list_connections()),
151 ToolName::ListDatabases => self.list_databases(&args).await,
152 ToolName::ListSchemas => self.list_schemas().await,
153 ToolName::ListObjects => self.list_objects(&args).await,
154 ToolName::GetCurrentContext => self.get_current_context().await,
155 ToolName::SwitchConnection => self.switch_connection(&args).await,
156 ToolName::RunSelect => self.run_select(&args).await,
157 ToolName::ExplainQuery => self.explain_query(&args).await,
158 ToolName::SearchSchema => self.search_schema(&args).await,
159 ToolName::DescribeObject => self.describe_object(&args).await,
160 ToolName::GetJoinPath => self.get_join_path(&args).await,
161 ToolName::SampleValues => self.sample_values(&args).await,
162 ToolName::GetDdl => self.get_ddl(&args).await,
163 ToolName::TableStats => self.table_stats(&args).await,
164 ToolName::IndexUsage => self.index_usage(&args).await,
165 ToolName::ListRunningQueries => self.list_running_queries().await,
166 ToolName::FindBlockingLocks => self.find_blocking_locks().await,
167 ToolName::SlowQueries => self.slow_queries(&args).await,
168 ToolName::DbHealthCheck => self.db_health_check().await,
169 ToolName::ExplainAnalyze => self.explain_analyze(&args).await,
170 ToolName::AnalyzeQueryPlan => self.analyze_query_plan(&args).await,
171 ToolName::GetIndexStatus => self.get_index_status().await,
172 ToolName::ListExtensions => self.list_extensions().await,
173 ToolName::ServerSettings => self.server_settings().await,
174 ToolName::SuggestIndexes => self.suggest_indexes(&args).await,
175 ToolName::FindUnusedIndexes => self.find_unused_indexes(&args).await,
176 ToolName::BloatReport => self.bloat_report(&args).await,
177 ToolName::FindMissingFks => self.find_missing_fks(&args).await,
178 ToolName::ExportQuery => self.export_query(&args).await,
179 ToolName::ListRoles => self.list_roles(&args).await,
180 ToolName::DbDashboard => self.db_dashboard().await,
181 ToolName::DeepPlanAnalysis => self.deep_plan_analysis(&args).await,
182 ToolName::SchemaDiff => self.schema_diff(&args).await,
183 ToolName::GenerateMigration => self.generate_migration(&args).await,
184 ToolName::ExecuteSql => self.execute_sql_tool(&args).await,
185 ToolName::EditRow => self.edit_row_tool(&args).await,
186 ToolName::ImportData => self.import_data_tool(&args).await,
187 ToolName::ApplyDdl => self.apply_ddl_tool(&args).await,
188 ToolName::CreateIndexConcurrently => self.create_index_concurrently_tool(&args).await,
189 ToolName::RunMaintenance => self.run_maintenance_tool(&args).await,
190 ToolName::TerminateQuery => self.terminate_query_tool(&args).await,
191 ToolName::ResolveTarget => self.resolve_target(&args).await,
192 ToolName::DiscoverTools => self.discover_tools(&args).await,
193 ToolName::AutoTuneQuery => self.auto_tune_query(&args).await,
194 ToolName::CheckDdlSafety => self.check_ddl_safety_tool(&args).await,
195 }
196 }
197
198 fn require_write(&self) -> Result<(), ToolError> {
199 if !self.session.access_mode.allows_writes() {
200 return Err(ToolError::Execution(
201 "write tools require --access-mode write or admin (current session: read)".into(),
202 ));
203 }
204 Ok(())
205 }
206
207 fn require_admin(&self) -> Result<(), ToolError> {
208 if !self.session.access_mode.allows_admin() {
209 return Err(ToolError::Execution(
210 "admin tools require --access-mode admin".into(),
211 ));
212 }
213 Ok(())
214 }
215
216 async fn execute_sql_tool(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
217 self.require_write()?;
218 let sql = args
219 .get("sql")
220 .and_then(|v| v.as_str())
221 .ok_or_else(|| ToolError::InvalidArgs("sql is required".into()))?;
222 let dry_run = args
223 .get("dry_run")
224 .and_then(|v| v.as_bool())
225 .unwrap_or(false);
226 execute_sql(&self.session, sql, dry_run).await
227 }
228
229 async fn edit_row_tool(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
230 self.require_write()?;
231 edit_row(&self.session, args).await
232 }
233
234 async fn import_data_tool(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
235 self.require_write()?;
236 import_data(&self.session, args).await
237 }
238
239 async fn apply_ddl_tool(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
240 self.require_admin()?;
241 let sql = args
242 .get("sql")
243 .and_then(|v| v.as_str())
244 .ok_or_else(|| ToolError::InvalidArgs("sql is required".into()))?;
245 let dry_run = args
246 .get("dry_run")
247 .and_then(|v| v.as_bool())
248 .unwrap_or(false);
249 apply_ddl(&self.session, sql, dry_run).await
250 }
251
252 async fn create_index_concurrently_tool(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
253 self.require_admin()?;
254 let sql = args
255 .get("sql")
256 .and_then(|v| v.as_str())
257 .ok_or_else(|| ToolError::InvalidArgs("sql is required".into()))?;
258 create_index_concurrently(&self.session, sql).await
259 }
260
261 async fn run_maintenance_tool(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
262 self.require_admin()?;
263 run_maintenance(&self.session, args).await
264 }
265
266 async fn terminate_query_tool(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
267 self.require_admin()?;
268 terminate_query(&self.session, args).await
269 }
270
271 async fn resolve_target(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
274 let hint = args
275 .get("hint")
276 .and_then(|v| v.as_str())
277 .map(str::trim)
278 .filter(|s| !s.is_empty());
279 let object_hint = args
280 .get("objectHint")
281 .and_then(|v| v.as_str())
282 .map(str::trim)
283 .filter(|s| !s.is_empty());
284 if hint.is_none() && object_hint.is_none() {
285 return Err(ToolError::InvalidArgs(
286 "At least one of \"hint\" or \"objectHint\" is required.".into(),
287 ));
288 }
289
290 let connections = &self.session.connections;
291 if connections.is_empty() {
292 return Ok(ToolOutcome::err("No connections configured."));
293 }
294
295 #[derive(Clone)]
296 struct Candidate {
297 connection_id: String,
298 database: String,
299 }
300 fn key_of(c: &Candidate) -> String {
301 format!("{}\u{0}{}", c.connection_id, c.database)
302 }
303
304 let indexed: Vec<(String, String)> = self
305 .index_store()
306 .map(|store| store.list_indexed_databases().unwrap_or_default())
307 .unwrap_or_default();
308
309 let mut seen = std::collections::HashSet::new();
310 let mut candidates: Vec<Candidate> = Vec::new();
311 let mut add_candidate = |connection_id: &str, database: &str| {
312 if !connections.iter().any(|c| c.id == connection_id) {
313 return;
314 }
315 let key = format!("{connection_id}\u{0}{database}");
316 if !seen.insert(key) {
317 return;
318 }
319 candidates.push(Candidate {
320 connection_id: connection_id.to_string(),
321 database: database.to_string(),
322 });
323 };
324 for (cid, db) in &indexed {
325 add_candidate(cid, db);
326 }
327 for c in connections {
328 let db = c.database.clone().unwrap_or_else(|| "postgres".into());
329 add_candidate(&c.id, &db);
330 }
331
332 let mut scored: std::collections::HashMap<String, (Candidate, f64, Vec<String>)> =
333 std::collections::HashMap::new();
334
335 if let Some(hint) = hint {
336 for c in &candidates {
337 let Some(conn) = connections.iter().find(|x| x.id == c.connection_id) else {
338 continue;
339 };
340 let fields: [(&str, &str); 3] = [
341 ("connection name", conn.name.as_str()),
342 ("host", conn.host.as_deref().unwrap_or("")),
343 ("database", c.database.as_str()),
344 ];
345 let mut best = 0.0f64;
346 let mut best_field = "";
347 for (label, value) in fields {
348 let s = fuzzy_score(hint, value);
349 if s > best {
350 best = s;
351 best_field = label;
352 }
353 }
354 if best > 0.0 {
355 let entry = scored
356 .entry(key_of(c))
357 .or_insert_with(|| (c.clone(), 0.0, Vec::new()));
358 entry.1 += best;
359 entry
360 .2
361 .push(format!("{best_field} matched hint \"{hint}\" ({best:.0})"));
362 }
363 }
364 }
365
366 if let Some(object_hint) = object_hint
367 && let Some(store) = self.index_store()
368 {
369 let filter = self.query_filter();
370 for (cid, db) in &indexed {
371 let svc = IndexQueryService::new(store, cid.clone(), db.clone());
372 if let Ok(hits) = svc.search_schema(
373 object_hint,
374 3,
375 Some(&filter),
376 SearchOptions {
377 use_semantic: self.use_semantic,
378 embedder: self.embedder.as_deref(),
379 },
380 ) && let Some(top) = hits.first()
381 {
382 let c = Candidate {
383 connection_id: cid.clone(),
384 database: db.clone(),
385 };
386 let entry = scored
387 .entry(key_of(&c))
388 .or_insert_with(|| (c.clone(), 0.0, Vec::new()));
389 entry.1 += top.score * 10.0;
390 entry.2.push(format!(
391 "schema search for \"{object_hint}\" found {} (score {:.2})",
392 top.ref_, top.score
393 ));
394 }
395 }
396 }
397
398 let mut ranked: Vec<(Candidate, f64, Vec<String>)> = scored.into_values().collect();
399 ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
400
401 if ranked.is_empty() {
402 let candidates_json: Vec<Value> = connections
403 .iter()
404 .map(|c| {
405 json!({
406 "connectionId": c.id,
407 "connectionName": c.name,
408 "database": c.database.clone().unwrap_or_else(|| "postgres".into()),
409 })
410 })
411 .collect();
412 return Ok(ToolOutcome::ok_json(json!({
413 "ambiguous": true,
414 "message": format!(
415 "No connection/database matched \"{}\". Choose from the configured connections.",
416 hint.or(object_hint).unwrap_or_default()
417 ),
418 "candidates": candidates_json
419 })));
420 }
421
422 let winner = &ranked[0];
423 let is_tied = ranked
424 .get(1)
425 .is_some_and(|runner_up| runner_up.1 >= winner.1 * 0.85);
426
427 if is_tied {
428 let threshold = winner.1 * 0.85;
429 let tied: Vec<&(Candidate, f64, Vec<String>)> =
430 ranked.iter().filter(|r| r.1 >= threshold).take(5).collect();
431 let candidates_json: Vec<Value> = tied
432 .iter()
433 .filter_map(|(c, score, evidence)| {
434 connections.iter().find(|x| x.id == c.connection_id).map(|conn| {
435 json!({
436 "connectionId": c.connection_id,
437 "connectionName": conn.name,
438 "database": c.database,
439 "score": score,
440 "evidence": evidence,
441 })
442 })
443 })
444 .collect();
445 return Ok(ToolOutcome::ok_json(json!({
446 "ambiguous": true,
447 "message": format!("{} equally-plausible candidates matched.", tied.len()),
448 "candidates": candidates_json
449 })));
450 }
451
452 let (winner_candidate, winner_score, winner_evidence) = winner;
453 self.session
454 .switch(&winner_candidate.connection_id, Some(winner_candidate.database.clone()))
455 .await?;
456 let conn = connections
457 .iter()
458 .find(|x| x.id == winner_candidate.connection_id)
459 .ok_or_else(|| ToolError::Execution("resolved connection vanished".into()))?;
460
461 Ok(ToolOutcome::ok_json(json!({
462 "resolved": true,
463 "connectionId": winner_candidate.connection_id,
464 "connectionName": conn.name,
465 "database": winner_candidate.database,
466 "confidence": winner_score,
467 "evidence": winner_evidence,
468 })))
469 }
470
471 async fn discover_tools(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
472 let query = args
473 .get("query")
474 .and_then(|v| v.as_str())
475 .map(str::to_lowercase);
476 let category = args
477 .get("category")
478 .and_then(|v| v.as_str())
479 .map(str::to_lowercase);
480
481 let all_specs = active_tools();
482 let filtered: Vec<Value> = all_specs
483 .into_iter()
484 .filter(|spec| {
485 if spec.name == ToolName::DiscoverTools {
486 return false;
487 }
488 if let Some(ref cat) = category {
489 match cat.as_str() {
490 "query" if !ToolName::QUERY_PROFILE.contains(&spec.name) => return false,
491 "dba" if !ToolName::DBA_PROFILE.contains(&spec.name) => return false,
492 "write" if !ToolName::PHASE9.contains(&spec.name) => return false,
493 _ => {}
494 }
495 }
496 if let Some(ref q) = query {
497 let name_match = spec.name.as_str().contains(q.as_str());
498 let desc_match = spec.description.to_lowercase().contains(q.as_str());
499 if !name_match && !desc_match {
500 return false;
501 }
502 }
503 true
504 })
505 .map(|spec| {
506 json!({
507 "name": spec.name.as_str(),
508 "description": spec.description,
509 "input_schema": spec.input_schema,
510 })
511 })
512 .collect();
513
514 Ok(ToolOutcome::ok_json(json!({
515 "query": args.get("query"),
516 "category": args.get("category"),
517 "count": filtered.len(),
518 "tools": filtered,
519 })))
520 }
521
522 async fn auto_tune_query(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
523 let sql = args
524 .get("sql")
525 .and_then(|v| v.as_str())
526 .ok_or_else(|| ToolError::InvalidArgs("sql is required".into()))?;
527
528 let deep_plan = self
529 .deep_plan_analysis(&json!({ "sql": sql, "analyze": true }))
530 .await?;
531
532 let suggestions_data = match self.suggest_indexes(&json!({})).await {
533 Ok(outcome) => outcome.structured.unwrap_or(json!([])),
534 Err(_) => json!([]),
535 };
536
537 let summary = json!({
538 "target_query": sql,
539 "deep_plan_analysis": deep_plan.structured,
540 "index_suggestions": suggestions_data,
541 "tuning_summary": "Auto-tune evaluation complete. Inspect plan findings and index recommendations.",
542 });
543
544 Ok(ToolOutcome::ok_json(summary))
545 }
546
547 async fn check_ddl_safety_tool(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
548 let ddl = args
549 .get("ddl")
550 .and_then(|v| v.as_str())
551 .ok_or_else(|| ToolError::InvalidArgs("ddl is required".into()))?;
552
553 let report = crate::dba_guard::analyze_ddl_safety(ddl);
554 Ok(ToolOutcome::ok_json(report))
555 }
556
557 async fn index_service(&self) -> Result<(&IndexStore, String, String), ToolError> {
558 let store = self
559 .index_store()
560 .ok_or_else(|| ToolError::Execution(NO_INDEX_HINT.into()))?;
561 let (connection_id, database) = self.session.active_context().await;
562 let base = store.base_dir(&connection_id, &database);
563 if store.read_manifest(&base)?.is_none() {
564 return Err(ToolError::Execution(format!(
565 "No schema index for database \"{database}\" — run `nexql-mcp index build`."
566 )));
567 }
568 Ok((store, connection_id, database))
569 }
570
571 async fn search_schema(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
572 let query = args
573 .get("query")
574 .and_then(|v| v.as_str())
575 .unwrap_or("")
576 .trim();
577 if query.is_empty() {
578 return Ok(ToolOutcome::ok_json(json!([])));
579 }
580 let (store, connection_id, database) = self.index_service().await?;
581 let svc = IndexQueryService::new(store, &connection_id, &database);
582 let filter = self.query_filter();
583 let hits = svc.search_schema(
584 query,
585 SEARCH_SCHEMA_LIMIT,
586 Some(&filter),
587 SearchOptions {
588 use_semantic: self.use_semantic,
589 embedder: self.embedder.as_deref(),
590 },
591 )?;
592 let rows: Vec<Value> = hits
593 .into_iter()
594 .map(|h| {
595 json!({
596 "ref": h.ref_,
597 "score": h.score,
598 "kind": h.kind,
599 })
600 })
601 .collect();
602 Ok(ToolOutcome::ok_json(json!(rows)))
603 }
604
605 async fn describe_object(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
606 let ref_ = args
607 .get("ref")
608 .and_then(|v| v.as_str())
609 .ok_or_else(|| ToolError::InvalidArgs("ref is required".into()))?;
610 let (store, connection_id, database) = self.index_service().await?;
611 let svc = IndexQueryService::new(store, &connection_id, &database);
612 let filter = self.query_filter();
613 let entry = svc.describe_object(ref_, Some(&filter))?;
614 let value = serde_json::to_value(entry).map_err(|e| ToolError::Execution(e.to_string()))?;
615 Ok(ToolOutcome::ok_json(value))
616 }
617
618 async fn get_join_path(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
619 let a = args
620 .get("a")
621 .and_then(|v| v.as_str())
622 .ok_or_else(|| ToolError::InvalidArgs("a is required".into()))?;
623 let b = args
624 .get("b")
625 .and_then(|v| v.as_str())
626 .ok_or_else(|| ToolError::InvalidArgs("b is required".into()))?;
627 let (store, connection_id, database) = self.index_service().await?;
628 let svc = IndexQueryService::new(store, &connection_id, &database);
629 let path = svc.get_join_path(a, b)?;
630 let value = serde_json::to_value(path).map_err(|e| ToolError::Execution(e.to_string()))?;
631 Ok(ToolOutcome::ok_json(value))
632 }
633
634 async fn sample_values(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
635 let ref_ = args
636 .get("ref")
637 .and_then(|v| v.as_str())
638 .ok_or_else(|| ToolError::InvalidArgs("ref is required".into()))?;
639 let col = args
640 .get("col")
641 .and_then(|v| v.as_str())
642 .ok_or_else(|| ToolError::InvalidArgs("col is required".into()))?;
643 let (store, connection_id, database) = self.index_service().await?;
644 let svc = IndexQueryService::new(store, &connection_id, &database);
645 let filter = self.query_filter();
646 let result = svc.sample_values(ref_, col, Some(&filter), None)?;
648 let mut payload = json!({ "values": result.values });
649 if let Some(message) = result.message {
650 payload["message"] = json!(message);
651 }
652 Ok(ToolOutcome::ok_json(payload))
653 }
654
655 fn list_connections(&self) -> ToolOutcome {
656 let rows: Vec<Value> = self
657 .session
658 .connections
659 .iter()
660 .map(|c| {
661 json!({
662 "id": c.id,
663 "name": c.name,
664 "host": c.host,
665 "port": c.port,
666 "database": c.database,
667 })
668 })
669 .collect();
670 ToolOutcome::ok_json(json!(rows))
671 }
672
673 async fn list_databases(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
674 let connection_id = args
675 .get("connectionId")
676 .and_then(|v| v.as_str())
677 .ok_or_else(|| ToolError::InvalidArgs("connectionId is required".into()))?;
678 let conn = self
679 .session
680 .connections
681 .iter()
682 .find(|c| c.id == connection_id)
683 .ok_or_else(|| {
684 ToolError::Execution(format!(
685 "Connection not found for ID: {connection_id} — call list_connections"
686 ))
687 })?;
688 let client = {
690 if self.session.active_context().await.0 == connection_id {
692 self.session.checkout().await?
693 } else {
694 let pool = nexql_conn::create_pool(&conn.params, &self.session.pool_opts).await?;
695 nexql_conn::checkout_guarded(&pool, &self.session.pool_opts).await?
696 }
697 };
698 let rows = client
699 .query(
700 "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname",
701 &[],
702 )
703 .await?;
704 let names: Vec<String> = rows.iter().map(|r| r.get(0)).collect();
705 Ok(ToolOutcome::ok_json(json!(names)))
706 }
707
708 async fn list_schemas(&self) -> Result<ToolOutcome, ToolError> {
709 let client = self.session.checkout().await?;
710 let rows = client
711 .query(
712 r#"
713 SELECT nspname AS schema_name
714 FROM pg_namespace
715 WHERE nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
716 AND nspname NOT LIKE 'pg_%'
717 ORDER BY nspname
718 "#,
719 &[],
720 )
721 .await?;
722 let out: Vec<Value> = rows
723 .iter()
724 .filter(|r| {
725 let name: String = r.get(0);
726 self.session.filter.allows_schema(&name)
727 })
728 .map(|r| json!({ "schema_name": r.get::<_, String>(0) }))
729 .collect();
730 Ok(ToolOutcome::ok_json(json!(out)))
731 }
732
733 async fn list_objects(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
734 let schema = args
735 .get("schema")
736 .and_then(|v| v.as_str())
737 .unwrap_or("public");
738 if !schema
739 .chars()
740 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
741 {
742 return Err(ToolError::InvalidArgs(
743 "Invalid or missing schema name format".into(),
744 ));
745 }
746 if !self.session.filter.allows_schema(schema) {
747 return Ok(ToolOutcome::ok_json(json!([])));
748 }
749 let kind = args.get("kind").and_then(|v| v.as_str());
750 let mut queries = Vec::new();
751 let push_rel = |queries: &mut Vec<String>, relkinds: &[&str], label: &str| {
752 let kinds = relkinds
753 .iter()
754 .map(|k| format!("'{k}'"))
755 .collect::<Vec<_>>()
756 .join(",");
757 queries.push(format!(
758 r#"
759 SELECT n.nspname AS schema, c.relname AS name, '{label}' AS kind,
760 d.description AS comment
761 FROM pg_class c
762 JOIN pg_namespace n ON n.oid = c.relnamespace
763 LEFT JOIN pg_description d ON d.objoid = c.oid AND d.objsubid = 0
764 WHERE n.nspname = $1 AND c.relkind IN ({kinds})
765 "#
766 ));
767 };
768 if kind.is_none() || kind == Some("table") {
769 push_rel(&mut queries, &["r", "f", "p"], "table");
770 }
771 if kind.is_none() || kind == Some("view") {
772 push_rel(&mut queries, &["v"], "view");
773 }
774 if kind.is_none() || kind == Some("matview") {
775 push_rel(&mut queries, &["m"], "matview");
776 }
777 if queries.is_empty() {
778 return Ok(ToolOutcome::ok_json(json!([])));
779 }
780 let sql = queries.join("\nUNION ALL\n") + "\nORDER BY kind, name";
781 let client = self.session.checkout().await?;
782 let rows = client.query(&sql, &[&schema]).await?;
783 let out: Vec<Value> = rows
784 .iter()
785 .filter(|r| {
786 let s: String = r.get("schema");
787 let name: String = r.get("name");
788 self.session.filter.allows_table(&s, &name)
789 })
790 .map(|r| {
791 json!({
792 "schema": r.get::<_, String>("schema"),
793 "name": r.get::<_, String>("name"),
794 "kind": r.get::<_, String>("kind"),
795 "comment": r.get::<_, Option<String>>("comment"),
796 })
797 })
798 .collect();
799 Ok(ToolOutcome::ok_json(json!(out)))
800 }
801
802 async fn get_current_context(&self) -> Result<ToolOutcome, ToolError> {
803 let (connection_id, database) = self.session.active_context().await;
804 let conn = self
805 .session
806 .connections
807 .iter()
808 .find(|c| c.id == connection_id);
809 Ok(ToolOutcome::ok_json(json!({
810 "connectionId": connection_id,
811 "connectionName": conn.map(|c| c.name.as_str()).unwrap_or("Unknown"),
812 "database": database,
813 "host": conn.and_then(|c| c.host.clone()),
814 "port": conn.and_then(|c| c.port),
815 "access_mode": match self.session.access_mode {
816 nexql_policy::AccessMode::Read => "read",
817 nexql_policy::AccessMode::Write => "write",
818 nexql_policy::AccessMode::Admin => "admin",
819 },
820 })))
821 }
822
823 async fn switch_connection(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
824 let connection_id = args
825 .get("connectionId")
826 .and_then(|v| v.as_str())
827 .ok_or_else(|| ToolError::InvalidArgs("connectionId is required".into()))?;
828 let database = args
829 .get("database")
830 .and_then(|v| v.as_str())
831 .map(str::to_owned);
832 self.session.switch(connection_id, database).await?;
833 self.get_current_context().await
834 }
835
836 async fn run_select(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
837 let sql = args
838 .get("sql")
839 .and_then(|v| v.as_str())
840 .ok_or_else(|| ToolError::InvalidArgs("sql is required".into()))?;
841 match validate_readonly_sql(sql)? {
842 SqlDecision::Allow => {}
843 SqlDecision::Reject => {
844 return Err(ToolError::Execution(
845 "Security Error: Only read-only SELECT, WITH, or EXPLAIN statements are permitted."
846 .into(),
847 ));
848 }
849 }
850 let trimmed = sql.trim().to_ascii_lowercase();
851 if trimmed.starts_with("explain") {
852 return self.run_select_internal(sql, None).await;
853 }
854 let max_rows = self.session.caps.max_rows;
855 self.run_select_internal(sql, Some(max_rows)).await
856 }
857
858 async fn explain_query(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
859 let sql = args
860 .get("sql")
861 .and_then(|v| v.as_str())
862 .ok_or_else(|| ToolError::InvalidArgs("sql is required".into()))?;
863 match validate_readonly_sql(sql)? {
864 SqlDecision::Allow => {}
865 SqlDecision::Reject => {
866 return Err(ToolError::Execution(
867 "Security Error: Only SELECT, WITH, or EXPLAIN statements can be analyzed."
868 .into(),
869 ));
870 }
871 }
872 let clean = if sql.trim().to_ascii_lowercase().starts_with("explain") {
873 sql.to_string()
874 } else {
875 format!("EXPLAIN {sql}")
876 };
877 if validate_readonly_sql(&clean)? == SqlDecision::Reject {
879 return Err(ToolError::Execution(
880 "Security Error: EXPLAIN target is not read-only.".into(),
881 ));
882 }
883 self.run_select_internal(&clean, None).await
884 }
885
886 async fn get_ddl(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
887 let ref_ = args
888 .get("ref")
889 .and_then(|v| v.as_str())
890 .ok_or_else(|| ToolError::InvalidArgs("ref is required".into()))?;
891 let (schema, name) = parse_ref(ref_).map_err(ToolError::InvalidArgs)?;
892 let kind = args.get("kind").and_then(|v| v.as_str()).unwrap_or("table");
893 let reg = sql::regclass_literal(&schema, &name);
894 let client = self.session.checkout().await?;
895
896 match kind {
897 "view" | "matview" => {
898 let sql = format!("SELECT pg_get_viewdef({reg}, true) AS definition");
899 let rows = client.query(&sql, &[]).await?;
900 Ok(ToolOutcome::ok_json(rows_to_json(&rows)))
901 }
902 "function" => {
903 let sql = format!(
904 r#"SELECT p.proname AS name, pg_get_functiondef(p.oid) AS definition
905 FROM pg_proc p
906 JOIN pg_namespace n ON n.oid = p.pronamespace
907 WHERE n.nspname = '{schema}' AND p.proname = '{name}'"#
908 );
909 let rows = client.query(&sql, &[]).await?;
910 Ok(ToolOutcome::ok_json(rows_to_json(&rows)))
911 }
912 "index" => {
913 let sql = format!("SELECT pg_get_indexdef({reg}) AS definition");
914 let rows = client.query(&sql, &[]).await?;
915 Ok(ToolOutcome::ok_json(rows_to_json(&rows)))
916 }
917 "table" => {
918 let columns = client
919 .query(&sql::column_details(&schema, &name), &[])
920 .await?;
921 let constraints = client
922 .query(
923 &format!(
924 r#"SELECT conname AS name, pg_get_constraintdef(oid) AS definition
925 FROM pg_constraint WHERE conrelid = {reg} ORDER BY conname"#
926 ),
927 &[],
928 )
929 .await?;
930 let indexes = client
931 .query(
932 &format!(
933 r#"SELECT indexname AS name, indexdef AS definition
934 FROM pg_indexes
935 WHERE schemaname = '{schema}' AND tablename = '{name}'
936 ORDER BY indexname"#
937 ),
938 &[],
939 )
940 .await?;
941 Ok(ToolOutcome::ok_json(json!({
942 "table": format!("{schema}.{name}"),
943 "columns": rows_to_json(&columns),
944 "constraints": rows_to_json(&constraints),
945 "indexes": rows_to_json(&indexes),
946 })))
947 }
948 other => Err(ToolError::InvalidArgs(format!(
949 "Unsupported DDL kind \"{other}\". Use table, view, matview, function, or index."
950 ))),
951 }
952 }
953
954 async fn table_stats(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
955 let ref_ = args
956 .get("ref")
957 .and_then(|v| v.as_str())
958 .ok_or_else(|| ToolError::InvalidArgs("ref is required".into()))?;
959 let (schema, name) = parse_ref(ref_).map_err(ToolError::InvalidArgs)?;
960 let client = self.session.checkout().await?;
961 let stats = client.query(&sql::table_stats(&schema, &name), &[]).await?;
962 let activity = client
963 .query(&sql::table_activity(&schema, &name), &[])
964 .await?;
965 let columns = client
966 .query(&sql::column_stats(&schema, &name), &[])
967 .await?;
968 let size = rows_to_json(&stats)
969 .as_array()
970 .and_then(|a| a.first())
971 .cloned()
972 .unwrap_or(Value::Null);
973 let activity = rows_to_json(&activity)
974 .as_array()
975 .and_then(|a| a.first())
976 .cloned()
977 .unwrap_or(Value::Null);
978 Ok(ToolOutcome::ok_json(json!({
979 "size": size,
980 "activity": activity,
981 "columns": rows_to_json(&columns),
982 })))
983 }
984
985 async fn index_usage(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
986 let ref_ = args
987 .get("ref")
988 .and_then(|v| v.as_str())
989 .ok_or_else(|| ToolError::InvalidArgs("ref is required".into()))?;
990 let (schema, name) = parse_ref(ref_).map_err(ToolError::InvalidArgs)?;
991 let client = self.session.checkout().await?;
992 let rows = client.query(&sql::index_usage(&schema, &name), &[]).await?;
993 Ok(ToolOutcome::ok_json(rows_to_json(&rows)))
994 }
995
996 async fn list_running_queries(&self) -> Result<ToolOutcome, ToolError> {
997 let client = self.session.checkout().await?;
998 let rows = client.query(sql::running_queries(), &[]).await?;
999 Ok(ToolOutcome::ok_json(rows_to_json(&rows)))
1000 }
1001
1002 async fn find_blocking_locks(&self) -> Result<ToolOutcome, ToolError> {
1003 let client = self.session.checkout().await?;
1004 let rows = client.query(sql::blocking_locks(), &[]).await?;
1005 let values = rows_to_json(&rows);
1006 if values.as_array().map(|a| a.is_empty()).unwrap_or(true) {
1007 return Ok(ToolOutcome::ok_json(json!({
1008 "message": "No blocking locks found.",
1009 "locks": [],
1010 })));
1011 }
1012 Ok(ToolOutcome::ok_json(values))
1013 }
1014
1015 async fn slow_queries(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
1016 let limit = args
1017 .get("limit")
1018 .and_then(|v| v.as_u64())
1019 .map(|n| n as u32)
1020 .unwrap_or(SLOW_QUERIES_DEFAULT);
1021 let client = self.session.checkout().await?;
1022 match client.query(&sql::slow_queries(limit), &[]).await {
1023 Ok(rows) => Ok(ToolOutcome::ok_json(rows_to_json(&rows))),
1024 Err(e) => {
1025 if let Some(message) = sql::map_stat_statements_error(&e) {
1026 Ok(ToolOutcome::ok_json(json!({
1027 "error": message,
1028 "hint": message,
1029 })))
1030 } else {
1031 Err(ToolError::Postgres(e))
1032 }
1033 }
1034 }
1035 }
1036
1037 async fn db_health_check(&self) -> Result<ToolOutcome, ToolError> {
1038 let client = self.session.checkout().await?;
1039 let sections: &[(&str, &str)] = &[
1040 ("overview", sql::database_stats()),
1041 ("cache", sql::cache_hit_ratio()),
1042 ("dead_tuples", sql::database_maintenance_stats()),
1043 ("connection_states", sql::connection_states()),
1044 ("blocking_locks", sql::blocking_locks()),
1045 ];
1046 let mut report = serde_json::Map::new();
1047 for (key, q) in sections {
1048 match client.query(*q, &[]).await {
1049 Ok(rows) => {
1050 report.insert((*key).into(), rows_to_json(&rows));
1051 }
1052 Err(e) => {
1053 report.insert((*key).into(), json!({ "error": e.to_string() }));
1054 }
1055 }
1056 }
1057 let lock_count = report
1058 .get("blocking_locks")
1059 .and_then(|v| v.as_array())
1060 .map(|a| a.len() as u64);
1061 report.insert("blocking_lock_count".into(), json!(lock_count));
1062 Ok(ToolOutcome::ok_json(Value::Object(report)))
1063 }
1064
1065 async fn explain_analyze(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
1066 let sql = args
1067 .get("sql")
1068 .and_then(|v| v.as_str())
1069 .ok_or_else(|| ToolError::InvalidArgs("sql is required".into()))?;
1070 require_select_or_with(sql)?;
1071 let explain = build_explain_sql(sql, true);
1072 self.run_explain_in_transaction(&explain).await
1073 }
1074
1075 async fn analyze_query_plan(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
1076 let sql = args
1077 .get("sql")
1078 .and_then(|v| v.as_str())
1079 .ok_or_else(|| ToolError::InvalidArgs("sql is required".into()))?;
1080 require_select_or_with(sql)?;
1081 let analyze = args
1082 .get("analyze")
1083 .and_then(|v| v.as_bool())
1084 .unwrap_or(false);
1085 let explain = build_explain_sql(sql, analyze);
1086 let outcome = self.run_explain_in_transaction(&explain).await?;
1087 let rows = outcome.structured.unwrap_or(Value::Null);
1088 let row_array = rows
1089 .get("rows")
1090 .and_then(|v| v.as_array())
1091 .or_else(|| rows.as_array());
1092 let plan = row_array
1093 .and_then(|a| a.first())
1094 .and_then(|r| r.get("QUERY PLAN"))
1095 .cloned()
1096 .unwrap_or(Value::Null);
1097 let metrics = extract_plan_metrics(&plan).or_else(|| extract_plan_metrics(&rows));
1098 let recommendations = metrics
1099 .as_ref()
1100 .and_then(|m| m.get("recommendations"))
1101 .cloned()
1102 .unwrap_or_else(|| json!([]));
1103 Ok(ToolOutcome::ok_json(json!({
1104 "metrics": metrics,
1105 "recommendations": recommendations,
1106 "plan": plan,
1107 })))
1108 }
1109
1110 async fn run_explain_in_transaction(
1112 &self,
1113 explain_sql: &str,
1114 ) -> Result<ToolOutcome, ToolError> {
1115 let client = self.session.checkout().await?;
1116 client
1117 .batch_execute("SET statement_timeout = '30s'")
1118 .await?;
1119 client.batch_execute("BEGIN").await?;
1120 let result = async {
1121 client.batch_execute("SET TRANSACTION READ ONLY").await?;
1122 let rows = client.query(explain_sql, &[]).await?;
1123 Ok::<_, ToolError>(rows_to_json(&rows))
1124 }
1125 .await;
1126 let _ = client.batch_execute("ROLLBACK").await;
1128 match result {
1129 Ok(values) => Ok(ToolOutcome::ok_json(values)),
1130 Err(e) => Err(e),
1131 }
1132 }
1133
1134 async fn get_index_status(&self) -> Result<ToolOutcome, ToolError> {
1135 let (store, connection_id, database) = self.index_service().await?;
1136 let base = store.base_dir(&connection_id, &database);
1137 let Some(manifest) = store.read_manifest(&base)? else {
1138 return Err(ToolError::Execution(format!(
1139 "No schema index for database \"{database}\" — run `nexql-mcp index build`."
1140 )));
1141 };
1142
1143 let mut live_fingerprint: Option<String> = None;
1144 let mut drift: Option<bool> = None;
1145 if let Ok(client) = self.session.checkout().await {
1146 let db = PgCatalogDb::new(&client);
1147 if let Ok(fp) = db.schema_fingerprint().await {
1148 drift = Some(fp != manifest.schema_fingerprint);
1149 live_fingerprint = Some(fp);
1150 }
1151 }
1152
1153 Ok(ToolOutcome::ok_json(json!({
1154 "connectionId": manifest.connection_id,
1155 "database": manifest.database,
1156 "indexedAt": manifest.indexed_at,
1157 "fingerprint": manifest.schema_fingerprint,
1158 "liveFingerprint": live_fingerprint,
1159 "drift": drift,
1160 "pgVersion": manifest.pg_version,
1161 "counts": {
1162 "tables": manifest.counts.tables,
1163 "views": manifest.counts.views,
1164 "functions": manifest.counts.functions,
1165 "enums": manifest.counts.enums,
1166 },
1167 "buildMs": manifest.stats.build_ms,
1168 "warnings": manifest.stats.warnings,
1169 })))
1170 }
1171
1172 async fn list_extensions(&self) -> Result<ToolOutcome, ToolError> {
1173 let client = self.session.checkout().await?;
1174 let rows = client.query(sql::list_extensions(), &[]).await?;
1175 Ok(ToolOutcome::ok_json(rows_to_json(&rows)))
1176 }
1177
1178 async fn server_settings(&self) -> Result<ToolOutcome, ToolError> {
1179 let client = self.session.checkout().await?;
1180 let rows = client.query(sql::server_settings(), &[]).await?;
1181 Ok(ToolOutcome::ok_json(rows_to_json(&rows)))
1182 }
1183
1184 async fn suggest_indexes(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
1185 let limit = args
1186 .get("limit")
1187 .and_then(|v| v.as_u64())
1188 .map(|n| n as u32)
1189 .unwrap_or(REPORT_LIMIT_DEFAULT);
1190 let client = self.session.checkout().await?;
1191
1192 let high_seq = client.query(&sql::high_seq_scan_tables(limit), &[]).await?;
1193 let unindexed_fks = client.query(&sql::unindexed_fk_columns(limit), &[]).await?;
1194
1195 let mut pg_stat_available = false;
1196 let mut slow_queries = Value::Null;
1197 let mut pg_stat_note: Option<String> = None;
1198 match client.query(&sql::slow_queries(limit.min(10)), &[]).await {
1199 Ok(rows) => {
1200 pg_stat_available = true;
1201 slow_queries = rows_to_json(&rows);
1202 }
1203 Err(e) => {
1204 if let Some(message) = sql::map_stat_statements_error(&e) {
1205 pg_stat_note = Some(message);
1206 } else {
1207 return Err(ToolError::Postgres(e));
1208 }
1209 }
1210 }
1211
1212 let mut plan_heuristics = Value::Null;
1213 if let Some(sql_text) = args.get("sql").and_then(|v| v.as_str()) {
1214 require_select_or_with(sql_text)?;
1215 let explain = build_explain_sql(sql_text, false);
1216 let outcome = self.run_explain_in_transaction(&explain).await?;
1217 let rows = outcome.structured.unwrap_or(Value::Null);
1218 let plan = rows
1219 .as_array()
1220 .and_then(|a| a.first())
1221 .and_then(|r| r.get("QUERY PLAN"))
1222 .cloned()
1223 .unwrap_or(Value::Null);
1224 let metrics = extract_plan_metrics(&plan).or_else(|| extract_plan_metrics(&rows));
1225 plan_heuristics = json!({
1226 "metrics": metrics,
1227 "hint": "Use analyze_query_plan with analyze=true for actual timings before creating indexes.",
1228 });
1229 }
1230
1231 let high_seq_json = rows_to_json(&high_seq);
1232 let unindexed_json = rows_to_json(&unindexed_fks);
1233 let has_candidates = high_seq_json
1234 .as_array()
1235 .map(|a| !a.is_empty())
1236 .unwrap_or(false)
1237 || unindexed_json
1238 .as_array()
1239 .map(|a| !a.is_empty())
1240 .unwrap_or(false)
1241 || plan_heuristics != Value::Null;
1242
1243 if !has_candidates && !pg_stat_available {
1244 return Ok(ToolOutcome::ok_json(json!({
1245 "suggestions": [],
1246 "message": "No index suggestions yet. Either table stats show healthy index use, or there is not enough scan history. Enable pg_stat_statements and/or pass a sql argument for EXPLAIN plan heuristics.",
1247 "hint": pg_stat_note,
1248 })));
1249 }
1250
1251 if !has_candidates {
1252 return Ok(ToolOutcome::ok_json(json!({
1253 "high_seq_scan_tables": high_seq_json,
1254 "unindexed_fk_columns": unindexed_json,
1255 "slow_queries": slow_queries,
1256 "plan_heuristics": plan_heuristics,
1257 "message": "No strong index candidates from sequential-scan or unindexed-FK heuristics. Review slow_queries / pass sql for plan-level advice.",
1258 "hint": "CREATE INDEX CONCURRENTLY after validating with EXPLAIN (ANALYZE, BUFFERS).",
1259 })));
1260 }
1261
1262 Ok(ToolOutcome::ok_json(json!({
1263 "high_seq_scan_tables": high_seq_json,
1264 "unindexed_fk_columns": unindexed_json,
1265 "slow_queries": slow_queries,
1266 "plan_heuristics": plan_heuristics,
1267 "pg_stat_statements": pg_stat_available,
1268 "hint": pg_stat_note.unwrap_or_else(|| {
1269 "Validate candidates with analyze_query_plan / EXPLAIN before CREATE INDEX CONCURRENTLY.".into()
1270 }),
1271 })))
1272 }
1273
1274 async fn find_unused_indexes(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
1275 let limit = args
1276 .get("limit")
1277 .and_then(|v| v.as_u64())
1278 .map(|n| n as u32)
1279 .unwrap_or(REPORT_LIMIT_DEFAULT);
1280 let client = self.session.checkout().await?;
1281 let rows = client.query(&sql::find_unused_indexes(limit), &[]).await?;
1282 let indexes = rows_to_json(&rows);
1283 if indexes.as_array().map(|a| a.is_empty()).unwrap_or(true) {
1284 return Ok(ToolOutcome::ok_json(json!({
1285 "indexes": [],
1286 "message": "No unused non-constraint indexes found (idx_scan = 0). Note: pg_stat_reset / server restart clears scan counts — treat never-scanned indexes cautiously on fresh stats.",
1287 })));
1288 }
1289 Ok(ToolOutcome::ok_json(json!({
1290 "indexes": indexes,
1291 "hint": "Prefer DROP INDEX CONCURRENTLY after confirming the workload (and that stats are mature).",
1292 })))
1293 }
1294
1295 async fn bloat_report(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
1296 let limit = args
1297 .get("limit")
1298 .and_then(|v| v.as_u64())
1299 .map(|n| n as u32)
1300 .unwrap_or(REPORT_LIMIT_DEFAULT);
1301 let client = self.session.checkout().await?;
1302 let rows = client.query(&sql::bloat_report(limit), &[]).await?;
1303 let tables = rows_to_json(&rows);
1304 if tables.as_array().map(|a| a.is_empty()).unwrap_or(true) {
1305 return Ok(ToolOutcome::ok_json(json!({
1306 "tables": [],
1307 "method": "dead_tuple_ratio",
1308 "message": "No tables with significant dead-tuple pressure (>1000 dead tuples). This is a simplified estimate from pg_stat_user_tables, not physical page bloat.",
1309 })));
1310 }
1311 Ok(ToolOutcome::ok_json(json!({
1312 "tables": tables,
1313 "method": "dead_tuple_ratio",
1314 "note": "Approximate bloat via n_dead_tup / (n_live_tup + n_dead_tup). Not a physical page-bloat estimate (pgstattuple / check_postgres). Consider VACUUM / VACUUM FULL only after confirming impact.",
1315 "hint": "VACUUM ANALYZE on high bloat_pct tables; investigate autovacuum settings if last_autovacuum is stale.",
1316 })))
1317 }
1318
1319 async fn find_missing_fks(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
1320 let limit = args
1321 .get("limit")
1322 .and_then(|v| v.as_u64())
1323 .map(|n| n as u32)
1324 .unwrap_or(REPORT_LIMIT_DEFAULT);
1325 let capped = limit.clamp(1, sql::REPORT_LIMIT_MAX) as usize;
1326
1327 if let Ok((store, connection_id, database)) = self.index_service().await {
1329 let base = store.base_dir(&connection_id, &database);
1330 if let Ok(Some(manifest)) = store.read_manifest(&base) {
1331 if let Ok(Some(graph)) = store.read_join_graph(&base, &manifest) {
1332 let candidates: Vec<Value> = graph
1333 .edges
1334 .into_iter()
1335 .filter(|e| e.inferred == Some(true) && e.disabled != Some(true))
1336 .take(capped)
1337 .map(|e| {
1338 let cols: Vec<Value> = e
1339 .cols
1340 .iter()
1341 .map(|(a, b)| json!({ "from": a, "to": b }))
1342 .collect();
1343 json!({
1344 "from_table": e.from,
1345 "to_table": e.to,
1346 "via": e.via,
1347 "columns": cols,
1348 "detection": "join_graph_inferred",
1349 })
1350 })
1351 .collect();
1352 if !candidates.is_empty() {
1353 return Ok(ToolOutcome::ok_json(json!({
1354 "candidates": candidates,
1355 "source": "join_graph",
1356 "hint": "These edges were inferred by naming convention and have no declared FK. Review before ALTER TABLE … ADD FOREIGN KEY.",
1357 })));
1358 }
1359 }
1360 }
1361 }
1362
1363 let client = self.session.checkout().await?;
1364 let rows = client
1365 .query(&sql::find_missing_fks_catalog(limit), &[])
1366 .await?;
1367 let candidates = rows_to_json(&rows);
1368 if candidates.as_array().map(|a| a.is_empty()).unwrap_or(true) {
1369 return Ok(ToolOutcome::ok_json(json!({
1370 "candidates": [],
1371 "source": "catalog",
1372 "message": "No missing FK candidates found via join-graph inferred edges or *_id naming against single-column PKs.",
1373 })));
1374 }
1375 Ok(ToolOutcome::ok_json(json!({
1376 "candidates": candidates,
1377 "source": "catalog",
1378 "hint": "Naming-inferred only — verify referential integrity and nullability before adding constraints. Run `nexql-mcp index build` for join-graph inferred edges.",
1379 })))
1380 }
1381
1382 async fn list_roles(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
1383 let client = self.session.checkout().await?;
1384 let role = args
1385 .get("role")
1386 .and_then(|v| v.as_str())
1387 .map(str::trim)
1388 .filter(|s| !s.is_empty());
1389
1390 let Some(role_name) = role else {
1391 let rows = client.query(sql::list_roles(), &[]).await?;
1392 return Ok(ToolOutcome::ok_json(rows_to_json(&rows)));
1393 };
1394
1395 let details = client.query(sql::role_details(), &[&role_name]).await?;
1396 if details.is_empty() {
1397 return Err(ToolError::Execution(format!(
1398 "Role \"{role_name}\" not found"
1399 )));
1400 }
1401 let member_of = client.query(sql::role_member_of(), &[&role_name]).await?;
1402 let has_members = client.query(sql::role_has_members(), &[&role_name]).await?;
1403 let privileges = client
1404 .query(sql::role_table_privileges(), &[&role_name])
1405 .await?;
1406
1407 Ok(ToolOutcome::ok_json(json!({
1408 "role": rows_to_json(&details).as_array().and_then(|a| a.first().cloned()).unwrap_or(Value::Null),
1409 "member_of": rows_to_json(&member_of),
1410 "has_members": rows_to_json(&has_members),
1411 "table_privileges": rows_to_json(&privileges),
1412 })))
1413 }
1414
1415 async fn export_query(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
1416 let sql = args
1417 .get("sql")
1418 .and_then(|v| v.as_str())
1419 .ok_or_else(|| ToolError::InvalidArgs("sql is required".into()))?;
1420 require_select_or_with(sql)?;
1421
1422 let format = args
1423 .get("format")
1424 .and_then(|v| v.as_str())
1425 .map(|s| {
1426 ExportFormat::parse(s).ok_or_else(|| {
1427 ToolError::InvalidArgs(format!(
1428 "Unsupported format \"{s}\". Use csv, json, or sqlinsert."
1429 ))
1430 })
1431 })
1432 .transpose()?
1433 .unwrap_or(ExportFormat::Csv);
1434
1435 let table_target = match args.get("table").and_then(|v| v.as_str()) {
1436 Some(t) if !t.trim().is_empty() => Some(parse_ref(t).map_err(ToolError::InvalidArgs)?),
1437 _ => None,
1438 };
1439
1440 if format == ExportFormat::SqlInsert && table_target.is_none() {
1441 return Err(ToolError::InvalidArgs(
1442 "table (schema.name) is required when format=sqlinsert".into(),
1443 ));
1444 }
1445
1446 let max_rows = self.session.caps.max_rows;
1447 let outcome = self.run_select_internal(sql, Some(max_rows)).await?;
1448 if outcome.is_error {
1449 return Ok(outcome);
1450 }
1451
1452 let structured = outcome.structured.unwrap_or(Value::Null);
1453 let rows_val = structured
1454 .get("rows")
1455 .cloned()
1456 .or_else(|| structured.get("data").and_then(|d| d.get("rows").cloned()))
1457 .unwrap_or(Value::Array(vec![]));
1458 let rows = rows_val.as_array().cloned().unwrap_or_default();
1459 let columns = columns_from_rows(&rows);
1460 let truncated = structured
1461 .get("truncated")
1462 .and_then(|v| v.as_bool())
1463 .unwrap_or(false);
1464
1465 let payload = match format {
1466 ExportFormat::Json => json!({
1467 "format": format.as_str(),
1468 "rowCount": rows.len(),
1469 "truncated": truncated,
1470 "columns": columns,
1471 "rows": rows,
1472 }),
1473 ExportFormat::Csv => {
1474 let content = rows_to_csv(&rows, &columns);
1475 let (char_trunc, content) = self.session.caps.truncate_chars(&content);
1476 json!({
1477 "format": format.as_str(),
1478 "rowCount": rows.len(),
1479 "truncated": truncated || char_trunc,
1480 "columns": columns,
1481 "content": content,
1482 })
1483 }
1484 ExportFormat::SqlInsert => {
1485 let (schema, table) = table_target.expect("checked above");
1486 let content = rows_to_sql_insert(&rows, &columns, &schema, &table);
1487 let (char_trunc, content) = self.session.caps.truncate_chars(&content);
1488 json!({
1489 "format": format.as_str(),
1490 "rowCount": rows.len(),
1491 "truncated": truncated || char_trunc,
1492 "table": format!("{schema}.{table}"),
1493 "columns": columns,
1494 "content": content,
1495 })
1496 }
1497 };
1498
1499 Ok(ToolOutcome::ok_json(payload))
1500 }
1501
1502 async fn db_dashboard(&self) -> Result<ToolOutcome, ToolError> {
1503 let client = self.session.checkout().await?;
1504 let sections: &[(&str, &str)] = &[
1505 ("db_info", sql::dashboard_db_info()),
1506 ("connection_states", sql::connection_states()),
1507 ("top_tables", sql::dashboard_top_tables()),
1508 ("object_counts", sql::dashboard_object_counts()),
1509 ("active_queries", sql::dashboard_active_queries()),
1510 ("blocking_locks", sql::blocking_locks()),
1511 ("max_connections", sql::dashboard_max_connections()),
1512 ("extension_count", sql::dashboard_extension_count()),
1513 ("cache", sql::cache_hit_ratio()),
1514 ];
1515 let mut report = serde_json::Map::new();
1516 for (key, q) in sections {
1517 match client.query(*q, &[]).await {
1518 Ok(rows) => {
1519 report.insert((*key).into(), rows_to_json(&rows));
1520 }
1521 Err(e) => {
1522 report.insert((*key).into(), json!({ "error": e.to_string() }));
1523 }
1524 }
1525 }
1526
1527 for key in ["db_info", "object_counts", "extension_count", "cache"] {
1529 if let Some(Value::Array(arr)) = report.get(key).cloned() {
1530 if arr.len() == 1 {
1531 report.insert(key.into(), arr.into_iter().next().unwrap());
1532 }
1533 }
1534 }
1535 if let Some(Value::Array(arr)) = report.get("max_connections").cloned() {
1536 if let Some(row) = arr.first() {
1537 report.insert(
1538 "max_connections".into(),
1539 row.get("max_connections")
1540 .cloned()
1541 .unwrap_or_else(|| row.clone()),
1542 );
1543 }
1544 }
1545
1546 Ok(ToolOutcome::ok_json(Value::Object(report)))
1547 }
1548
1549 async fn deep_plan_analysis(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
1550 let sql = args
1551 .get("sql")
1552 .and_then(|v| v.as_str())
1553 .ok_or_else(|| ToolError::InvalidArgs("sql is required".into()))?;
1554 require_select_or_with(sql)?;
1555 let analyze = args
1556 .get("analyze")
1557 .and_then(|v| v.as_bool())
1558 .unwrap_or(true);
1559 let explain = build_explain_sql(sql, analyze);
1560 let outcome = self.run_explain_in_transaction(&explain).await?;
1561 let rows = outcome.structured.unwrap_or(Value::Null);
1562 let row_array = rows
1563 .get("rows")
1564 .and_then(|v| v.as_array())
1565 .or_else(|| rows.as_array());
1566 let plan = row_array
1567 .and_then(|a| a.first())
1568 .and_then(|r| r.get("QUERY PLAN"))
1569 .cloned()
1570 .unwrap_or(Value::Null);
1571 let deep = analyze_deep_plan(&plan, sql)
1572 .or_else(|| analyze_deep_plan(&rows, sql))
1573 .ok_or_else(|| {
1574 ToolError::Execution("Could not parse EXPLAIN JSON plan for deep analysis".into())
1575 })?;
1576 let metrics = extract_plan_metrics(&plan).or_else(|| extract_plan_metrics(&rows));
1577 Ok(ToolOutcome::ok_json(json!({
1578 "deep": deep,
1579 "metrics": metrics,
1580 "plan": plan,
1581 "analyzed": analyze,
1582 })))
1583 }
1584
1585 async fn schema_diff(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
1586 let source_schema = args
1587 .get("sourceSchema")
1588 .and_then(|v| v.as_str())
1589 .ok_or_else(|| ToolError::InvalidArgs("sourceSchema is required".into()))?;
1590 let target_schema = args
1591 .get("targetSchema")
1592 .and_then(|v| v.as_str())
1593 .ok_or_else(|| ToolError::InvalidArgs("targetSchema is required".into()))?;
1594 crate::schema_diff::require_safe_schema(source_schema)?;
1595 crate::schema_diff::require_safe_schema(target_schema)?;
1596
1597 let client = self.session.checkout().await?;
1598 let source = crate::schema_diff::load_schema_snapshot(&client, source_schema).await?;
1599 let target = crate::schema_diff::load_schema_snapshot(&client, target_schema).await?;
1600 let diffs = crate::schema_diff::compute_schema_diff(&source, &target);
1601 let changed = diffs
1602 .iter()
1603 .filter(|d| d.status != crate::schema_diff::DiffStatus::Unchanged)
1604 .count();
1605 Ok(ToolOutcome::ok_json(json!({
1606 "sourceSchema": source_schema,
1607 "targetSchema": target_schema,
1608 "tableCount": diffs.len(),
1609 "changedCount": changed,
1610 "diffs": crate::schema_diff::diffs_to_json(&diffs),
1611 })))
1612 }
1613
1614 async fn generate_migration(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
1615 let source_schema = args
1616 .get("sourceSchema")
1617 .and_then(|v| v.as_str())
1618 .ok_or_else(|| ToolError::InvalidArgs("sourceSchema is required".into()))?;
1619 let target_schema = args
1620 .get("targetSchema")
1621 .and_then(|v| v.as_str())
1622 .ok_or_else(|| ToolError::InvalidArgs("targetSchema is required".into()))?;
1623 crate::schema_diff::require_safe_schema(source_schema)?;
1624 crate::schema_diff::require_safe_schema(target_schema)?;
1625
1626 let client = self.session.checkout().await?;
1627 let source = crate::schema_diff::load_schema_snapshot(&client, source_schema).await?;
1628 let target = crate::schema_diff::load_schema_snapshot(&client, target_schema).await?;
1629 let diffs = crate::schema_diff::compute_schema_diff(&source, &target);
1630 let statements =
1631 crate::schema_diff::build_migration_statements(source_schema, target_schema, &diffs);
1632 let sql = if statements.is_empty() {
1633 format!("-- No differences between {source_schema} and {target_schema}")
1634 } else {
1635 statements.join("\n\n")
1636 };
1637 Ok(ToolOutcome::ok_json(json!({
1638 "sourceSchema": source_schema,
1639 "targetSchema": target_schema,
1640 "statementCount": statements.len(),
1641 "sql": sql,
1642 "hint": "Read-only: review and run via execute_sql / apply_ddl only with --access-mode write|admin. Destructive drops are commented out.",
1643 })))
1644 }
1645
1646 async fn run_select_internal(
1647 &self,
1648 sql: &str,
1649 max_rows: Option<u32>,
1650 ) -> Result<ToolOutcome, ToolError> {
1651 let client = self.session.checkout().await?;
1652 let Some(max_rows) = max_rows else {
1653 let rows = client.query(sql, &[]).await?;
1654 let values = rows_to_json(&rows);
1655 let payload = ensure_structured_object(values);
1657 let text = serde_json::to_string_pretty(&payload)
1658 .map_err(|e| ToolError::Execution(e.to_string()))?;
1659 let (trunc, text) = self.session.caps.truncate_chars(&text);
1660 let structured = if trunc {
1661 json!({ "truncated_chars": true, "data": payload })
1662 } else {
1663 payload
1664 };
1665 return Ok(ToolOutcome {
1666 text: text.to_string(),
1667 structured: Some(structured),
1668 is_error: false,
1669 });
1670 };
1671
1672 let cleaned = sql.trim().trim_end_matches(';').trim();
1673 let wrapped = format!(
1674 "SELECT * FROM ({cleaned}) AS nexql_limited LIMIT {}",
1675 max_rows + 1
1676 );
1677 let rows = match client.query(&wrapped, &[]).await {
1678 Ok(r) => r,
1679 Err(_) => client.query(sql, &[]).await?,
1680 };
1681 let truncated = rows.len() as u32 > max_rows;
1682 let keep = if truncated {
1683 &rows[..max_rows as usize]
1684 } else {
1685 &rows[..]
1686 };
1687 let values = rows_to_json(keep);
1688 let mut payload = ensure_structured_object(values);
1690 if truncated {
1691 if let Some(obj) = payload.as_object_mut() {
1692 obj.insert("truncated".into(), json!(true));
1693 obj.insert("maxRows".into(), json!(max_rows));
1694 }
1695 }
1696 let text = serde_json::to_string_pretty(&payload)
1697 .map_err(|e| ToolError::Execution(e.to_string()))?;
1698 let (char_trunc, text) = self.session.caps.truncate_chars(&text);
1699 let structured = if char_trunc {
1700 json!({ "truncated_chars": true, "data": payload })
1701 } else {
1702 payload
1703 };
1704 Ok(ToolOutcome {
1705 text: text.to_string(),
1706 structured: Some(structured),
1707 is_error: false,
1708 })
1709 }
1710}
1711
1712fn normalize_for_match(s: &str) -> String {
1714 let mut out = String::new();
1715 let mut last_was_sep = true;
1716 for ch in s.to_lowercase().chars() {
1717 if ch.is_ascii_alphanumeric() {
1718 out.push(ch);
1719 last_was_sep = false;
1720 } else if !last_was_sep {
1721 out.push(' ');
1722 last_was_sep = true;
1723 }
1724 }
1725 out.trim().to_string()
1726}
1727
1728fn fuzzy_score(hint: &str, candidate: &str) -> f64 {
1730 let h = normalize_for_match(hint);
1731 let c = normalize_for_match(candidate);
1732 if h.is_empty() || c.is_empty() {
1733 return 0.0;
1734 }
1735 if h == c {
1736 return 100.0;
1737 }
1738 if c.contains(&h) || h.contains(&c) {
1739 return 75.0;
1740 }
1741 let h_tokens: std::collections::HashSet<&str> = h.split(' ').filter(|s| !s.is_empty()).collect();
1742 let c_tokens: std::collections::HashSet<&str> = c.split(' ').filter(|s| !s.is_empty()).collect();
1743 let overlap = h_tokens.intersection(&c_tokens).count();
1744 if overlap == 0 {
1745 return 0.0;
1746 }
1747 (overlap as f64 / h_tokens.len().max(c_tokens.len()) as f64) * 60.0
1748}
1749
1750fn policy_to_query_filter(filter: &PolicyFilter) -> QueryPolicyFilter {
1751 QueryPolicyFilter {
1752 allow_schemas: filter.allow_schemas.clone(),
1753 deny_schemas: filter.deny_schemas.clone(),
1754 deny_tables: filter.deny_tables.clone(),
1755 pii_columns: filter.pii_columns.clone(),
1756 }
1757}
1758
1759fn require_select_or_with(sql: &str) -> Result<(), ToolError> {
1760 match validate_readonly_sql(sql)? {
1761 SqlDecision::Allow => {}
1762 SqlDecision::Reject => {
1763 return Err(ToolError::Execution(
1764 "Security Error: Only SELECT or WITH statements can be analyzed.".into(),
1765 ));
1766 }
1767 }
1768 let trimmed = sql.trim().to_ascii_lowercase();
1769 if !(trimmed.starts_with("select") || trimmed.starts_with("with")) {
1770 return Err(ToolError::Execution(
1771 "Security Error: Only SELECT or WITH statements can be analyzed.".into(),
1772 ));
1773 }
1774 Ok(())
1775}
1776
1777fn rows_to_json(rows: &[tokio_postgres::Row]) -> Value {
1778 let arr: Vec<Value> = rows
1779 .iter()
1780 .map(|row| {
1781 let mut map = serde_json::Map::new();
1782 for (i, col) in row.columns().iter().enumerate() {
1783 map.insert(col.name().to_string(), cell_to_json(row, i));
1784 }
1785 Value::Object(map)
1786 })
1787 .collect();
1788 Value::Array(arr)
1789}
1790
1791enum SqlNullness {
1793 Null,
1794 Value,
1795}
1796
1797impl<'a> FromSql<'a> for SqlNullness {
1798 fn from_sql(_: &Type, _: &'a [u8]) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
1799 Ok(SqlNullness::Value)
1800 }
1801
1802 fn from_sql_null(_: &Type) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
1803 Ok(SqlNullness::Null)
1804 }
1805
1806 fn accepts(_: &Type) -> bool {
1807 true
1808 }
1809}
1810
1811fn try_cell<T, F>(row: &tokio_postgres::Row, idx: usize, map: F) -> Option<Value>
1812where
1813 T: for<'a> FromSql<'a>,
1814 F: FnOnce(T) -> Value,
1815{
1816 match row.try_get::<_, Option<T>>(idx) {
1817 Ok(Some(v)) => Some(map(v)),
1818 Ok(None) => Some(Value::Null),
1819 Err(_) => None,
1820 }
1821}
1822
1823fn cell_to_json(row: &tokio_postgres::Row, idx: usize) -> Value {
1824 let col_type = row.columns()[idx].type_();
1825 if matches!(row.try_get::<_, SqlNullness>(idx), Ok(SqlNullness::Null)) {
1826 return Value::Null;
1827 }
1828
1829 if let Kind::Array(elem) = col_type.kind() {
1830 return array_cell_to_json(row, idx, elem);
1831 }
1832
1833 if let Some(v) = match *col_type {
1834 Type::BOOL => try_cell::<bool, _>(row, idx, |b| json!(b)),
1835 Type::INT2 => try_cell::<i16, _>(row, idx, |n| json!(n)),
1836 Type::INT4 | Type::OID => try_cell::<i32, _>(row, idx, |n| json!(n)),
1837 Type::INT8 => try_cell::<i64, _>(row, idx, |n| json!(n)),
1838 Type::FLOAT4 => try_cell::<f32, _>(row, idx, |n| json!(n)),
1839 Type::FLOAT8 => try_cell::<f64, _>(row, idx, |n| json!(n)),
1840 Type::TEXT | Type::VARCHAR | Type::BPCHAR | Type::NAME => {
1841 try_cell::<String, _>(row, idx, Value::String)
1842 }
1843 Type::TIMESTAMP => try_cell::<NaiveDateTime, _>(row, idx, |t| {
1844 json!(t.format("%Y-%m-%dT%H:%M:%S%.f").to_string())
1845 }),
1846 Type::TIMESTAMPTZ => {
1847 try_cell::<DateTime<FixedOffset>, _>(row, idx, |t| json!(t.to_rfc3339()))
1848 }
1849 Type::DATE => {
1850 try_cell::<NaiveDate, _>(row, idx, |d| json!(d.format("%Y-%m-%d").to_string()))
1851 }
1852 Type::TIME => {
1853 try_cell::<NaiveTime, _>(row, idx, |t| json!(t.format("%H:%M:%S%.f").to_string()))
1854 }
1855 Type::UUID => try_cell::<Uuid, _>(row, idx, |u| json!(u.to_string())),
1856 Type::JSON | Type::JSONB => try_cell::<Value, _>(row, idx, |j| j),
1857 Type::NUMERIC => try_cell::<Decimal, _>(row, idx, |d| json!(d.to_string())),
1858 Type::MONEY => try_cell::<i64, _>(row, idx, |v| json!(money_to_string(v))),
1859 Type::BYTEA => try_cell::<Vec<u8>, _>(row, idx, |b| json!(BASE64.encode(b))),
1860 _ => None,
1861 } {
1862 return v;
1863 }
1864
1865 cell_to_json_untyped(row, idx, col_type)
1866}
1867
1868fn array_cell_to_json(row: &tokio_postgres::Row, idx: usize, elem: &Type) -> Value {
1869 let try_array = |result: Result<Option<Vec<Value>>, tokio_postgres::Error>| -> Option<Value> {
1870 match result {
1871 Ok(Some(items)) => Some(Value::Array(items)),
1872 Ok(None) => Some(Value::Null),
1873 Err(_) => None,
1874 }
1875 };
1876
1877 match *elem {
1878 Type::BOOL => {
1879 if let Some(v) = try_array(
1880 row.try_get::<_, Option<Vec<bool>>>(idx)
1881 .map(|v| v.map(|a| a.into_iter().map(|x| json!(x)).collect())),
1882 ) {
1883 return v;
1884 }
1885 }
1886 Type::INT2 => {
1887 if let Some(v) = try_array(
1888 row.try_get::<_, Option<Vec<i16>>>(idx)
1889 .map(|v| v.map(|a| a.into_iter().map(|x| json!(x)).collect())),
1890 ) {
1891 return v;
1892 }
1893 }
1894 Type::INT4 | Type::OID => {
1895 if let Some(v) = try_array(
1896 row.try_get::<_, Option<Vec<i32>>>(idx)
1897 .map(|v| v.map(|a| a.into_iter().map(|x| json!(x)).collect())),
1898 ) {
1899 return v;
1900 }
1901 }
1902 Type::INT8 => {
1903 if let Some(v) = try_array(
1904 row.try_get::<_, Option<Vec<i64>>>(idx)
1905 .map(|v| v.map(|a| a.into_iter().map(|x| json!(x)).collect())),
1906 ) {
1907 return v;
1908 }
1909 }
1910 Type::FLOAT4 => {
1911 if let Some(v) = try_array(
1912 row.try_get::<_, Option<Vec<f32>>>(idx)
1913 .map(|v| v.map(|a| a.into_iter().map(|n| json!(n)).collect())),
1914 ) {
1915 return v;
1916 }
1917 }
1918 Type::FLOAT8 => {
1919 if let Some(v) = try_array(
1920 row.try_get::<_, Option<Vec<f64>>>(idx)
1921 .map(|v| v.map(|a| a.into_iter().map(|n| json!(n)).collect())),
1922 ) {
1923 return v;
1924 }
1925 }
1926 Type::TEXT | Type::VARCHAR | Type::BPCHAR | Type::NAME => {
1927 if let Some(v) = try_array(
1928 row.try_get::<_, Option<Vec<String>>>(idx)
1929 .map(|v| v.map(|a| a.into_iter().map(Value::String).collect())),
1930 ) {
1931 return v;
1932 }
1933 }
1934 Type::UUID => {
1935 if let Some(v) = try_array(
1936 row.try_get::<_, Option<Vec<Uuid>>>(idx)
1937 .map(|v| v.map(|a| a.into_iter().map(|u| json!(u.to_string())).collect())),
1938 ) {
1939 return v;
1940 }
1941 }
1942 Type::TIMESTAMP => {
1943 if let Some(v) = try_array(row.try_get::<_, Option<Vec<NaiveDateTime>>>(idx).map(|v| {
1944 v.map(|a| {
1945 a.into_iter()
1946 .map(|t| json!(t.format("%Y-%m-%dT%H:%M:%S%.f").to_string()))
1947 .collect()
1948 })
1949 })) {
1950 return v;
1951 }
1952 }
1953 Type::TIMESTAMPTZ => {
1954 if let Some(v) = try_array(
1955 row.try_get::<_, Option<Vec<DateTime<FixedOffset>>>>(idx)
1956 .map(|v| v.map(|a| a.into_iter().map(|t| json!(t.to_rfc3339())).collect())),
1957 ) {
1958 return v;
1959 }
1960 }
1961 Type::DATE => {
1962 if let Some(v) = try_array(row.try_get::<_, Option<Vec<NaiveDate>>>(idx).map(|v| {
1963 v.map(|a| {
1964 a.into_iter()
1965 .map(|d| json!(d.format("%Y-%m-%d").to_string()))
1966 .collect()
1967 })
1968 })) {
1969 return v;
1970 }
1971 }
1972 Type::JSON | Type::JSONB => {
1973 if let Some(v) = try_array(row.try_get::<_, Option<Vec<Value>>>(idx)) {
1974 return v;
1975 }
1976 }
1977 Type::NUMERIC => {
1978 if let Some(v) = try_array(
1979 row.try_get::<_, Option<Vec<Decimal>>>(idx)
1980 .map(|v| v.map(|a| a.into_iter().map(|d| json!(d.to_string())).collect())),
1981 ) {
1982 return v;
1983 }
1984 }
1985 Type::MONEY => {
1986 if let Some(v) = try_array(
1987 row.try_get::<_, Option<Vec<i64>>>(idx)
1988 .map(|v| v.map(|a| a.into_iter().map(|m| json!(money_to_string(m))).collect())),
1989 ) {
1990 return v;
1991 }
1992 }
1993 Type::BYTEA => {
1994 if let Some(v) = try_array(
1995 row.try_get::<_, Option<Vec<Vec<u8>>>>(idx)
1996 .map(|v| v.map(|a| a.into_iter().map(|b| json!(BASE64.encode(b))).collect())),
1997 ) {
1998 return v;
1999 }
2000 }
2001 _ => {}
2002 }
2003
2004 cell_to_json_untyped(row, idx, row.columns()[idx].type_())
2005}
2006
2007fn money_to_string(v: i64) -> String {
2009 let sign = if v < 0 { "-" } else { "" };
2010 let abs = v.unsigned_abs();
2011 format!("{}{}.{:04}", sign, abs / 10_000, abs % 10_000)
2012}
2013
2014fn cell_to_json_untyped(row: &tokio_postgres::Row, idx: usize, pg_type: &Type) -> Value {
2016 if let Ok(Some(s)) = row.try_get::<_, Option<String>>(idx) {
2017 return Value::String(s);
2018 }
2019 json!({
2020 "__untyped": true,
2021 "type": pg_type.name()
2022 })
2023}
2024
2025#[cfg(test)]
2026mod tests {
2027 use super::*;
2028 use crate::plan::build_explain_sql;
2029 use nexql_policy::PolicyFilter;
2030 use serde_json::json;
2031
2032 use crate::session::{ConnectionInfo, ToolSession};
2033
2034 fn test_conn() -> ConnectionInfo {
2035 ConnectionInfo {
2036 id: "conn-1".into(),
2037 name: "conn-1".into(),
2038 host: Some("127.0.0.1".into()),
2039 port: Some(5432),
2040 database: Some("appdb".into()),
2041 params: Default::default(),
2042 }
2043 }
2044
2045 #[test]
2046 fn policy_maps_one_to_one() {
2047 let f = PolicyFilter {
2048 allow_schemas: vec!["public".into()],
2049 deny_schemas: vec!["pgboss".into()],
2050 deny_tables: vec!["auth.*".into()],
2051 pii_columns: vec!["public.users.ssn".into()],
2052 };
2053 let q = policy_to_query_filter(&f);
2054 assert_eq!(q.allow_schemas, f.allow_schemas);
2055 assert_eq!(q.deny_schemas, f.deny_schemas);
2056 assert_eq!(q.deny_tables, f.deny_tables);
2057 assert_eq!(q.pii_columns, f.pii_columns);
2058 }
2059
2060 #[test]
2061 fn ok_json_wraps_arrays_for_cursor_structured_content() {
2062 let out = ToolOutcome::ok_json(json!([{ "id": 1 }, { "id": 2 }]));
2063 assert!(!out.is_error);
2064 let s = out.structured.as_ref().unwrap();
2065 assert!(s.is_object(), "structuredContent must be object, got {s}");
2066 assert_eq!(s["rows"].as_array().unwrap().len(), 2);
2067 assert!(out.text.contains("\"rows\""));
2068 }
2069
2070 #[test]
2071 fn ok_json_leaves_objects_unchanged() {
2072 let out = ToolOutcome::ok_json(json!({ "kind": "table", "name": "orders" }));
2073 let s = out.structured.as_ref().unwrap();
2074 assert_eq!(s["kind"], "table");
2075 assert!(s.get("rows").is_none());
2076 }
2077
2078 #[test]
2079 fn router_specs_include_phase4_and_phase9() {
2080 let session = ToolSession::for_tests(vec![test_conn()], PolicyFilter::default(), None);
2081 let router = ToolRouter::with_index_store(session, None);
2082 assert_eq!(router.specs().len(), 45);
2083 let names: Vec<_> = router.specs().iter().map(|s| s.name.as_str()).collect();
2084 assert!(names.contains(&"search_schema"));
2085 assert!(names.contains(&"get_ddl"));
2086 assert!(names.contains(&"explain_analyze"));
2087 assert!(names.contains(&"get_index_status"));
2088 assert!(names.contains(&"list_extensions"));
2089 assert!(names.contains(&"server_settings"));
2090 assert!(names.contains(&"suggest_indexes"));
2091 assert!(names.contains(&"find_unused_indexes"));
2092 assert!(names.contains(&"bloat_report"));
2093 assert!(names.contains(&"find_missing_fks"));
2094 assert!(names.contains(&"export_query"));
2095 assert!(names.contains(&"list_roles"));
2096 assert!(names.contains(&"db_dashboard"));
2097 assert!(names.contains(&"deep_plan_analysis"));
2098 assert!(names.contains(&"execute_sql"));
2099 assert!(names.contains(&"edit_row"));
2100 assert!(names.contains(&"import_data"));
2101 assert!(names.contains(&"apply_ddl"));
2102 assert!(names.contains(&"create_index_concurrently"));
2103 assert!(names.contains(&"run_maintenance"));
2104 assert!(names.contains(&"terminate_query"));
2105 }
2106
2107 #[tokio::test]
2108 async fn write_tools_refuse_read_mode() {
2109 let session = ToolSession::for_tests(vec![test_conn()], PolicyFilter::default(), None);
2110 let router = ToolRouter::with_index_store(session, None);
2111 for tool in [
2112 "execute_sql",
2113 "edit_row",
2114 "import_data",
2115 "apply_ddl",
2116 "create_index_concurrently",
2117 "run_maintenance",
2118 "terminate_query",
2119 ] {
2120 let out = router
2121 .call(tool, json!({ "sql": "SELECT 1", "table": "public.t", "rows": [], "action": "insert", "values": {}, "pid": 1 }))
2122 .await;
2123 assert!(out.is_error, "{tool}: {}", out.text);
2124 assert!(
2125 out.text.contains("write") || out.text.contains("admin"),
2126 "{tool}: {}",
2127 out.text
2128 );
2129 }
2130 }
2131
2132 #[tokio::test]
2133 async fn table_stats_rejects_injection_ref() {
2134 let session = ToolSession::for_tests(vec![test_conn()], PolicyFilter::default(), None);
2135 let router = ToolRouter::with_index_store(session, None);
2136 let out = router
2137 .call("table_stats", json!({ "ref": "public.users; DROP" }))
2138 .await;
2139 assert!(out.is_error, "{}", out.text);
2140 assert!(
2141 out.text.contains("Invalid object reference") || out.text.contains("invalid arguments"),
2142 "expected ref validation error, got: {}",
2143 out.text
2144 );
2145 }
2146
2147 #[test]
2148 fn explain_transaction_path_builds_readonly_sequence() {
2149 let explain = build_explain_sql("SELECT 1", true);
2151 assert!(explain.starts_with("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)"));
2152 assert!(!explain.to_ascii_lowercase().contains("commit"));
2153 let steps = ["BEGIN", "SET TRANSACTION READ ONLY", &explain, "ROLLBACK"];
2154 assert_eq!(steps.len(), 4);
2155 assert_eq!(steps[0], "BEGIN");
2156 assert_eq!(steps[1], "SET TRANSACTION READ ONLY");
2157 assert_eq!(steps[3], "ROLLBACK");
2158 }
2159
2160 #[tokio::test]
2161 async fn missing_index_returns_actionable_error() {
2162 let session = ToolSession::for_tests(vec![test_conn()], PolicyFilter::default(), None);
2163 let router = ToolRouter::with_index_store(session, None);
2164 let out = router
2165 .call("search_schema", json!({ "query": "users" }))
2166 .await;
2167 assert!(out.is_error, "{}", out.text);
2168 assert!(
2169 out.text.contains("nexql-mcp index build"),
2170 "expected actionable hint, got: {}",
2171 out.text
2172 );
2173 }
2174
2175 #[tokio::test]
2176 async fn empty_index_dir_returns_build_hint() {
2177 let tmp = tempfile::TempDir::new().unwrap();
2178 let store = IndexStore::new(tmp.path());
2179 let session = ToolSession::for_tests(
2180 vec![test_conn()],
2181 PolicyFilter::default(),
2182 Some(IndexStore::new(tmp.path())),
2183 );
2184 let router = ToolRouter::with_index_store(session, Some(store));
2185 let out = router
2186 .call("describe_object", json!({ "ref": "public.users" }))
2187 .await;
2188 assert!(out.is_error, "{}", out.text);
2189 assert!(
2190 out.text.contains("nexql-mcp index build"),
2191 "expected build hint, got: {}",
2192 out.text
2193 );
2194 }
2195}