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