Skip to main content

nexql_tools/
exec.rs

1//! Tool dispatch for catalog (Phase 2) + index (Phase 3) + Phase 4 surfaces.
2
3use 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
29/// Default hit cap for `search_schema` (matches TS ToolExecutor).
30const 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    /// Success payload for MCP `structuredContent`.
44    ///
45    /// Cursor (and some other clients) require `structuredContent` to be a JSON
46    /// **object**. Bare arrays are dropped before the model sees them — always
47    /// wrap: `{ "rows": [ ... ] }`.
48    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
68/// Cursor MCP rejects non-object `structuredContent`. Wrap arrays as `{ "rows": … }`.
69fn 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    /// Optional override; when `None`, uses `session.index_store`.
79    index_override: Option<Option<IndexStore>>,
80    /// When true and an embedder is set, `search_schema` fuses via RRF.
81    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    /// Build with an explicit index store (or `None` to force the no-index error path).
98    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    /// Enable semantic RRF fusion for `search_schema` (requires embeddings on disk + embedder).
109    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    /// Filter active tools by requested `ToolProfile`.
120    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    /// Autonomously resolve which connection/database matches a free-text `hint` and/or
272    /// `objectHint`, then switch the session context to it.
273    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        // Index-only this phase — live DB sampling stays Phase 4+.
647        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        // Connect using that profile's params (may differ from active).
689        let client = {
690            // Temporarily use active checkout if same id; else one-shot.
691            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        // Re-validate EXPLAIN wrapper
878        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    /// EXPLAIN ANALYZE executes the query — always wrap in READ ONLY + ROLLBACK.
1111    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        // Always roll back — belt-and-braces on top of default_transaction_read_only.
1127        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        // Prefer schema-index join-graph inferred edges when an index exists.
1328        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        // Normalize single-row sections to objects for agents.
1528        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            // Always object-shaped for Cursor structuredContent (bare arrays are dropped).
1656            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        // Always `{ "rows": [...] }` — truncation flags are extra fields on the object.
1689        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
1712/// Lowercase + collapse to alphanumeric-separated-by-single-spaces, for `fuzzy_score`.
1713fn 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
1728/// Cheap fuzzy match, 0-100: exact > substring > token overlap.
1729fn 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
1791/// Detect SQL NULL for any column type without committing to a concrete `FromSql` type.
1792enum 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
2007/// PostgreSQL `money` is int64 in ten-thousandths of the base currency unit.
2008fn 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
2014/// Last-resort decoding for unknown or composite Postgres types — never silent null for non-null cells.
2015fn 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        // Documented contract: BEGIN → SET TRANSACTION READ ONLY → EXPLAIN → ROLLBACK
2150        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}