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 specs(&self) -> &[ToolSpec] {
120 &self.specs
121 }
122
123 fn index_store(&self) -> Option<&IndexStore> {
124 match &self.index_override {
125 Some(inner) => inner.as_ref(),
126 None => self.session.index_store.as_ref(),
127 }
128 }
129
130 fn query_filter(&self) -> QueryPolicyFilter {
131 policy_to_query_filter(&self.session.filter)
132 }
133
134 pub async fn call(&self, name: &str, args: Value) -> ToolOutcome {
135 match self.call_inner(name, args).await {
136 Ok(out) => out,
137 Err(e) => ToolOutcome::err(e.to_string()),
138 }
139 }
140
141 async fn call_inner(&self, name: &str, args: Value) -> Result<ToolOutcome, ToolError> {
142 let tool = ToolName::parse(name).ok_or_else(|| ToolError::Unknown(name.to_string()))?;
143 match tool {
144 ToolName::ListConnections => Ok(self.list_connections()),
145 ToolName::ListDatabases => self.list_databases(&args).await,
146 ToolName::ListSchemas => self.list_schemas().await,
147 ToolName::ListObjects => self.list_objects(&args).await,
148 ToolName::GetCurrentContext => self.get_current_context().await,
149 ToolName::SwitchConnection => self.switch_connection(&args).await,
150 ToolName::RunSelect => self.run_select(&args).await,
151 ToolName::ExplainQuery => self.explain_query(&args).await,
152 ToolName::SearchSchema => self.search_schema(&args).await,
153 ToolName::DescribeObject => self.describe_object(&args).await,
154 ToolName::GetJoinPath => self.get_join_path(&args).await,
155 ToolName::SampleValues => self.sample_values(&args).await,
156 ToolName::GetDdl => self.get_ddl(&args).await,
157 ToolName::TableStats => self.table_stats(&args).await,
158 ToolName::IndexUsage => self.index_usage(&args).await,
159 ToolName::ListRunningQueries => self.list_running_queries().await,
160 ToolName::FindBlockingLocks => self.find_blocking_locks().await,
161 ToolName::SlowQueries => self.slow_queries(&args).await,
162 ToolName::DbHealthCheck => self.db_health_check().await,
163 ToolName::ExplainAnalyze => self.explain_analyze(&args).await,
164 ToolName::AnalyzeQueryPlan => self.analyze_query_plan(&args).await,
165 ToolName::GetIndexStatus => self.get_index_status().await,
166 ToolName::ListExtensions => self.list_extensions().await,
167 ToolName::ServerSettings => self.server_settings().await,
168 ToolName::SuggestIndexes => self.suggest_indexes(&args).await,
169 ToolName::FindUnusedIndexes => self.find_unused_indexes(&args).await,
170 ToolName::BloatReport => self.bloat_report(&args).await,
171 ToolName::FindMissingFks => self.find_missing_fks(&args).await,
172 ToolName::ExportQuery => self.export_query(&args).await,
173 ToolName::ListRoles => self.list_roles(&args).await,
174 ToolName::DbDashboard => self.db_dashboard().await,
175 ToolName::DeepPlanAnalysis => self.deep_plan_analysis(&args).await,
176 ToolName::SchemaDiff => self.schema_diff(&args).await,
177 ToolName::GenerateMigration => self.generate_migration(&args).await,
178 ToolName::ExecuteSql => self.execute_sql_tool(&args).await,
179 ToolName::EditRow => self.edit_row_tool(&args).await,
180 ToolName::ImportData => self.import_data_tool(&args).await,
181 ToolName::ApplyDdl => self.apply_ddl_tool(&args).await,
182 ToolName::CreateIndexConcurrently => self.create_index_concurrently_tool(&args).await,
183 ToolName::RunMaintenance => self.run_maintenance_tool(&args).await,
184 ToolName::TerminateQuery => self.terminate_query_tool(&args).await,
185 }
186 }
187
188 fn require_write(&self) -> Result<(), ToolError> {
189 if !self.session.access_mode.allows_writes() {
190 return Err(ToolError::Execution(
191 "write tools require --access-mode write or admin (current session: read)".into(),
192 ));
193 }
194 Ok(())
195 }
196
197 fn require_admin(&self) -> Result<(), ToolError> {
198 if !self.session.access_mode.allows_admin() {
199 return Err(ToolError::Execution(
200 "admin tools require --access-mode admin".into(),
201 ));
202 }
203 Ok(())
204 }
205
206 async fn execute_sql_tool(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
207 self.require_write()?;
208 let sql = args
209 .get("sql")
210 .and_then(|v| v.as_str())
211 .ok_or_else(|| ToolError::InvalidArgs("sql is required".into()))?;
212 let dry_run = args
213 .get("dry_run")
214 .and_then(|v| v.as_bool())
215 .unwrap_or(false);
216 execute_sql(&self.session, sql, dry_run).await
217 }
218
219 async fn edit_row_tool(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
220 self.require_write()?;
221 edit_row(&self.session, args).await
222 }
223
224 async fn import_data_tool(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
225 self.require_write()?;
226 import_data(&self.session, args).await
227 }
228
229 async fn apply_ddl_tool(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
230 self.require_admin()?;
231 let sql = args
232 .get("sql")
233 .and_then(|v| v.as_str())
234 .ok_or_else(|| ToolError::InvalidArgs("sql is required".into()))?;
235 let dry_run = args
236 .get("dry_run")
237 .and_then(|v| v.as_bool())
238 .unwrap_or(false);
239 apply_ddl(&self.session, sql, dry_run).await
240 }
241
242 async fn create_index_concurrently_tool(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
243 self.require_admin()?;
244 let sql = args
245 .get("sql")
246 .and_then(|v| v.as_str())
247 .ok_or_else(|| ToolError::InvalidArgs("sql is required".into()))?;
248 create_index_concurrently(&self.session, sql).await
249 }
250
251 async fn run_maintenance_tool(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
252 self.require_admin()?;
253 run_maintenance(&self.session, args).await
254 }
255
256 async fn terminate_query_tool(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
257 self.require_admin()?;
258 terminate_query(&self.session, args).await
259 }
260
261 async fn index_service(&self) -> Result<(&IndexStore, String, String), ToolError> {
262 let store = self
263 .index_store()
264 .ok_or_else(|| ToolError::Execution(NO_INDEX_HINT.into()))?;
265 let (connection_id, database) = self.session.active_context().await;
266 let base = store.base_dir(&connection_id, &database);
267 if store.read_manifest(&base)?.is_none() {
268 return Err(ToolError::Execution(format!(
269 "No schema index for database \"{database}\" — run `nexql-mcp index build`."
270 )));
271 }
272 Ok((store, connection_id, database))
273 }
274
275 async fn search_schema(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
276 let query = args
277 .get("query")
278 .and_then(|v| v.as_str())
279 .unwrap_or("")
280 .trim();
281 if query.is_empty() {
282 return Ok(ToolOutcome::ok_json(json!([])));
283 }
284 let (store, connection_id, database) = self.index_service().await?;
285 let svc = IndexQueryService::new(store, &connection_id, &database);
286 let filter = self.query_filter();
287 let hits = svc.search_schema(
288 query,
289 SEARCH_SCHEMA_LIMIT,
290 Some(&filter),
291 SearchOptions {
292 use_semantic: self.use_semantic,
293 embedder: self.embedder.as_deref(),
294 },
295 )?;
296 let rows: Vec<Value> = hits
297 .into_iter()
298 .map(|h| {
299 json!({
300 "ref": h.ref_,
301 "score": h.score,
302 "kind": h.kind,
303 })
304 })
305 .collect();
306 Ok(ToolOutcome::ok_json(json!(rows)))
307 }
308
309 async fn describe_object(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
310 let ref_ = args
311 .get("ref")
312 .and_then(|v| v.as_str())
313 .ok_or_else(|| ToolError::InvalidArgs("ref is required".into()))?;
314 let (store, connection_id, database) = self.index_service().await?;
315 let svc = IndexQueryService::new(store, &connection_id, &database);
316 let filter = self.query_filter();
317 let entry = svc.describe_object(ref_, Some(&filter))?;
318 let value = serde_json::to_value(entry).map_err(|e| ToolError::Execution(e.to_string()))?;
319 Ok(ToolOutcome::ok_json(value))
320 }
321
322 async fn get_join_path(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
323 let a = args
324 .get("a")
325 .and_then(|v| v.as_str())
326 .ok_or_else(|| ToolError::InvalidArgs("a is required".into()))?;
327 let b = args
328 .get("b")
329 .and_then(|v| v.as_str())
330 .ok_or_else(|| ToolError::InvalidArgs("b is required".into()))?;
331 let (store, connection_id, database) = self.index_service().await?;
332 let svc = IndexQueryService::new(store, &connection_id, &database);
333 let path = svc.get_join_path(a, b)?;
334 let value = serde_json::to_value(path).map_err(|e| ToolError::Execution(e.to_string()))?;
335 Ok(ToolOutcome::ok_json(value))
336 }
337
338 async fn sample_values(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
339 let ref_ = args
340 .get("ref")
341 .and_then(|v| v.as_str())
342 .ok_or_else(|| ToolError::InvalidArgs("ref is required".into()))?;
343 let col = args
344 .get("col")
345 .and_then(|v| v.as_str())
346 .ok_or_else(|| ToolError::InvalidArgs("col is required".into()))?;
347 let (store, connection_id, database) = self.index_service().await?;
348 let svc = IndexQueryService::new(store, &connection_id, &database);
349 let filter = self.query_filter();
350 let result = svc.sample_values(ref_, col, Some(&filter), None)?;
352 let mut payload = json!({ "values": result.values });
353 if let Some(message) = result.message {
354 payload["message"] = json!(message);
355 }
356 Ok(ToolOutcome::ok_json(payload))
357 }
358
359 fn list_connections(&self) -> ToolOutcome {
360 let rows: Vec<Value> = self
361 .session
362 .connections
363 .iter()
364 .map(|c| {
365 json!({
366 "id": c.id,
367 "name": c.name,
368 "host": c.host,
369 "port": c.port,
370 "database": c.database,
371 })
372 })
373 .collect();
374 ToolOutcome::ok_json(json!(rows))
375 }
376
377 async fn list_databases(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
378 let connection_id = args
379 .get("connectionId")
380 .and_then(|v| v.as_str())
381 .ok_or_else(|| ToolError::InvalidArgs("connectionId is required".into()))?;
382 let conn = self
383 .session
384 .connections
385 .iter()
386 .find(|c| c.id == connection_id)
387 .ok_or_else(|| {
388 ToolError::Execution(format!(
389 "Connection not found for ID: {connection_id} — call list_connections"
390 ))
391 })?;
392 let client = {
394 if self.session.active_context().await.0 == connection_id {
396 self.session.checkout().await?
397 } else {
398 let pool = nexql_conn::create_pool(&conn.params, &self.session.pool_opts).await?;
399 nexql_conn::checkout_guarded(&pool, &self.session.pool_opts).await?
400 }
401 };
402 let rows = client
403 .query(
404 "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname",
405 &[],
406 )
407 .await?;
408 let names: Vec<String> = rows.iter().map(|r| r.get(0)).collect();
409 Ok(ToolOutcome::ok_json(json!(names)))
410 }
411
412 async fn list_schemas(&self) -> Result<ToolOutcome, ToolError> {
413 let client = self.session.checkout().await?;
414 let rows = client
415 .query(
416 r#"
417 SELECT nspname AS schema_name
418 FROM pg_namespace
419 WHERE nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
420 AND nspname NOT LIKE 'pg_%'
421 ORDER BY nspname
422 "#,
423 &[],
424 )
425 .await?;
426 let out: Vec<Value> = rows
427 .iter()
428 .filter(|r| {
429 let name: String = r.get(0);
430 self.session.filter.allows_schema(&name)
431 })
432 .map(|r| json!({ "schema_name": r.get::<_, String>(0) }))
433 .collect();
434 Ok(ToolOutcome::ok_json(json!(out)))
435 }
436
437 async fn list_objects(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
438 let schema = args
439 .get("schema")
440 .and_then(|v| v.as_str())
441 .unwrap_or("public");
442 if !schema
443 .chars()
444 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
445 {
446 return Err(ToolError::InvalidArgs(
447 "Invalid or missing schema name format".into(),
448 ));
449 }
450 if !self.session.filter.allows_schema(schema) {
451 return Ok(ToolOutcome::ok_json(json!([])));
452 }
453 let kind = args.get("kind").and_then(|v| v.as_str());
454 let mut queries = Vec::new();
455 let push_rel = |queries: &mut Vec<String>, relkinds: &[&str], label: &str| {
456 let kinds = relkinds
457 .iter()
458 .map(|k| format!("'{k}'"))
459 .collect::<Vec<_>>()
460 .join(",");
461 queries.push(format!(
462 r#"
463 SELECT n.nspname AS schema, c.relname AS name, '{label}' AS kind,
464 d.description AS comment
465 FROM pg_class c
466 JOIN pg_namespace n ON n.oid = c.relnamespace
467 LEFT JOIN pg_description d ON d.objoid = c.oid AND d.objsubid = 0
468 WHERE n.nspname = $1 AND c.relkind IN ({kinds})
469 "#
470 ));
471 };
472 if kind.is_none() || kind == Some("table") {
473 push_rel(&mut queries, &["r", "f", "p"], "table");
474 }
475 if kind.is_none() || kind == Some("view") {
476 push_rel(&mut queries, &["v"], "view");
477 }
478 if kind.is_none() || kind == Some("matview") {
479 push_rel(&mut queries, &["m"], "matview");
480 }
481 if queries.is_empty() {
482 return Ok(ToolOutcome::ok_json(json!([])));
483 }
484 let sql = queries.join("\nUNION ALL\n") + "\nORDER BY kind, name";
485 let client = self.session.checkout().await?;
486 let rows = client.query(&sql, &[&schema]).await?;
487 let out: Vec<Value> = rows
488 .iter()
489 .filter(|r| {
490 let s: String = r.get("schema");
491 let name: String = r.get("name");
492 self.session.filter.allows_table(&s, &name)
493 })
494 .map(|r| {
495 json!({
496 "schema": r.get::<_, String>("schema"),
497 "name": r.get::<_, String>("name"),
498 "kind": r.get::<_, String>("kind"),
499 "comment": r.get::<_, Option<String>>("comment"),
500 })
501 })
502 .collect();
503 Ok(ToolOutcome::ok_json(json!(out)))
504 }
505
506 async fn get_current_context(&self) -> Result<ToolOutcome, ToolError> {
507 let (connection_id, database) = self.session.active_context().await;
508 let conn = self
509 .session
510 .connections
511 .iter()
512 .find(|c| c.id == connection_id);
513 Ok(ToolOutcome::ok_json(json!({
514 "connectionId": connection_id,
515 "connectionName": conn.map(|c| c.name.as_str()).unwrap_or("Unknown"),
516 "database": database,
517 "host": conn.and_then(|c| c.host.clone()),
518 "port": conn.and_then(|c| c.port),
519 "access_mode": match self.session.access_mode {
520 nexql_policy::AccessMode::Read => "read",
521 nexql_policy::AccessMode::Write => "write",
522 nexql_policy::AccessMode::Admin => "admin",
523 },
524 })))
525 }
526
527 async fn switch_connection(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
528 let connection_id = args
529 .get("connectionId")
530 .and_then(|v| v.as_str())
531 .ok_or_else(|| ToolError::InvalidArgs("connectionId is required".into()))?;
532 let database = args
533 .get("database")
534 .and_then(|v| v.as_str())
535 .map(str::to_owned);
536 self.session.switch(connection_id, database).await?;
537 self.get_current_context().await
538 }
539
540 async fn run_select(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
541 let sql = args
542 .get("sql")
543 .and_then(|v| v.as_str())
544 .ok_or_else(|| ToolError::InvalidArgs("sql is required".into()))?;
545 match validate_readonly_sql(sql)? {
546 SqlDecision::Allow => {}
547 SqlDecision::Reject => {
548 return Err(ToolError::Execution(
549 "Security Error: Only read-only SELECT, WITH, or EXPLAIN statements are permitted."
550 .into(),
551 ));
552 }
553 }
554 let trimmed = sql.trim().to_ascii_lowercase();
555 if trimmed.starts_with("explain") {
556 return self.run_select_internal(sql, None).await;
557 }
558 let max_rows = self.session.caps.max_rows;
559 self.run_select_internal(sql, Some(max_rows)).await
560 }
561
562 async fn explain_query(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
563 let sql = args
564 .get("sql")
565 .and_then(|v| v.as_str())
566 .ok_or_else(|| ToolError::InvalidArgs("sql is required".into()))?;
567 match validate_readonly_sql(sql)? {
568 SqlDecision::Allow => {}
569 SqlDecision::Reject => {
570 return Err(ToolError::Execution(
571 "Security Error: Only SELECT, WITH, or EXPLAIN statements can be analyzed."
572 .into(),
573 ));
574 }
575 }
576 let clean = if sql.trim().to_ascii_lowercase().starts_with("explain") {
577 sql.to_string()
578 } else {
579 format!("EXPLAIN {sql}")
580 };
581 if validate_readonly_sql(&clean)? == SqlDecision::Reject {
583 return Err(ToolError::Execution(
584 "Security Error: EXPLAIN target is not read-only.".into(),
585 ));
586 }
587 self.run_select_internal(&clean, None).await
588 }
589
590 async fn get_ddl(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
591 let ref_ = args
592 .get("ref")
593 .and_then(|v| v.as_str())
594 .ok_or_else(|| ToolError::InvalidArgs("ref is required".into()))?;
595 let (schema, name) = parse_ref(ref_).map_err(ToolError::InvalidArgs)?;
596 let kind = args.get("kind").and_then(|v| v.as_str()).unwrap_or("table");
597 let reg = sql::regclass_literal(&schema, &name);
598 let client = self.session.checkout().await?;
599
600 match kind {
601 "view" | "matview" => {
602 let sql = format!("SELECT pg_get_viewdef({reg}, true) AS definition");
603 let rows = client.query(&sql, &[]).await?;
604 Ok(ToolOutcome::ok_json(rows_to_json(&rows)))
605 }
606 "function" => {
607 let sql = format!(
608 r#"SELECT p.proname AS name, pg_get_functiondef(p.oid) AS definition
609 FROM pg_proc p
610 JOIN pg_namespace n ON n.oid = p.pronamespace
611 WHERE n.nspname = '{schema}' AND p.proname = '{name}'"#
612 );
613 let rows = client.query(&sql, &[]).await?;
614 Ok(ToolOutcome::ok_json(rows_to_json(&rows)))
615 }
616 "index" => {
617 let sql = format!("SELECT pg_get_indexdef({reg}) AS definition");
618 let rows = client.query(&sql, &[]).await?;
619 Ok(ToolOutcome::ok_json(rows_to_json(&rows)))
620 }
621 "table" => {
622 let columns = client
623 .query(&sql::column_details(&schema, &name), &[])
624 .await?;
625 let constraints = client
626 .query(
627 &format!(
628 r#"SELECT conname AS name, pg_get_constraintdef(oid) AS definition
629 FROM pg_constraint WHERE conrelid = {reg} ORDER BY conname"#
630 ),
631 &[],
632 )
633 .await?;
634 let indexes = client
635 .query(
636 &format!(
637 r#"SELECT indexname AS name, indexdef AS definition
638 FROM pg_indexes
639 WHERE schemaname = '{schema}' AND tablename = '{name}'
640 ORDER BY indexname"#
641 ),
642 &[],
643 )
644 .await?;
645 Ok(ToolOutcome::ok_json(json!({
646 "table": format!("{schema}.{name}"),
647 "columns": rows_to_json(&columns),
648 "constraints": rows_to_json(&constraints),
649 "indexes": rows_to_json(&indexes),
650 })))
651 }
652 other => Err(ToolError::InvalidArgs(format!(
653 "Unsupported DDL kind \"{other}\". Use table, view, matview, function, or index."
654 ))),
655 }
656 }
657
658 async fn table_stats(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
659 let ref_ = args
660 .get("ref")
661 .and_then(|v| v.as_str())
662 .ok_or_else(|| ToolError::InvalidArgs("ref is required".into()))?;
663 let (schema, name) = parse_ref(ref_).map_err(ToolError::InvalidArgs)?;
664 let client = self.session.checkout().await?;
665 let stats = client.query(&sql::table_stats(&schema, &name), &[]).await?;
666 let activity = client
667 .query(&sql::table_activity(&schema, &name), &[])
668 .await?;
669 let columns = client
670 .query(&sql::column_stats(&schema, &name), &[])
671 .await?;
672 let size = rows_to_json(&stats)
673 .as_array()
674 .and_then(|a| a.first())
675 .cloned()
676 .unwrap_or(Value::Null);
677 let activity = rows_to_json(&activity)
678 .as_array()
679 .and_then(|a| a.first())
680 .cloned()
681 .unwrap_or(Value::Null);
682 Ok(ToolOutcome::ok_json(json!({
683 "size": size,
684 "activity": activity,
685 "columns": rows_to_json(&columns),
686 })))
687 }
688
689 async fn index_usage(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
690 let ref_ = args
691 .get("ref")
692 .and_then(|v| v.as_str())
693 .ok_or_else(|| ToolError::InvalidArgs("ref is required".into()))?;
694 let (schema, name) = parse_ref(ref_).map_err(ToolError::InvalidArgs)?;
695 let client = self.session.checkout().await?;
696 let rows = client.query(&sql::index_usage(&schema, &name), &[]).await?;
697 Ok(ToolOutcome::ok_json(rows_to_json(&rows)))
698 }
699
700 async fn list_running_queries(&self) -> Result<ToolOutcome, ToolError> {
701 let client = self.session.checkout().await?;
702 let rows = client.query(sql::running_queries(), &[]).await?;
703 Ok(ToolOutcome::ok_json(rows_to_json(&rows)))
704 }
705
706 async fn find_blocking_locks(&self) -> Result<ToolOutcome, ToolError> {
707 let client = self.session.checkout().await?;
708 let rows = client.query(sql::blocking_locks(), &[]).await?;
709 let values = rows_to_json(&rows);
710 if values.as_array().map(|a| a.is_empty()).unwrap_or(true) {
711 return Ok(ToolOutcome::ok_json(json!({
712 "message": "No blocking locks found.",
713 "locks": [],
714 })));
715 }
716 Ok(ToolOutcome::ok_json(values))
717 }
718
719 async fn slow_queries(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
720 let limit = args
721 .get("limit")
722 .and_then(|v| v.as_u64())
723 .map(|n| n as u32)
724 .unwrap_or(SLOW_QUERIES_DEFAULT);
725 let client = self.session.checkout().await?;
726 match client.query(&sql::slow_queries(limit), &[]).await {
727 Ok(rows) => Ok(ToolOutcome::ok_json(rows_to_json(&rows))),
728 Err(e) => {
729 if let Some(message) = sql::map_stat_statements_error(&e) {
730 Ok(ToolOutcome::ok_json(json!({
731 "error": message,
732 "hint": message,
733 })))
734 } else {
735 Err(ToolError::Postgres(e))
736 }
737 }
738 }
739 }
740
741 async fn db_health_check(&self) -> Result<ToolOutcome, ToolError> {
742 let client = self.session.checkout().await?;
743 let sections: &[(&str, &str)] = &[
744 ("overview", sql::database_stats()),
745 ("cache", sql::cache_hit_ratio()),
746 ("dead_tuples", sql::database_maintenance_stats()),
747 ("connection_states", sql::connection_states()),
748 ("blocking_locks", sql::blocking_locks()),
749 ];
750 let mut report = serde_json::Map::new();
751 for (key, q) in sections {
752 match client.query(*q, &[]).await {
753 Ok(rows) => {
754 report.insert((*key).into(), rows_to_json(&rows));
755 }
756 Err(e) => {
757 report.insert((*key).into(), json!({ "error": e.to_string() }));
758 }
759 }
760 }
761 let lock_count = report
762 .get("blocking_locks")
763 .and_then(|v| v.as_array())
764 .map(|a| a.len() as u64);
765 report.insert("blocking_lock_count".into(), json!(lock_count));
766 Ok(ToolOutcome::ok_json(Value::Object(report)))
767 }
768
769 async fn explain_analyze(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
770 let sql = args
771 .get("sql")
772 .and_then(|v| v.as_str())
773 .ok_or_else(|| ToolError::InvalidArgs("sql is required".into()))?;
774 require_select_or_with(sql)?;
775 let explain = build_explain_sql(sql, true);
776 self.run_explain_in_transaction(&explain).await
777 }
778
779 async fn analyze_query_plan(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
780 let sql = args
781 .get("sql")
782 .and_then(|v| v.as_str())
783 .ok_or_else(|| ToolError::InvalidArgs("sql is required".into()))?;
784 require_select_or_with(sql)?;
785 let analyze = args
786 .get("analyze")
787 .and_then(|v| v.as_bool())
788 .unwrap_or(false);
789 let explain = build_explain_sql(sql, analyze);
790 let outcome = self.run_explain_in_transaction(&explain).await?;
791 let rows = outcome.structured.unwrap_or(Value::Null);
792 let row_array = rows
793 .get("rows")
794 .and_then(|v| v.as_array())
795 .or_else(|| rows.as_array());
796 let plan = row_array
797 .and_then(|a| a.first())
798 .and_then(|r| r.get("QUERY PLAN"))
799 .cloned()
800 .unwrap_or(Value::Null);
801 let metrics = extract_plan_metrics(&plan).or_else(|| extract_plan_metrics(&rows));
802 let recommendations = metrics
803 .as_ref()
804 .and_then(|m| m.get("recommendations"))
805 .cloned()
806 .unwrap_or_else(|| json!([]));
807 Ok(ToolOutcome::ok_json(json!({
808 "metrics": metrics,
809 "recommendations": recommendations,
810 "plan": plan,
811 })))
812 }
813
814 async fn run_explain_in_transaction(
816 &self,
817 explain_sql: &str,
818 ) -> Result<ToolOutcome, ToolError> {
819 let client = self.session.checkout().await?;
820 client
821 .batch_execute("SET statement_timeout = '30s'")
822 .await?;
823 client.batch_execute("BEGIN").await?;
824 let result = async {
825 client.batch_execute("SET TRANSACTION READ ONLY").await?;
826 let rows = client.query(explain_sql, &[]).await?;
827 Ok::<_, ToolError>(rows_to_json(&rows))
828 }
829 .await;
830 let _ = client.batch_execute("ROLLBACK").await;
832 match result {
833 Ok(values) => Ok(ToolOutcome::ok_json(values)),
834 Err(e) => Err(e),
835 }
836 }
837
838 async fn get_index_status(&self) -> Result<ToolOutcome, ToolError> {
839 let (store, connection_id, database) = self.index_service().await?;
840 let base = store.base_dir(&connection_id, &database);
841 let Some(manifest) = store.read_manifest(&base)? else {
842 return Err(ToolError::Execution(format!(
843 "No schema index for database \"{database}\" — run `nexql-mcp index build`."
844 )));
845 };
846
847 let mut live_fingerprint: Option<String> = None;
848 let mut drift: Option<bool> = None;
849 if let Ok(client) = self.session.checkout().await {
850 let db = PgCatalogDb::new(&client);
851 if let Ok(fp) = db.schema_fingerprint().await {
852 drift = Some(fp != manifest.schema_fingerprint);
853 live_fingerprint = Some(fp);
854 }
855 }
856
857 Ok(ToolOutcome::ok_json(json!({
858 "connectionId": manifest.connection_id,
859 "database": manifest.database,
860 "indexedAt": manifest.indexed_at,
861 "fingerprint": manifest.schema_fingerprint,
862 "liveFingerprint": live_fingerprint,
863 "drift": drift,
864 "pgVersion": manifest.pg_version,
865 "counts": {
866 "tables": manifest.counts.tables,
867 "views": manifest.counts.views,
868 "functions": manifest.counts.functions,
869 "enums": manifest.counts.enums,
870 },
871 "buildMs": manifest.stats.build_ms,
872 "warnings": manifest.stats.warnings,
873 })))
874 }
875
876 async fn list_extensions(&self) -> Result<ToolOutcome, ToolError> {
877 let client = self.session.checkout().await?;
878 let rows = client.query(sql::list_extensions(), &[]).await?;
879 Ok(ToolOutcome::ok_json(rows_to_json(&rows)))
880 }
881
882 async fn server_settings(&self) -> Result<ToolOutcome, ToolError> {
883 let client = self.session.checkout().await?;
884 let rows = client.query(sql::server_settings(), &[]).await?;
885 Ok(ToolOutcome::ok_json(rows_to_json(&rows)))
886 }
887
888 async fn suggest_indexes(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
889 let limit = args
890 .get("limit")
891 .and_then(|v| v.as_u64())
892 .map(|n| n as u32)
893 .unwrap_or(REPORT_LIMIT_DEFAULT);
894 let client = self.session.checkout().await?;
895
896 let high_seq = client.query(&sql::high_seq_scan_tables(limit), &[]).await?;
897 let unindexed_fks = client.query(&sql::unindexed_fk_columns(limit), &[]).await?;
898
899 let mut pg_stat_available = false;
900 let mut slow_queries = Value::Null;
901 let mut pg_stat_note: Option<String> = None;
902 match client.query(&sql::slow_queries(limit.min(10)), &[]).await {
903 Ok(rows) => {
904 pg_stat_available = true;
905 slow_queries = rows_to_json(&rows);
906 }
907 Err(e) => {
908 if let Some(message) = sql::map_stat_statements_error(&e) {
909 pg_stat_note = Some(message);
910 } else {
911 return Err(ToolError::Postgres(e));
912 }
913 }
914 }
915
916 let mut plan_heuristics = Value::Null;
917 if let Some(sql_text) = args.get("sql").and_then(|v| v.as_str()) {
918 require_select_or_with(sql_text)?;
919 let explain = build_explain_sql(sql_text, false);
920 let outcome = self.run_explain_in_transaction(&explain).await?;
921 let rows = outcome.structured.unwrap_or(Value::Null);
922 let plan = rows
923 .as_array()
924 .and_then(|a| a.first())
925 .and_then(|r| r.get("QUERY PLAN"))
926 .cloned()
927 .unwrap_or(Value::Null);
928 let metrics = extract_plan_metrics(&plan).or_else(|| extract_plan_metrics(&rows));
929 plan_heuristics = json!({
930 "metrics": metrics,
931 "hint": "Use analyze_query_plan with analyze=true for actual timings before creating indexes.",
932 });
933 }
934
935 let high_seq_json = rows_to_json(&high_seq);
936 let unindexed_json = rows_to_json(&unindexed_fks);
937 let has_candidates = high_seq_json
938 .as_array()
939 .map(|a| !a.is_empty())
940 .unwrap_or(false)
941 || unindexed_json
942 .as_array()
943 .map(|a| !a.is_empty())
944 .unwrap_or(false)
945 || plan_heuristics != Value::Null;
946
947 if !has_candidates && !pg_stat_available {
948 return Ok(ToolOutcome::ok_json(json!({
949 "suggestions": [],
950 "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.",
951 "hint": pg_stat_note,
952 })));
953 }
954
955 if !has_candidates {
956 return Ok(ToolOutcome::ok_json(json!({
957 "high_seq_scan_tables": high_seq_json,
958 "unindexed_fk_columns": unindexed_json,
959 "slow_queries": slow_queries,
960 "plan_heuristics": plan_heuristics,
961 "message": "No strong index candidates from sequential-scan or unindexed-FK heuristics. Review slow_queries / pass sql for plan-level advice.",
962 "hint": "CREATE INDEX CONCURRENTLY after validating with EXPLAIN (ANALYZE, BUFFERS).",
963 })));
964 }
965
966 Ok(ToolOutcome::ok_json(json!({
967 "high_seq_scan_tables": high_seq_json,
968 "unindexed_fk_columns": unindexed_json,
969 "slow_queries": slow_queries,
970 "plan_heuristics": plan_heuristics,
971 "pg_stat_statements": pg_stat_available,
972 "hint": pg_stat_note.unwrap_or_else(|| {
973 "Validate candidates with analyze_query_plan / EXPLAIN before CREATE INDEX CONCURRENTLY.".into()
974 }),
975 })))
976 }
977
978 async fn find_unused_indexes(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
979 let limit = args
980 .get("limit")
981 .and_then(|v| v.as_u64())
982 .map(|n| n as u32)
983 .unwrap_or(REPORT_LIMIT_DEFAULT);
984 let client = self.session.checkout().await?;
985 let rows = client.query(&sql::find_unused_indexes(limit), &[]).await?;
986 let indexes = rows_to_json(&rows);
987 if indexes.as_array().map(|a| a.is_empty()).unwrap_or(true) {
988 return Ok(ToolOutcome::ok_json(json!({
989 "indexes": [],
990 "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.",
991 })));
992 }
993 Ok(ToolOutcome::ok_json(json!({
994 "indexes": indexes,
995 "hint": "Prefer DROP INDEX CONCURRENTLY after confirming the workload (and that stats are mature).",
996 })))
997 }
998
999 async fn bloat_report(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
1000 let limit = args
1001 .get("limit")
1002 .and_then(|v| v.as_u64())
1003 .map(|n| n as u32)
1004 .unwrap_or(REPORT_LIMIT_DEFAULT);
1005 let client = self.session.checkout().await?;
1006 let rows = client.query(&sql::bloat_report(limit), &[]).await?;
1007 let tables = rows_to_json(&rows);
1008 if tables.as_array().map(|a| a.is_empty()).unwrap_or(true) {
1009 return Ok(ToolOutcome::ok_json(json!({
1010 "tables": [],
1011 "method": "dead_tuple_ratio",
1012 "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.",
1013 })));
1014 }
1015 Ok(ToolOutcome::ok_json(json!({
1016 "tables": tables,
1017 "method": "dead_tuple_ratio",
1018 "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.",
1019 "hint": "VACUUM ANALYZE on high bloat_pct tables; investigate autovacuum settings if last_autovacuum is stale.",
1020 })))
1021 }
1022
1023 async fn find_missing_fks(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
1024 let limit = args
1025 .get("limit")
1026 .and_then(|v| v.as_u64())
1027 .map(|n| n as u32)
1028 .unwrap_or(REPORT_LIMIT_DEFAULT);
1029 let capped = limit.clamp(1, sql::REPORT_LIMIT_MAX) as usize;
1030
1031 if let Ok((store, connection_id, database)) = self.index_service().await {
1033 let base = store.base_dir(&connection_id, &database);
1034 if let Ok(Some(manifest)) = store.read_manifest(&base) {
1035 if let Ok(Some(graph)) = store.read_join_graph(&base, &manifest) {
1036 let candidates: Vec<Value> = graph
1037 .edges
1038 .into_iter()
1039 .filter(|e| e.inferred == Some(true) && e.disabled != Some(true))
1040 .take(capped)
1041 .map(|e| {
1042 let cols: Vec<Value> = e
1043 .cols
1044 .iter()
1045 .map(|(a, b)| json!({ "from": a, "to": b }))
1046 .collect();
1047 json!({
1048 "from_table": e.from,
1049 "to_table": e.to,
1050 "via": e.via,
1051 "columns": cols,
1052 "detection": "join_graph_inferred",
1053 })
1054 })
1055 .collect();
1056 if !candidates.is_empty() {
1057 return Ok(ToolOutcome::ok_json(json!({
1058 "candidates": candidates,
1059 "source": "join_graph",
1060 "hint": "These edges were inferred by naming convention and have no declared FK. Review before ALTER TABLE … ADD FOREIGN KEY.",
1061 })));
1062 }
1063 }
1064 }
1065 }
1066
1067 let client = self.session.checkout().await?;
1068 let rows = client
1069 .query(&sql::find_missing_fks_catalog(limit), &[])
1070 .await?;
1071 let candidates = rows_to_json(&rows);
1072 if candidates.as_array().map(|a| a.is_empty()).unwrap_or(true) {
1073 return Ok(ToolOutcome::ok_json(json!({
1074 "candidates": [],
1075 "source": "catalog",
1076 "message": "No missing FK candidates found via join-graph inferred edges or *_id naming against single-column PKs.",
1077 })));
1078 }
1079 Ok(ToolOutcome::ok_json(json!({
1080 "candidates": candidates,
1081 "source": "catalog",
1082 "hint": "Naming-inferred only — verify referential integrity and nullability before adding constraints. Run `nexql-mcp index build` for join-graph inferred edges.",
1083 })))
1084 }
1085
1086 async fn list_roles(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
1087 let client = self.session.checkout().await?;
1088 let role = args
1089 .get("role")
1090 .and_then(|v| v.as_str())
1091 .map(str::trim)
1092 .filter(|s| !s.is_empty());
1093
1094 let Some(role_name) = role else {
1095 let rows = client.query(sql::list_roles(), &[]).await?;
1096 return Ok(ToolOutcome::ok_json(rows_to_json(&rows)));
1097 };
1098
1099 let details = client.query(sql::role_details(), &[&role_name]).await?;
1100 if details.is_empty() {
1101 return Err(ToolError::Execution(format!(
1102 "Role \"{role_name}\" not found"
1103 )));
1104 }
1105 let member_of = client.query(sql::role_member_of(), &[&role_name]).await?;
1106 let has_members = client.query(sql::role_has_members(), &[&role_name]).await?;
1107 let privileges = client
1108 .query(sql::role_table_privileges(), &[&role_name])
1109 .await?;
1110
1111 Ok(ToolOutcome::ok_json(json!({
1112 "role": rows_to_json(&details).as_array().and_then(|a| a.first().cloned()).unwrap_or(Value::Null),
1113 "member_of": rows_to_json(&member_of),
1114 "has_members": rows_to_json(&has_members),
1115 "table_privileges": rows_to_json(&privileges),
1116 })))
1117 }
1118
1119 async fn export_query(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
1120 let sql = args
1121 .get("sql")
1122 .and_then(|v| v.as_str())
1123 .ok_or_else(|| ToolError::InvalidArgs("sql is required".into()))?;
1124 require_select_or_with(sql)?;
1125
1126 let format = args
1127 .get("format")
1128 .and_then(|v| v.as_str())
1129 .map(|s| {
1130 ExportFormat::parse(s).ok_or_else(|| {
1131 ToolError::InvalidArgs(format!(
1132 "Unsupported format \"{s}\". Use csv, json, or sqlinsert."
1133 ))
1134 })
1135 })
1136 .transpose()?
1137 .unwrap_or(ExportFormat::Csv);
1138
1139 let table_target = match args.get("table").and_then(|v| v.as_str()) {
1140 Some(t) if !t.trim().is_empty() => Some(parse_ref(t).map_err(ToolError::InvalidArgs)?),
1141 _ => None,
1142 };
1143
1144 if format == ExportFormat::SqlInsert && table_target.is_none() {
1145 return Err(ToolError::InvalidArgs(
1146 "table (schema.name) is required when format=sqlinsert".into(),
1147 ));
1148 }
1149
1150 let max_rows = self.session.caps.max_rows;
1151 let outcome = self.run_select_internal(sql, Some(max_rows)).await?;
1152 if outcome.is_error {
1153 return Ok(outcome);
1154 }
1155
1156 let structured = outcome.structured.unwrap_or(Value::Null);
1157 let rows_val = structured
1158 .get("rows")
1159 .cloned()
1160 .or_else(|| structured.get("data").and_then(|d| d.get("rows").cloned()))
1161 .unwrap_or(Value::Array(vec![]));
1162 let rows = rows_val.as_array().cloned().unwrap_or_default();
1163 let columns = columns_from_rows(&rows);
1164 let truncated = structured
1165 .get("truncated")
1166 .and_then(|v| v.as_bool())
1167 .unwrap_or(false);
1168
1169 let payload = match format {
1170 ExportFormat::Json => json!({
1171 "format": format.as_str(),
1172 "rowCount": rows.len(),
1173 "truncated": truncated,
1174 "columns": columns,
1175 "rows": rows,
1176 }),
1177 ExportFormat::Csv => {
1178 let content = rows_to_csv(&rows, &columns);
1179 let (char_trunc, content) = self.session.caps.truncate_chars(&content);
1180 json!({
1181 "format": format.as_str(),
1182 "rowCount": rows.len(),
1183 "truncated": truncated || char_trunc,
1184 "columns": columns,
1185 "content": content,
1186 })
1187 }
1188 ExportFormat::SqlInsert => {
1189 let (schema, table) = table_target.expect("checked above");
1190 let content = rows_to_sql_insert(&rows, &columns, &schema, &table);
1191 let (char_trunc, content) = self.session.caps.truncate_chars(&content);
1192 json!({
1193 "format": format.as_str(),
1194 "rowCount": rows.len(),
1195 "truncated": truncated || char_trunc,
1196 "table": format!("{schema}.{table}"),
1197 "columns": columns,
1198 "content": content,
1199 })
1200 }
1201 };
1202
1203 Ok(ToolOutcome::ok_json(payload))
1204 }
1205
1206 async fn db_dashboard(&self) -> Result<ToolOutcome, ToolError> {
1207 let client = self.session.checkout().await?;
1208 let sections: &[(&str, &str)] = &[
1209 ("db_info", sql::dashboard_db_info()),
1210 ("connection_states", sql::connection_states()),
1211 ("top_tables", sql::dashboard_top_tables()),
1212 ("object_counts", sql::dashboard_object_counts()),
1213 ("active_queries", sql::dashboard_active_queries()),
1214 ("blocking_locks", sql::blocking_locks()),
1215 ("max_connections", sql::dashboard_max_connections()),
1216 ("extension_count", sql::dashboard_extension_count()),
1217 ("cache", sql::cache_hit_ratio()),
1218 ];
1219 let mut report = serde_json::Map::new();
1220 for (key, q) in sections {
1221 match client.query(*q, &[]).await {
1222 Ok(rows) => {
1223 report.insert((*key).into(), rows_to_json(&rows));
1224 }
1225 Err(e) => {
1226 report.insert((*key).into(), json!({ "error": e.to_string() }));
1227 }
1228 }
1229 }
1230
1231 for key in ["db_info", "object_counts", "extension_count", "cache"] {
1233 if let Some(Value::Array(arr)) = report.get(key).cloned() {
1234 if arr.len() == 1 {
1235 report.insert(key.into(), arr.into_iter().next().unwrap());
1236 }
1237 }
1238 }
1239 if let Some(Value::Array(arr)) = report.get("max_connections").cloned() {
1240 if let Some(row) = arr.first() {
1241 report.insert(
1242 "max_connections".into(),
1243 row.get("max_connections")
1244 .cloned()
1245 .unwrap_or_else(|| row.clone()),
1246 );
1247 }
1248 }
1249
1250 Ok(ToolOutcome::ok_json(Value::Object(report)))
1251 }
1252
1253 async fn deep_plan_analysis(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
1254 let sql = args
1255 .get("sql")
1256 .and_then(|v| v.as_str())
1257 .ok_or_else(|| ToolError::InvalidArgs("sql is required".into()))?;
1258 require_select_or_with(sql)?;
1259 let analyze = args
1260 .get("analyze")
1261 .and_then(|v| v.as_bool())
1262 .unwrap_or(true);
1263 let explain = build_explain_sql(sql, analyze);
1264 let outcome = self.run_explain_in_transaction(&explain).await?;
1265 let rows = outcome.structured.unwrap_or(Value::Null);
1266 let row_array = rows
1267 .get("rows")
1268 .and_then(|v| v.as_array())
1269 .or_else(|| rows.as_array());
1270 let plan = row_array
1271 .and_then(|a| a.first())
1272 .and_then(|r| r.get("QUERY PLAN"))
1273 .cloned()
1274 .unwrap_or(Value::Null);
1275 let deep = analyze_deep_plan(&plan, sql)
1276 .or_else(|| analyze_deep_plan(&rows, sql))
1277 .ok_or_else(|| {
1278 ToolError::Execution("Could not parse EXPLAIN JSON plan for deep analysis".into())
1279 })?;
1280 let metrics = extract_plan_metrics(&plan).or_else(|| extract_plan_metrics(&rows));
1281 Ok(ToolOutcome::ok_json(json!({
1282 "deep": deep,
1283 "metrics": metrics,
1284 "plan": plan,
1285 "analyzed": analyze,
1286 })))
1287 }
1288
1289 async fn schema_diff(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
1290 let source_schema = args
1291 .get("sourceSchema")
1292 .and_then(|v| v.as_str())
1293 .ok_or_else(|| ToolError::InvalidArgs("sourceSchema is required".into()))?;
1294 let target_schema = args
1295 .get("targetSchema")
1296 .and_then(|v| v.as_str())
1297 .ok_or_else(|| ToolError::InvalidArgs("targetSchema is required".into()))?;
1298 crate::schema_diff::require_safe_schema(source_schema)?;
1299 crate::schema_diff::require_safe_schema(target_schema)?;
1300
1301 let client = self.session.checkout().await?;
1302 let source = crate::schema_diff::load_schema_snapshot(&client, source_schema).await?;
1303 let target = crate::schema_diff::load_schema_snapshot(&client, target_schema).await?;
1304 let diffs = crate::schema_diff::compute_schema_diff(&source, &target);
1305 let changed = diffs
1306 .iter()
1307 .filter(|d| d.status != crate::schema_diff::DiffStatus::Unchanged)
1308 .count();
1309 Ok(ToolOutcome::ok_json(json!({
1310 "sourceSchema": source_schema,
1311 "targetSchema": target_schema,
1312 "tableCount": diffs.len(),
1313 "changedCount": changed,
1314 "diffs": crate::schema_diff::diffs_to_json(&diffs),
1315 })))
1316 }
1317
1318 async fn generate_migration(&self, args: &Value) -> Result<ToolOutcome, ToolError> {
1319 let source_schema = args
1320 .get("sourceSchema")
1321 .and_then(|v| v.as_str())
1322 .ok_or_else(|| ToolError::InvalidArgs("sourceSchema is required".into()))?;
1323 let target_schema = args
1324 .get("targetSchema")
1325 .and_then(|v| v.as_str())
1326 .ok_or_else(|| ToolError::InvalidArgs("targetSchema is required".into()))?;
1327 crate::schema_diff::require_safe_schema(source_schema)?;
1328 crate::schema_diff::require_safe_schema(target_schema)?;
1329
1330 let client = self.session.checkout().await?;
1331 let source = crate::schema_diff::load_schema_snapshot(&client, source_schema).await?;
1332 let target = crate::schema_diff::load_schema_snapshot(&client, target_schema).await?;
1333 let diffs = crate::schema_diff::compute_schema_diff(&source, &target);
1334 let statements =
1335 crate::schema_diff::build_migration_statements(source_schema, target_schema, &diffs);
1336 let sql = if statements.is_empty() {
1337 format!("-- No differences between {source_schema} and {target_schema}")
1338 } else {
1339 statements.join("\n\n")
1340 };
1341 Ok(ToolOutcome::ok_json(json!({
1342 "sourceSchema": source_schema,
1343 "targetSchema": target_schema,
1344 "statementCount": statements.len(),
1345 "sql": sql,
1346 "hint": "Read-only: review and run via execute_sql / apply_ddl only with --access-mode write|admin. Destructive drops are commented out.",
1347 })))
1348 }
1349
1350 async fn run_select_internal(
1351 &self,
1352 sql: &str,
1353 max_rows: Option<u32>,
1354 ) -> Result<ToolOutcome, ToolError> {
1355 let client = self.session.checkout().await?;
1356 let Some(max_rows) = max_rows else {
1357 let rows = client.query(sql, &[]).await?;
1358 let values = rows_to_json(&rows);
1359 let payload = ensure_structured_object(values);
1361 let text = serde_json::to_string_pretty(&payload)
1362 .map_err(|e| ToolError::Execution(e.to_string()))?;
1363 let (trunc, text) = self.session.caps.truncate_chars(&text);
1364 let structured = if trunc {
1365 json!({ "truncated_chars": true, "data": payload })
1366 } else {
1367 payload
1368 };
1369 return Ok(ToolOutcome {
1370 text: text.to_string(),
1371 structured: Some(structured),
1372 is_error: false,
1373 });
1374 };
1375
1376 let cleaned = sql.trim().trim_end_matches(';').trim();
1377 let wrapped = format!(
1378 "SELECT * FROM ({cleaned}) AS nexql_limited LIMIT {}",
1379 max_rows + 1
1380 );
1381 let rows = match client.query(&wrapped, &[]).await {
1382 Ok(r) => r,
1383 Err(_) => client.query(sql, &[]).await?,
1384 };
1385 let truncated = rows.len() as u32 > max_rows;
1386 let keep = if truncated {
1387 &rows[..max_rows as usize]
1388 } else {
1389 &rows[..]
1390 };
1391 let values = rows_to_json(keep);
1392 let mut payload = ensure_structured_object(values);
1394 if truncated {
1395 if let Some(obj) = payload.as_object_mut() {
1396 obj.insert("truncated".into(), json!(true));
1397 obj.insert("maxRows".into(), json!(max_rows));
1398 }
1399 }
1400 let text = serde_json::to_string_pretty(&payload)
1401 .map_err(|e| ToolError::Execution(e.to_string()))?;
1402 let (char_trunc, text) = self.session.caps.truncate_chars(&text);
1403 let structured = if char_trunc {
1404 json!({ "truncated_chars": true, "data": payload })
1405 } else {
1406 payload
1407 };
1408 Ok(ToolOutcome {
1409 text: text.to_string(),
1410 structured: Some(structured),
1411 is_error: false,
1412 })
1413 }
1414}
1415
1416fn policy_to_query_filter(filter: &PolicyFilter) -> QueryPolicyFilter {
1417 QueryPolicyFilter {
1418 allow_schemas: filter.allow_schemas.clone(),
1419 deny_schemas: filter.deny_schemas.clone(),
1420 deny_tables: filter.deny_tables.clone(),
1421 pii_columns: filter.pii_columns.clone(),
1422 }
1423}
1424
1425fn require_select_or_with(sql: &str) -> Result<(), ToolError> {
1426 match validate_readonly_sql(sql)? {
1427 SqlDecision::Allow => {}
1428 SqlDecision::Reject => {
1429 return Err(ToolError::Execution(
1430 "Security Error: Only SELECT or WITH statements can be analyzed.".into(),
1431 ));
1432 }
1433 }
1434 let trimmed = sql.trim().to_ascii_lowercase();
1435 if !(trimmed.starts_with("select") || trimmed.starts_with("with")) {
1436 return Err(ToolError::Execution(
1437 "Security Error: Only SELECT or WITH statements can be analyzed.".into(),
1438 ));
1439 }
1440 Ok(())
1441}
1442
1443fn rows_to_json(rows: &[tokio_postgres::Row]) -> Value {
1444 let arr: Vec<Value> = rows
1445 .iter()
1446 .map(|row| {
1447 let mut map = serde_json::Map::new();
1448 for (i, col) in row.columns().iter().enumerate() {
1449 map.insert(col.name().to_string(), cell_to_json(row, i));
1450 }
1451 Value::Object(map)
1452 })
1453 .collect();
1454 Value::Array(arr)
1455}
1456
1457enum SqlNullness {
1459 Null,
1460 Value,
1461}
1462
1463impl<'a> FromSql<'a> for SqlNullness {
1464 fn from_sql(_: &Type, _: &'a [u8]) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
1465 Ok(SqlNullness::Value)
1466 }
1467
1468 fn from_sql_null(_: &Type) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
1469 Ok(SqlNullness::Null)
1470 }
1471
1472 fn accepts(_: &Type) -> bool {
1473 true
1474 }
1475}
1476
1477fn try_cell<T, F>(row: &tokio_postgres::Row, idx: usize, map: F) -> Option<Value>
1478where
1479 T: for<'a> FromSql<'a>,
1480 F: FnOnce(T) -> Value,
1481{
1482 match row.try_get::<_, Option<T>>(idx) {
1483 Ok(Some(v)) => Some(map(v)),
1484 Ok(None) => Some(Value::Null),
1485 Err(_) => None,
1486 }
1487}
1488
1489fn cell_to_json(row: &tokio_postgres::Row, idx: usize) -> Value {
1490 let col_type = row.columns()[idx].type_();
1491 if matches!(row.try_get::<_, SqlNullness>(idx), Ok(SqlNullness::Null)) {
1492 return Value::Null;
1493 }
1494
1495 if let Kind::Array(elem) = col_type.kind() {
1496 return array_cell_to_json(row, idx, elem);
1497 }
1498
1499 if let Some(v) = match *col_type {
1500 Type::BOOL => try_cell::<bool, _>(row, idx, |b| json!(b)),
1501 Type::INT2 => try_cell::<i16, _>(row, idx, |n| json!(n)),
1502 Type::INT4 | Type::OID => try_cell::<i32, _>(row, idx, |n| json!(n)),
1503 Type::INT8 => try_cell::<i64, _>(row, idx, |n| json!(n)),
1504 Type::FLOAT4 => try_cell::<f32, _>(row, idx, |n| json!(n)),
1505 Type::FLOAT8 => try_cell::<f64, _>(row, idx, |n| json!(n)),
1506 Type::TEXT | Type::VARCHAR | Type::BPCHAR | Type::NAME => {
1507 try_cell::<String, _>(row, idx, Value::String)
1508 }
1509 Type::TIMESTAMP => try_cell::<NaiveDateTime, _>(row, idx, |t| {
1510 json!(t.format("%Y-%m-%dT%H:%M:%S%.f").to_string())
1511 }),
1512 Type::TIMESTAMPTZ => {
1513 try_cell::<DateTime<FixedOffset>, _>(row, idx, |t| json!(t.to_rfc3339()))
1514 }
1515 Type::DATE => {
1516 try_cell::<NaiveDate, _>(row, idx, |d| json!(d.format("%Y-%m-%d").to_string()))
1517 }
1518 Type::TIME => {
1519 try_cell::<NaiveTime, _>(row, idx, |t| json!(t.format("%H:%M:%S%.f").to_string()))
1520 }
1521 Type::UUID => try_cell::<Uuid, _>(row, idx, |u| json!(u.to_string())),
1522 Type::JSON | Type::JSONB => try_cell::<Value, _>(row, idx, |j| j),
1523 Type::NUMERIC => try_cell::<Decimal, _>(row, idx, |d| json!(d.to_string())),
1524 Type::MONEY => try_cell::<i64, _>(row, idx, |v| json!(money_to_string(v))),
1525 Type::BYTEA => try_cell::<Vec<u8>, _>(row, idx, |b| json!(BASE64.encode(b))),
1526 _ => None,
1527 } {
1528 return v;
1529 }
1530
1531 cell_to_json_untyped(row, idx, col_type)
1532}
1533
1534fn array_cell_to_json(row: &tokio_postgres::Row, idx: usize, elem: &Type) -> Value {
1535 let try_array = |result: Result<Option<Vec<Value>>, tokio_postgres::Error>| -> Option<Value> {
1536 match result {
1537 Ok(Some(items)) => Some(Value::Array(items)),
1538 Ok(None) => Some(Value::Null),
1539 Err(_) => None,
1540 }
1541 };
1542
1543 match *elem {
1544 Type::BOOL => {
1545 if let Some(v) = try_array(
1546 row.try_get::<_, Option<Vec<bool>>>(idx)
1547 .map(|v| v.map(|a| a.into_iter().map(|x| json!(x)).collect())),
1548 ) {
1549 return v;
1550 }
1551 }
1552 Type::INT2 => {
1553 if let Some(v) = try_array(
1554 row.try_get::<_, Option<Vec<i16>>>(idx)
1555 .map(|v| v.map(|a| a.into_iter().map(|x| json!(x)).collect())),
1556 ) {
1557 return v;
1558 }
1559 }
1560 Type::INT4 | Type::OID => {
1561 if let Some(v) = try_array(
1562 row.try_get::<_, Option<Vec<i32>>>(idx)
1563 .map(|v| v.map(|a| a.into_iter().map(|x| json!(x)).collect())),
1564 ) {
1565 return v;
1566 }
1567 }
1568 Type::INT8 => {
1569 if let Some(v) = try_array(
1570 row.try_get::<_, Option<Vec<i64>>>(idx)
1571 .map(|v| v.map(|a| a.into_iter().map(|x| json!(x)).collect())),
1572 ) {
1573 return v;
1574 }
1575 }
1576 Type::FLOAT4 => {
1577 if let Some(v) = try_array(
1578 row.try_get::<_, Option<Vec<f32>>>(idx)
1579 .map(|v| v.map(|a| a.into_iter().map(|n| json!(n)).collect())),
1580 ) {
1581 return v;
1582 }
1583 }
1584 Type::FLOAT8 => {
1585 if let Some(v) = try_array(
1586 row.try_get::<_, Option<Vec<f64>>>(idx)
1587 .map(|v| v.map(|a| a.into_iter().map(|n| json!(n)).collect())),
1588 ) {
1589 return v;
1590 }
1591 }
1592 Type::TEXT | Type::VARCHAR | Type::BPCHAR | Type::NAME => {
1593 if let Some(v) = try_array(
1594 row.try_get::<_, Option<Vec<String>>>(idx)
1595 .map(|v| v.map(|a| a.into_iter().map(Value::String).collect())),
1596 ) {
1597 return v;
1598 }
1599 }
1600 Type::UUID => {
1601 if let Some(v) = try_array(
1602 row.try_get::<_, Option<Vec<Uuid>>>(idx)
1603 .map(|v| v.map(|a| a.into_iter().map(|u| json!(u.to_string())).collect())),
1604 ) {
1605 return v;
1606 }
1607 }
1608 Type::TIMESTAMP => {
1609 if let Some(v) = try_array(row.try_get::<_, Option<Vec<NaiveDateTime>>>(idx).map(|v| {
1610 v.map(|a| {
1611 a.into_iter()
1612 .map(|t| json!(t.format("%Y-%m-%dT%H:%M:%S%.f").to_string()))
1613 .collect()
1614 })
1615 })) {
1616 return v;
1617 }
1618 }
1619 Type::TIMESTAMPTZ => {
1620 if let Some(v) = try_array(
1621 row.try_get::<_, Option<Vec<DateTime<FixedOffset>>>>(idx)
1622 .map(|v| v.map(|a| a.into_iter().map(|t| json!(t.to_rfc3339())).collect())),
1623 ) {
1624 return v;
1625 }
1626 }
1627 Type::DATE => {
1628 if let Some(v) = try_array(row.try_get::<_, Option<Vec<NaiveDate>>>(idx).map(|v| {
1629 v.map(|a| {
1630 a.into_iter()
1631 .map(|d| json!(d.format("%Y-%m-%d").to_string()))
1632 .collect()
1633 })
1634 })) {
1635 return v;
1636 }
1637 }
1638 Type::JSON | Type::JSONB => {
1639 if let Some(v) = try_array(row.try_get::<_, Option<Vec<Value>>>(idx)) {
1640 return v;
1641 }
1642 }
1643 Type::NUMERIC => {
1644 if let Some(v) = try_array(
1645 row.try_get::<_, Option<Vec<Decimal>>>(idx)
1646 .map(|v| v.map(|a| a.into_iter().map(|d| json!(d.to_string())).collect())),
1647 ) {
1648 return v;
1649 }
1650 }
1651 Type::MONEY => {
1652 if let Some(v) = try_array(
1653 row.try_get::<_, Option<Vec<i64>>>(idx)
1654 .map(|v| v.map(|a| a.into_iter().map(|m| json!(money_to_string(m))).collect())),
1655 ) {
1656 return v;
1657 }
1658 }
1659 Type::BYTEA => {
1660 if let Some(v) = try_array(
1661 row.try_get::<_, Option<Vec<Vec<u8>>>>(idx)
1662 .map(|v| v.map(|a| a.into_iter().map(|b| json!(BASE64.encode(b))).collect())),
1663 ) {
1664 return v;
1665 }
1666 }
1667 _ => {}
1668 }
1669
1670 cell_to_json_untyped(row, idx, row.columns()[idx].type_())
1671}
1672
1673fn money_to_string(v: i64) -> String {
1675 let sign = if v < 0 { "-" } else { "" };
1676 let abs = v.unsigned_abs();
1677 format!("{}{}.{:04}", sign, abs / 10_000, abs % 10_000)
1678}
1679
1680fn cell_to_json_untyped(row: &tokio_postgres::Row, idx: usize, pg_type: &Type) -> Value {
1682 if let Ok(Some(s)) = row.try_get::<_, Option<String>>(idx) {
1683 return Value::String(s);
1684 }
1685 json!({
1686 "__untyped": true,
1687 "type": pg_type.name()
1688 })
1689}
1690
1691#[cfg(test)]
1692mod tests {
1693 use super::*;
1694 use crate::plan::build_explain_sql;
1695 use nexql_policy::PolicyFilter;
1696 use serde_json::json;
1697
1698 use crate::session::{ConnectionInfo, ToolSession};
1699
1700 fn test_conn() -> ConnectionInfo {
1701 ConnectionInfo {
1702 id: "conn-1".into(),
1703 name: "conn-1".into(),
1704 host: Some("127.0.0.1".into()),
1705 port: Some(5432),
1706 database: Some("appdb".into()),
1707 params: Default::default(),
1708 }
1709 }
1710
1711 #[test]
1712 fn policy_maps_one_to_one() {
1713 let f = PolicyFilter {
1714 allow_schemas: vec!["public".into()],
1715 deny_schemas: vec!["pgboss".into()],
1716 deny_tables: vec!["auth.*".into()],
1717 pii_columns: vec!["public.users.ssn".into()],
1718 };
1719 let q = policy_to_query_filter(&f);
1720 assert_eq!(q.allow_schemas, f.allow_schemas);
1721 assert_eq!(q.deny_schemas, f.deny_schemas);
1722 assert_eq!(q.deny_tables, f.deny_tables);
1723 assert_eq!(q.pii_columns, f.pii_columns);
1724 }
1725
1726 #[test]
1727 fn ok_json_wraps_arrays_for_cursor_structured_content() {
1728 let out = ToolOutcome::ok_json(json!([{ "id": 1 }, { "id": 2 }]));
1729 assert!(!out.is_error);
1730 let s = out.structured.as_ref().unwrap();
1731 assert!(s.is_object(), "structuredContent must be object, got {s}");
1732 assert_eq!(s["rows"].as_array().unwrap().len(), 2);
1733 assert!(out.text.contains("\"rows\""));
1734 }
1735
1736 #[test]
1737 fn ok_json_leaves_objects_unchanged() {
1738 let out = ToolOutcome::ok_json(json!({ "kind": "table", "name": "orders" }));
1739 let s = out.structured.as_ref().unwrap();
1740 assert_eq!(s["kind"], "table");
1741 assert!(s.get("rows").is_none());
1742 }
1743
1744 #[test]
1745 fn router_specs_include_phase4_and_phase9() {
1746 let session = ToolSession::for_tests(vec![test_conn()], PolicyFilter::default(), None);
1747 let router = ToolRouter::with_index_store(session, None);
1748 assert_eq!(router.specs().len(), 41);
1749 let names: Vec<_> = router.specs().iter().map(|s| s.name.as_str()).collect();
1750 assert!(names.contains(&"search_schema"));
1751 assert!(names.contains(&"get_ddl"));
1752 assert!(names.contains(&"explain_analyze"));
1753 assert!(names.contains(&"get_index_status"));
1754 assert!(names.contains(&"list_extensions"));
1755 assert!(names.contains(&"server_settings"));
1756 assert!(names.contains(&"suggest_indexes"));
1757 assert!(names.contains(&"find_unused_indexes"));
1758 assert!(names.contains(&"bloat_report"));
1759 assert!(names.contains(&"find_missing_fks"));
1760 assert!(names.contains(&"export_query"));
1761 assert!(names.contains(&"list_roles"));
1762 assert!(names.contains(&"db_dashboard"));
1763 assert!(names.contains(&"deep_plan_analysis"));
1764 assert!(names.contains(&"execute_sql"));
1765 assert!(names.contains(&"edit_row"));
1766 assert!(names.contains(&"import_data"));
1767 assert!(names.contains(&"apply_ddl"));
1768 assert!(names.contains(&"create_index_concurrently"));
1769 assert!(names.contains(&"run_maintenance"));
1770 assert!(names.contains(&"terminate_query"));
1771 }
1772
1773 #[tokio::test]
1774 async fn write_tools_refuse_read_mode() {
1775 let session = ToolSession::for_tests(vec![test_conn()], PolicyFilter::default(), None);
1776 let router = ToolRouter::with_index_store(session, None);
1777 for tool in [
1778 "execute_sql",
1779 "edit_row",
1780 "import_data",
1781 "apply_ddl",
1782 "create_index_concurrently",
1783 "run_maintenance",
1784 "terminate_query",
1785 ] {
1786 let out = router
1787 .call(tool, json!({ "sql": "SELECT 1", "table": "public.t", "rows": [], "action": "insert", "values": {}, "pid": 1 }))
1788 .await;
1789 assert!(out.is_error, "{tool}: {}", out.text);
1790 assert!(
1791 out.text.contains("write") || out.text.contains("admin"),
1792 "{tool}: {}",
1793 out.text
1794 );
1795 }
1796 }
1797
1798 #[tokio::test]
1799 async fn table_stats_rejects_injection_ref() {
1800 let session = ToolSession::for_tests(vec![test_conn()], PolicyFilter::default(), None);
1801 let router = ToolRouter::with_index_store(session, None);
1802 let out = router
1803 .call("table_stats", json!({ "ref": "public.users; DROP" }))
1804 .await;
1805 assert!(out.is_error, "{}", out.text);
1806 assert!(
1807 out.text.contains("Invalid object reference") || out.text.contains("invalid arguments"),
1808 "expected ref validation error, got: {}",
1809 out.text
1810 );
1811 }
1812
1813 #[test]
1814 fn explain_transaction_path_builds_readonly_sequence() {
1815 let explain = build_explain_sql("SELECT 1", true);
1817 assert!(explain.starts_with("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)"));
1818 assert!(!explain.to_ascii_lowercase().contains("commit"));
1819 let steps = ["BEGIN", "SET TRANSACTION READ ONLY", &explain, "ROLLBACK"];
1820 assert_eq!(steps.len(), 4);
1821 assert_eq!(steps[0], "BEGIN");
1822 assert_eq!(steps[1], "SET TRANSACTION READ ONLY");
1823 assert_eq!(steps[3], "ROLLBACK");
1824 }
1825
1826 #[tokio::test]
1827 async fn missing_index_returns_actionable_error() {
1828 let session = ToolSession::for_tests(vec![test_conn()], PolicyFilter::default(), None);
1829 let router = ToolRouter::with_index_store(session, None);
1830 let out = router
1831 .call("search_schema", json!({ "query": "users" }))
1832 .await;
1833 assert!(out.is_error, "{}", out.text);
1834 assert!(
1835 out.text.contains("nexql-mcp index build"),
1836 "expected actionable hint, got: {}",
1837 out.text
1838 );
1839 }
1840
1841 #[tokio::test]
1842 async fn empty_index_dir_returns_build_hint() {
1843 let tmp = tempfile::TempDir::new().unwrap();
1844 let store = IndexStore::new(tmp.path());
1845 let session = ToolSession::for_tests(
1846 vec![test_conn()],
1847 PolicyFilter::default(),
1848 Some(IndexStore::new(tmp.path())),
1849 );
1850 let router = ToolRouter::with_index_store(session, Some(store));
1851 let out = router
1852 .call("describe_object", json!({ "ref": "public.users" }))
1853 .await;
1854 assert!(out.is_error, "{}", out.text);
1855 assert!(
1856 out.text.contains("nexql-mcp index build"),
1857 "expected build hint, got: {}",
1858 out.text
1859 );
1860 }
1861}