Skip to main content

sequel_mcp/mcp/
tools.rs

1//! MCP tool registrations: 19 tools on the shared library services.
2//! `query`/`execute` go through the gate with elicitation approvals; every
3//! other tool maps to a library service with the legacy JSON shape.
4
5use rmcp::handler::server::tool::ToolCallContext;
6use rmcp::handler::server::wrapper::Parameters;
7use rmcp::model::{CallToolResponse, CallToolResult};
8use rmcp::schemars;
9use rmcp::tool;
10use rmcp::tool_router;
11use rmcp::{ServerHandler, tool_handler};
12use serde::Deserialize;
13use serde_json::json;
14
15use crate::app::gate::{self, GateDeps, RunSqlArgs};
16use crate::config::{ConfigStore, Connection, SqliteConnection};
17use crate::policy::model::{PartialPolicy, PolicyPresetName, SqlCategory, TableRuleKey};
18
19use super::{SequelServer, error_tool_result, json_tool_result, text_tool_result};
20
21impl SequelServer {
22    /// Process-shared approval engine: "Allow for session" grants must
23    /// survive across tool calls (a fresh engine per call silently
24    /// voided the documented session scope).
25    fn gate_deps_blocking(&self, sink: Box<dyn gate::ApprovalSink>) -> GateDeps {
26        GateDeps::with_sink_and_approvals(sink, self.ctx.approvals.clone())
27    }
28}
29
30fn resolve_conn(store: &ConfigStore, name: Option<&str>) -> Result<Option<Connection>, String> {
31    store
32        .load()
33        .map(|cfg| cfg.resolve(name).cloned())
34        .map_err(|e| e.to_string())
35}
36
37#[derive(Debug, Deserialize, schemars::JsonSchema)]
38pub struct EmptyParams {}
39
40#[derive(Debug, Deserialize, schemars::JsonSchema)]
41pub struct SqlParams {
42    /// Configured connection name. Omit to use the default connection.
43    #[serde(default)]
44    pub connection: Option<String>,
45    /// Single SQL statement (multi-statement input rejected).
46    pub sql: String,
47    /// Override default database/schema.
48    #[serde(default)]
49    pub database: Option<String>,
50}
51
52#[derive(Debug, Deserialize, schemars::JsonSchema)]
53pub struct ConnectionRef {
54    #[serde(default)]
55    pub connection: Option<String>,
56}
57
58#[derive(Debug, Deserialize, schemars::JsonSchema)]
59pub struct AddSqliteParams {
60    pub name: String,
61    /// SQLite database file path. `~/` is expanded at execution time.
62    pub path: String,
63    /// SQLite schema name used for policy scope and metadata lookups.
64    #[serde(default)]
65    pub database: Option<String>,
66    #[serde(default)]
67    pub policy_preset: Option<String>,
68}
69
70#[derive(Debug, Deserialize, schemars::JsonSchema)]
71pub struct AddConnectionParams {
72    pub name: String,
73    pub host: String,
74    #[serde(default)]
75    pub port: Option<u16>,
76    pub user: String,
77    #[serde(default)]
78    pub database: Option<String>,
79    #[serde(default)]
80    pub ssl: Option<bool>,
81    #[serde(default)]
82    pub policy_preset: Option<String>,
83    #[serde(default)]
84    pub ssh_host: Option<String>,
85    #[serde(default)]
86    pub ssh_port: Option<u16>,
87    #[serde(default)]
88    pub ssh_user: Option<String>,
89    #[serde(default)]
90    pub ssh_key_path: Option<String>,
91    /// Docker container name (validated `^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`).
92    #[serde(default)]
93    pub ssh_docker_container: Option<String>,
94    /// `nc` (default), `ncat`, or `socat`.
95    #[serde(default)]
96    pub ssh_docker_bridge_tool: Option<String>,
97    /// `lenient` (default) or `strict`.
98    #[serde(default)]
99    pub ssh_host_key_policy: Option<String>,
100    #[serde(default)]
101    pub ssh_known_hosts_path: Option<String>,
102    #[serde(default)]
103    pub ssl_server_name: Option<String>,
104    /// PEM/DER file of a private CA the server certificate is verified
105    /// against (merged with the system roots).
106    #[serde(default)]
107    pub ssl_ca_path: Option<String>,
108}
109
110#[derive(Debug, Deserialize, schemars::JsonSchema)]
111pub struct RemoveParams {
112    pub name: String,
113}
114
115#[derive(Debug, Deserialize, schemars::JsonSchema)]
116pub struct SetDefaultParams {
117    /// Connection name, or empty string to clear the default.
118    pub name: String,
119}
120
121#[derive(Debug, Deserialize, schemars::JsonSchema)]
122pub struct SelectDatabaseParams {
123    #[serde(default)]
124    pub connection: Option<String>,
125    pub database: String,
126}
127
128#[derive(Debug, Deserialize, schemars::JsonSchema)]
129pub struct DescribeParams {
130    #[serde(default)]
131    pub connection: Option<String>,
132    #[serde(default)]
133    pub database: Option<String>,
134    pub table: String,
135}
136
137#[derive(Debug, Deserialize, schemars::JsonSchema)]
138pub struct PolicyParams {
139    pub name: String,
140    pub policy: PartialPolicy,
141}
142
143#[derive(Debug, Deserialize, schemars::JsonSchema)]
144pub struct DatabasePolicyParams {
145    #[serde(default)]
146    pub connection: Option<String>,
147    pub database: String,
148    pub policy: PartialPolicy,
149}
150
151#[derive(Debug, Deserialize, schemars::JsonSchema)]
152pub struct DatabaseOnlyParams {
153    #[serde(default)]
154    pub connection: Option<String>,
155    pub database: String,
156}
157
158#[derive(Debug, Deserialize, schemars::JsonSchema)]
159pub struct TablePolicyParams {
160    #[serde(default)]
161    pub connection: Option<String>,
162    /// Table key: `database.table` for exact rules or `database.*` wildcard.
163    pub table: String,
164    pub policy: PartialPolicy,
165}
166
167#[derive(Debug, Deserialize, schemars::JsonSchema)]
168pub struct TableOnlyParams {
169    #[serde(default)]
170    pub connection: Option<String>,
171    pub table: String,
172}
173
174#[derive(Debug, Deserialize, schemars::JsonSchema)]
175pub struct ExplainParams {
176    #[serde(default)]
177    pub connection: Option<String>,
178    pub sql: String,
179    #[serde(default)]
180    pub database: Option<String>,
181}
182
183#[derive(Debug, Deserialize, schemars::JsonSchema)]
184pub struct AuditSearchParams {
185    #[serde(default)]
186    pub connection: Option<String>,
187    #[serde(default)]
188    pub category: Option<String>,
189    #[serde(default)]
190    pub outcome: Option<String>,
191    #[serde(default)]
192    pub since_iso: Option<String>,
193    #[serde(default)]
194    pub until_iso: Option<String>,
195    #[serde(default)]
196    pub limit: Option<u32>,
197}
198
199#[derive(Debug, Deserialize, schemars::JsonSchema)]
200pub struct BackupListParams {
201    #[serde(default)]
202    pub connection: Option<String>,
203    #[serde(default)]
204    pub limit: Option<u32>,
205}
206
207#[derive(Debug, Deserialize, schemars::JsonSchema)]
208pub struct RestoreParams {
209    /// Backup id from list_backups.
210    pub backup_id: i64,
211    /// Default true: inspect the plan without executing anything.
212    #[serde(default = "default_true")]
213    pub dry_run: bool,
214}
215
216fn default_true() -> bool {
217    true
218}
219
220#[derive(Debug, Deserialize, schemars::JsonSchema)]
221pub struct AuditCleanupParams {
222    #[serde(default = "default_true")]
223    pub dry_run: bool,
224}
225
226#[derive(Debug, Deserialize, schemars::JsonSchema)]
227pub struct RetentionParams {
228    #[serde(default)]
229    pub retention_days_by_category: Option<RetentionPartial>,
230    #[serde(default)]
231    pub backup_days: Option<u32>,
232    #[serde(default)]
233    pub audit_max_mb: Option<u32>,
234    #[serde(default)]
235    pub backup_max_mb: Option<u32>,
236    #[serde(default)]
237    pub auto_cleanup_hours: Option<u32>,
238    #[serde(default)]
239    pub redact_sql_in_log: Option<bool>,
240    #[serde(default)]
241    pub tamper_evident_chain: Option<bool>,
242}
243
244#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
245pub struct RetentionPartial {
246    #[serde(default)]
247    pub read: Option<u32>,
248    #[serde(default)]
249    pub write: Option<u32>,
250    #[serde(default)]
251    pub ddl: Option<u32>,
252    #[serde(default)]
253    pub admin: Option<u32>,
254    #[serde(default)]
255    pub tx_ctrl: Option<u32>,
256}
257
258#[derive(Debug, Deserialize, schemars::JsonSchema)]
259pub struct HistorySearchParams {
260    #[serde(default)]
261    pub since_iso: Option<String>,
262    #[serde(default)]
263    pub until_iso: Option<String>,
264    #[serde(default)]
265    pub search: Option<String>,
266    #[serde(default)]
267    pub connection: Option<String>,
268    #[serde(default)]
269    pub source: Option<String>,
270    #[serde(default)]
271    pub limit: Option<u32>,
272}
273
274#[derive(Debug, Deserialize, schemars::JsonSchema)]
275pub struct SequelAceHistoryParams {
276    #[serde(default)]
277    pub since_iso: Option<String>,
278    #[serde(default)]
279    pub search: Option<String>,
280    #[serde(default)]
281    pub limit: Option<u32>,
282}
283
284#[derive(Debug, Deserialize, schemars::JsonSchema)]
285pub struct ImportParams {
286    /// Copy passwords from the Sequel Ace Keychain (macOS may prompt).
287    #[serde(default = "default_true")]
288    pub copy_passwords: bool,
289}
290
291fn parse_preset(s: &Option<String>) -> Result<PolicyPresetName, String> {
292    match s.as_deref() {
293        None => Ok(PolicyPresetName::ReadOnly),
294        Some(name) => {
295            PolicyPresetName::parse(name).ok_or_else(|| format!("unknown policy preset {name:?}"))
296        }
297    }
298}
299
300#[tool_router]
301impl SequelServer {
302    #[tool(
303        name = "query",
304        title = "Run a read-only SQL query",
305        description = "Run a single read-only SQL statement (SELECT/SHOW/DESCRIBE/EXPLAIN, plus read-only SQLite PRAGMA). SQLite opens a read-only file handle.",
306        annotations(read_only_hint = true, idempotent_hint = true, open_world_hint = false)
307    )]
308    fn query(&self, Parameters(p): Parameters<SqlParams>) -> CallToolResult {
309        // Hand-routed in call_tool for elicitation; this entry exists for
310        // discovery and direct dispatch without interactive approvals.
311        self.run_sql_blocking(
312            RunSqlArgs {
313                connection: p.connection,
314                sql: p.sql,
315                database: p.database,
316                expected_ddl_targets: None,
317            },
318            true,
319        )
320    }
321
322    #[tool(
323        name = "execute",
324        title = "Execute a write/DDL/admin SQL statement",
325        description = "Run a non-read SQL statement (INSERT/UPDATE/DELETE/DDL/admin). Subject to the two-layer policy: table-rule elevation still requires user confirmation.",
326        annotations(destructive_hint = true, open_world_hint = false)
327    )]
328    fn execute(&self, Parameters(p): Parameters<SqlParams>) -> CallToolResult {
329        self.run_sql_blocking(
330            RunSqlArgs {
331                connection: p.connection,
332                sql: p.sql,
333                database: p.database,
334                expected_ddl_targets: None,
335            },
336            false,
337        )
338    }
339
340    #[tool(
341        name = "list_connections",
342        title = "List configured connections",
343        description = "Return all connections configured in the local config (no passwords).",
344        annotations(read_only_hint = true, idempotent_hint = true, open_world_hint = false)
345    )]
346    fn list_connections(&self, _p: Parameters<EmptyParams>) -> CallToolResult {
347        let cfg = match self.ctx.config.load() {
348            Ok(c) => c,
349            Err(e) => return error_tool_result(format!("config load failed: {e}")),
350        };
351        let mut items = Vec::new();
352        for c in &cfg.connections {
353            let has_password = c.is_mysql()
354                && self
355                    .ctx
356                    .secrets
357                    .has_password(c.name(), mysql_user(c).as_deref().unwrap_or(""));
358            items.push(json!({
359                "name": c.name(),
360                "driver": if c.is_mysql() { "mysql" } else { "sqlite" },
361                "database": c.database(),
362                "policy": c.policy(),
363                "tablePolicies": c.table_policies(),
364                "isDefault": Some(c.name()) == cfg.default_connection.as_deref(),
365                "hasStoredPassword": has_password,
366            }));
367        }
368        json_tool_result(json!({
369            "defaultConnection": cfg.default_connection,
370            "connections": items,
371        }))
372    }
373
374    #[tool(
375        name = "add_connection",
376        title = "Add or update a MySQL/MariaDB connection",
377        description = "Persist a MySQL/MariaDB connection. The password is captured via elicitation and stored in the macOS Keychain; it never appears in tool arguments or logs.",
378        annotations(idempotent_hint = true, open_world_hint = false)
379    )]
380    fn add_connection(&self, _p: Parameters<AddConnectionParams>) -> CallToolResult {
381        // Hand-routed in call_tool: password capture needs the session
382        // peer for the elicitation round trip.
383        error_tool_result(
384            "add_connection is hand-routed for password elicitation; connect through the full tool call path",
385        )
386    }
387
388    #[tool(
389        name = "add_sqlite_connection",
390        title = "Add or update a SQLite connection",
391        description = "Persist a SQLite database file connection. Stores only the local file path and policy; no password or Keychain entry is used.",
392        annotations(idempotent_hint = true, open_world_hint = false)
393    )]
394    fn add_sqlite_connection(&self, Parameters(p): Parameters<AddSqliteParams>) -> CallToolResult {
395        let preset = match parse_preset(&p.policy_preset) {
396            Ok(v) => v,
397            Err(e) => return error_tool_result(e),
398        };
399        let mut sc = SqliteConnection {
400            name: p.name.clone(),
401            path: p.path.clone(),
402            database: p.database.clone().unwrap_or_else(|| "main".into()),
403            ..SqliteConnection::default()
404        };
405        sc.policy = crate::policy::model::policy_from_preset(preset);
406        let conn = Connection::Sqlite(sc);
407        if let Err(e) = conn.validate() {
408            return error_tool_result(format!("{e}"));
409        }
410        let cfg = match self.ctx.config.load() {
411            Ok(c) => c,
412            Err(e) => return error_tool_result(format!("{e}")),
413        };
414        let name = p.name.clone();
415        let preset_name = preset.as_str().to_string();
416        match self.ctx.config.update(cfg.revision, move |c| {
417            upsert(c, conn);
418            Ok(())
419        }) {
420            Ok(()) => text_tool_result(format!(
421                "Saved SQLite connection \"{name}\" with policy preset \"{preset_name}\". No password was stored."
422            )),
423            Err(e) => error_tool_result(format!("{e}")),
424        }
425    }
426
427    #[tool(
428        name = "remove_connection",
429        title = "Remove a connection",
430        description = "Delete the connection from config and delete any associated MySQL/MariaDB Keychain password.",
431        annotations(
432            destructive_hint = true,
433            idempotent_hint = true,
434            open_world_hint = false
435        )
436    )]
437    fn remove_connection(&self, Parameters(p): Parameters<RemoveParams>) -> CallToolResult {
438        let conn = match resolve_conn(&self.ctx.config, Some(&p.name)) {
439            Ok(Some(c)) => c,
440            Ok(None) => return error_tool_result(format!("Connection {:?} not found", p.name)),
441            Err(e) => return error_tool_result(e),
442        };
443        if let Connection::Mysql(m) = &conn {
444            let _ = self.ctx.secrets.delete_password(&m.name, &m.user);
445            if let Some(ssh) = &m.ssh {
446                let _ = self
447                    .ctx
448                    .secrets
449                    .delete_password(&format!("{}::ssh", m.name), &ssh.user);
450            }
451        }
452        let cfg = match self.ctx.config.load() {
453            Ok(c) => c,
454            Err(e) => return error_tool_result(format!("{e}")),
455        };
456        let name = p.name.clone();
457        match self.ctx.config.update(cfg.revision, move |c| {
458            c.connections.retain(|x| x.name() != name);
459            if c.default_connection.as_deref() == Some(name.as_str()) {
460                c.default_connection = None;
461            }
462            Ok(())
463        }) {
464            Ok(()) => text_tool_result(format!("Removed connection \"{}\".", p.name)),
465            Err(e) => error_tool_result(format!("{e}")),
466        }
467    }
468
469    #[tool(
470        name = "set_default_connection",
471        title = "Set the default connection",
472        description = "Mark a saved connection as the default. Subsequent query/execute/describe_table/list_databases calls without an explicit \"connection\" arg use it. Pass an empty string to clear.",
473        annotations(idempotent_hint = true, open_world_hint = false)
474    )]
475    fn set_default_connection(
476        &self,
477        Parameters(p): Parameters<SetDefaultParams>,
478    ) -> CallToolResult {
479        let cfg = match self.ctx.config.load() {
480            Ok(c) => c,
481            Err(e) => return error_tool_result(format!("{e}")),
482        };
483        if p.name.is_empty() {
484            let _ = self.ctx.config.update(cfg.revision, |c| {
485                c.default_connection = None;
486                Ok(())
487            });
488            return text_tool_result("Default connection cleared.");
489        }
490        let exists = cfg.connections.iter().any(|c| c.name() == p.name);
491        if !exists {
492            return error_tool_result(format!("Connection \"{}\" not found", p.name));
493        }
494        let name = p.name.clone();
495        match self.ctx.config.update(cfg.revision, move |c| {
496            c.default_connection = Some(name.clone());
497            Ok(())
498        }) {
499            Ok(()) => text_tool_result(format!("Default connection is now \"{}\".", p.name)),
500            Err(e) => error_tool_result(format!("{e}")),
501        }
502    }
503
504    #[tool(
505        name = "get_default_connection",
506        title = "Get the default connection",
507        description = "Return the connection name currently used when \"connection\" arg is omitted.",
508        annotations(read_only_hint = true, idempotent_hint = true, open_world_hint = false)
509    )]
510    fn get_default_connection(&self, _p: Parameters<EmptyParams>) -> CallToolResult {
511        let cfg = match self.ctx.config.load() {
512            Ok(c) => c,
513            Err(e) => return error_tool_result(format!("{e}")),
514        };
515        json_tool_result(json!({ "defaultConnection": cfg.default_connection }))
516    }
517
518    #[tool(
519        name = "select_database",
520        title = "Set the default database on a connection",
521        description = "Update a saved connection so that subsequent query/execute calls default to this database when no per-call override is supplied.",
522        annotations(idempotent_hint = true, open_world_hint = false)
523    )]
524    fn select_database(&self, Parameters(p): Parameters<SelectDatabaseParams>) -> CallToolResult {
525        if !p
526            .database
527            .chars()
528            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
529        {
530            return error_tool_result("database must be identifier-safe");
531        }
532        let mut conn = match resolve_conn(&self.ctx.config, p.connection.as_deref()) {
533            Ok(Some(c)) => c,
534            Ok(None) => {
535                return error_tool_result(gate::no_connection_message(p.connection.as_deref()));
536            }
537            Err(e) => return error_tool_result(e),
538        };
539        match &mut conn {
540            Connection::Mysql(m) => m.database = Some(p.database.clone()),
541            Connection::Sqlite(s) => s.database = p.database.clone(),
542        }
543        let cfg = match self.ctx.config.load() {
544            Ok(c) => c,
545            Err(e) => return error_tool_result(format!("{e}")),
546        };
547        let name = conn.name().to_string();
548        let db = p.database.clone();
549        let name_for_msg = name.clone();
550        match self.ctx.config.update(cfg.revision, move |c| {
551            upsert(c, conn);
552            let _ = (&name, &db);
553            Ok(())
554        }) {
555            Ok(()) => text_tool_result(format!(
556                "Default database for \"{}\" set to \"{}\". Per-call database overrides still take precedence.",
557                name_for_msg, p.database
558            )),
559            Err(e) => error_tool_result(format!("{e}")),
560        }
561    }
562
563    #[tool(
564        name = "set_policy",
565        title = "Update a connection policy",
566        description = "Change the action set (read|write|ddl|admin|txCtrl → allow|confirm|deny) and limits for an existing connection baseline.",
567        annotations(idempotent_hint = true, open_world_hint = false)
568    )]
569    fn set_policy(&self, Parameters(p): Parameters<PolicyParams>) -> CallToolResult {
570        let mut conn = match resolve_conn(&self.ctx.config, Some(&p.name)) {
571            Ok(Some(c)) => c,
572            Ok(None) => return error_tool_result(format!("Connection {:?} not found", p.name)),
573            Err(e) => return error_tool_result(e),
574        };
575        match &mut conn {
576            Connection::Mysql(m) => {
577                m.policy = m.policy.merged_with(&p.policy);
578            }
579            Connection::Sqlite(s) => {
580                s.policy = s.policy.merged_with(&p.policy);
581            }
582        }
583        if let Err(e) = conn.validate() {
584            return error_tool_result(format!("{e}"));
585        }
586        let policy = match &conn {
587            Connection::Mysql(m) => m.policy.clone(),
588            Connection::Sqlite(s) => s.policy.clone(),
589        };
590        let cfg = match self.ctx.config.load() {
591            Ok(c) => c,
592            Err(e) => return error_tool_result(format!("{e}")),
593        };
594        match self.ctx.config.update(cfg.revision, move |c| {
595            upsert(c, conn);
596            Ok(())
597        }) {
598            Ok(()) => json_tool_result(json!({ "name": p.name, "policy": policy })),
599            Err(e) => error_tool_result(format!("{e}")),
600        }
601    }
602
603    #[tool(
604        name = "set_table_policy",
605        title = "Set a table policy rule",
606        description = "Set an exact (`database.table`) or wildcard (`database.*`) table rule. Exact rules take precedence over wildcards. A rule that elevates what the baseline denies still requires confirmation per statement.",
607        annotations(idempotent_hint = true, open_world_hint = false)
608    )]
609    fn set_table_policy(&self, Parameters(p): Parameters<TablePolicyParams>) -> CallToolResult {
610        let key = match TableRuleKey::parse(&p.table) {
611            Some(k) => k,
612            None => {
613                return error_tool_result(format!(
614                    "invalid table key {:?} (expected database.table or database.*)",
615                    p.table
616                ));
617            }
618        };
619        // Table rules previously bypassed all bounds validation — a rule
620        // could widen rowCap/stmtTimeoutMs beyond what any baseline
621        // policy allows (review finding).
622        if let Err(e) = p.policy.validate() {
623            return error_tool_result(format!("table policy invalid: {e}"));
624        }
625        let mut conn = match resolve_conn(&self.ctx.config, p.connection.as_deref()) {
626            Ok(Some(c)) => c,
627            Ok(None) => {
628                return error_tool_result(gate::no_connection_message(p.connection.as_deref()));
629            }
630            Err(e) => return error_tool_result(e),
631        };
632        let rendered = key.render();
633        let partial = p.policy.clone();
634        match &mut conn {
635            Connection::Mysql(m) => {
636                m.table_policies.insert(key, partial);
637            }
638            Connection::Sqlite(s) => {
639                s.table_policies.insert(key, partial);
640            }
641        }
642        let cfg = match self.ctx.config.load() {
643            Ok(c) => c,
644            Err(e) => return error_tool_result(format!("{e}")),
645        };
646        let name = conn.name().to_string();
647        match self.ctx.config.update(cfg.revision, move |c| {
648            upsert(c, conn);
649            Ok(())
650        }) {
651            Ok(()) => json_tool_result(json!({
652                "connection": name,
653                "table": rendered,
654                "policy": p.policy,
655            })),
656            Err(e) => error_tool_result(format!("{e}")),
657        }
658    }
659
660    #[tool(
661        name = "clear_table_policy",
662        title = "Clear a table policy rule",
663        description = "Remove an exact or wildcard table rule; the connection baseline applies again.",
664        annotations(idempotent_hint = true, open_world_hint = false)
665    )]
666    fn clear_table_policy(&self, Parameters(p): Parameters<TableOnlyParams>) -> CallToolResult {
667        let key = match TableRuleKey::parse(&p.table) {
668            Some(k) => k,
669            None => return error_tool_result(format!("invalid table key {:?}", p.table)),
670        };
671        let mut conn = match resolve_conn(&self.ctx.config, p.connection.as_deref()) {
672            Ok(Some(c)) => c,
673            Ok(None) => {
674                return error_tool_result(gate::no_connection_message(p.connection.as_deref()));
675            }
676            Err(e) => return error_tool_result(e),
677        };
678        let existed = match &mut conn {
679            Connection::Mysql(m) => m.table_policies.remove(&key).is_some(),
680            Connection::Sqlite(s) => s.table_policies.remove(&key).is_some(),
681        };
682        if !existed {
683            return text_tool_result(format!("No rule exists for {}.", p.table));
684        }
685        let cfg = match self.ctx.config.load() {
686            Ok(c) => c,
687            Err(e) => return error_tool_result(format!("{e}")),
688        };
689        match self.ctx.config.update(cfg.revision, move |c| {
690            upsert(c, conn);
691            Ok(())
692        }) {
693            Ok(()) => text_tool_result(format!("Cleared table rule {}.", p.table)),
694            Err(e) => error_tool_result(format!("{e}")),
695        }
696    }
697
698    #[tool(
699        name = "list_table_policies",
700        title = "List table policy rules",
701        description = "Show the connection baseline plus every exact and wildcard table rule.",
702        annotations(read_only_hint = true, idempotent_hint = true, open_world_hint = false)
703    )]
704    fn list_table_policies(&self, Parameters(p): Parameters<ConnectionRef>) -> CallToolResult {
705        let conn = match resolve_conn(&self.ctx.config, p.connection.as_deref()) {
706            Ok(Some(c)) => c,
707            Ok(None) => {
708                return error_tool_result(gate::no_connection_message(p.connection.as_deref()));
709            }
710            Err(e) => return error_tool_result(e),
711        };
712        json_tool_result(json!({
713            "connection": conn.name(),
714            "baseline": conn.policy(),
715            "tablePolicies": conn.table_policies(),
716        }))
717    }
718
719    #[tool(
720        name = "explain_policy",
721        title = "Explain effective policy for a statement",
722        description = "Classify a statement and show the per-table resolution (baseline vs rule, strictest-wins, elevation flags) without executing it.",
723        annotations(read_only_hint = true, idempotent_hint = true, open_world_hint = false)
724    )]
725    fn explain_policy(&self, Parameters(p): Parameters<ExplainParams>) -> CallToolResult {
726        let conn = match resolve_conn(&self.ctx.config, p.connection.as_deref()) {
727            Ok(Some(c)) => c,
728            Ok(None) => {
729                return error_tool_result(gate::no_connection_message(p.connection.as_deref()));
730            }
731            Err(e) => return error_tool_result(e),
732        };
733        let dialect = if conn.is_mysql() {
734            crate::policy::classifier::Dialect::MySql
735        } else {
736            crate::policy::classifier::Dialect::SQLite
737        };
738        let classified = match crate::policy::classifier::classify_statement(&p.sql, dialect) {
739            Ok(c) => c,
740            Err(e) => {
741                return error_tool_result(format!("Cannot classify statement: {}", e.message()));
742            }
743        };
744        let fallback = p
745            .database
746            .clone()
747            .or_else(|| conn.database().map(str::to_string));
748        let r = crate::policy::resolver::resolve(&conn, &classified, fallback.as_deref());
749        let contributions: Vec<serde_json::Value> = r
750            .contributions
751            .iter()
752            .map(|c| {
753                json!({
754                    "table": format!("{}.{}", c.table.database, c.table.table),
755                    "kind": c.kind,
756                    "category": c.category.as_str(),
757                    "action": c.action.as_str(),
758                    "baselineAction": c.baseline_action.as_str(),
759                    "rule": c.rule,
760                    "elevated": c.elevated,
761                })
762            })
763            .collect();
764        json_tool_result(json!({
765            "category": classified.category.as_str(),
766            "astType": classified.ast_type,
767            "action": r.action.as_str(),
768            "elevated": r.elevated,
769            "denyReason": r.deny_reason.as_ref().map(|d| format!("{d:?}")),
770            "contributions": contributions,
771            "contributingDatabases": r.contributing_databases,
772            "flags": {
773                "lockingRead": classified.locking_read,
774                "fileIo": classified.file_io,
775                "executesWrapped": classified.executes_wrapped,
776            },
777        }))
778    }
779
780    #[tool(
781        name = "set_database_policy",
782        title = "Set per-database policy override (compatibility)",
783        description = "Compatibility wrapper: operates on the wildcard table rule `<database>.*`. Prefer set_table_policy.",
784        annotations(idempotent_hint = true, open_world_hint = false)
785    )]
786    fn set_database_policy(
787        &self,
788        Parameters(p): Parameters<DatabasePolicyParams>,
789    ) -> CallToolResult {
790        self.set_table_policy(Parameters(TablePolicyParams {
791            connection: p.connection.clone(),
792            table: format!("{}.*", p.database),
793            policy: p.policy.clone(),
794        }))
795    }
796
797    #[tool(
798        name = "clear_database_policy",
799        title = "Clear per-database policy override (compatibility)",
800        description = "Compatibility wrapper: clears the wildcard table rule `<database>.*`.",
801        annotations(idempotent_hint = true, open_world_hint = false)
802    )]
803    fn clear_database_policy(
804        &self,
805        Parameters(p): Parameters<DatabaseOnlyParams>,
806    ) -> CallToolResult {
807        self.clear_table_policy(Parameters(TableOnlyParams {
808            connection: p.connection.clone(),
809            table: format!("{}.*", p.database),
810        }))
811    }
812
813    #[tool(
814        name = "list_database_policies",
815        title = "List per-database policy overrides (compatibility)",
816        description = "Compatibility wrapper: shows baseline + wildcard (`db.*`) rules rendered as per-database overrides.",
817        annotations(read_only_hint = true, idempotent_hint = true, open_world_hint = false)
818    )]
819    fn list_database_policies(&self, Parameters(p): Parameters<ConnectionRef>) -> CallToolResult {
820        let conn = match resolve_conn(&self.ctx.config, p.connection.as_deref()) {
821            Ok(Some(c)) => c,
822            Ok(None) => {
823                return error_tool_result(gate::no_connection_message(p.connection.as_deref()));
824            }
825            Err(e) => return error_tool_result(e),
826        };
827        let mut overrides = serde_json::Map::new();
828        for (k, v) in conn.table_policies() {
829            if let TableRuleKey::Wildcard { database } = k {
830                overrides.insert(
831                    database.clone(),
832                    serde_json::to_value(v).unwrap_or_default(),
833                );
834            }
835        }
836        json_tool_result(json!({
837            "connection": conn.name(),
838            "baseline": conn.policy(),
839            "overrides": overrides,
840        }))
841    }
842
843    #[tool(
844        name = "describe_table",
845        title = "Describe a table",
846        description = "Describe a table. Uses DESCRIBE on MySQL/MariaDB and PRAGMA table_info on SQLite. Always read-only.",
847        annotations(read_only_hint = true, idempotent_hint = true, open_world_hint = false)
848    )]
849    fn describe_table(&self, Parameters(p): Parameters<DescribeParams>) -> CallToolResult {
850        let conn = match resolve_conn(&self.ctx.config, p.connection.as_deref()) {
851            Ok(Some(c)) => c,
852            Ok(None) => {
853                return error_tool_result(gate::no_connection_message(p.connection.as_deref()));
854            }
855            Err(e) => return error_tool_result(e),
856        };
857        let q = format!("`{}`", p.table.replace('`', "``"));
858        let sql = if conn.is_mysql() {
859            match &p.database {
860                Some(db) => format!(
861                    "DESCRIBE `{}`.`{}`",
862                    db.replace('`', "``"),
863                    p.table.replace('`', "``")
864                ),
865                None => format!("DESCRIBE {q}"),
866            }
867        } else {
868            let schema = p
869                .database
870                .as_deref()
871                .or(conn.database().map(|_| ""))
872                .unwrap_or("main");
873            let _ = schema;
874            format!(
875                "PRAGMA `{}`.table_info('{}')",
876                p.database
877                    .clone()
878                    .unwrap_or_else(|| "main".into())
879                    .replace('`', "``"),
880                p.table.replace('\'', "''")
881            )
882        };
883        self.run_sql_blocking(
884            RunSqlArgs {
885                connection: p.connection.clone(),
886                sql,
887                database: p.database.clone(),
888                expected_ddl_targets: None,
889            },
890            true,
891        )
892    }
893
894    #[tool(
895        name = "list_databases",
896        title = "List databases",
897        description = "SHOW DATABASES on MySQL/MariaDB or PRAGMA database_list on SQLite.",
898        annotations(read_only_hint = true, idempotent_hint = true, open_world_hint = false)
899    )]
900    fn list_databases(&self, Parameters(p): Parameters<ConnectionRef>) -> CallToolResult {
901        let conn = match resolve_conn(&self.ctx.config, p.connection.as_deref()) {
902            Ok(Some(c)) => c,
903            Ok(None) => {
904                return error_tool_result(gate::no_connection_message(p.connection.as_deref()));
905            }
906            Err(e) => return error_tool_result(e),
907        };
908        let sql = if conn.is_mysql() {
909            "SHOW DATABASES".to_string()
910        } else {
911            "PRAGMA database_list".to_string()
912        };
913        self.run_sql_blocking(
914            RunSqlArgs {
915                connection: p.connection.clone(),
916                sql,
917                database: None,
918                expected_ddl_targets: None,
919            },
920            true,
921        )
922    }
923
924    #[tool(
925        name = "audit_search",
926        title = "Search audit log",
927        description = "Query the local audit-log SQLite. Returns redacted SQL by default. Includes connection, decision, outcome, duration, and backup_id.",
928        annotations(read_only_hint = true, idempotent_hint = true, open_world_hint = false)
929    )]
930    fn audit_search(&self, Parameters(p): Parameters<AuditSearchParams>) -> CallToolResult {
931        let filters = crate::audit::AuditSearchFilters {
932            since: p.since_iso.clone(),
933            until: p.until_iso.clone(),
934            connection: p.connection.clone(),
935            category: p.category.as_deref().and_then(SqlCategory::parse),
936            outcome: p.outcome.clone(),
937            limit: p.limit.unwrap_or(200),
938        };
939        match crate::audit::search_audit_log(&self.ctx.audit, &filters) {
940            Ok(rows) => json_tool_result(json!({ "count": rows.len(), "rows": rows })),
941            Err(e) => error_tool_result(format!("audit search failed: {e}")),
942        }
943    }
944
945    #[tool(
946        name = "list_backups",
947        title = "List recent row/schema backups",
948        description = "Show recent pre-mutation backups taken before UPDATE/DELETE/TRUNCATE/DROP/ALTER. Each row links to a backup_id.",
949        annotations(read_only_hint = true, idempotent_hint = true, open_world_hint = false)
950    )]
951    fn list_backups(&self, Parameters(p): Parameters<BackupListParams>) -> CallToolResult {
952        match crate::backup::list_backups(
953            &self.ctx.audit,
954            p.connection.as_deref(),
955            p.limit.unwrap_or(50),
956        ) {
957            Ok(rows) => json_tool_result(json!({ "count": rows.len(), "rows": rows })),
958            Err(e) => error_tool_result(format!("backup list failed: {e}")),
959        }
960    }
961
962    #[tool(
963        name = "restore_backup",
964        title = "Restore from a pre-mutation backup",
965        description = "Replay backup #N into the originating connection. Generates dialect-specific upserts for row backups and CREATE TABLE for schema backups. Subject to the same policy gate (counts as a write). Pass dryRun=true to inspect the plan first.",
966        annotations(
967            destructive_hint = true,
968            idempotent_hint = false,
969            open_world_hint = false
970        )
971    )]
972    fn restore_backup(&self, _p: Parameters<RestoreParams>) -> CallToolResult {
973        // Hand-routed in call_tool for confirm-gated execution; this
974        // entry exists for discovery and dry-run dispatch without
975        // interactive approvals.
976        error_tool_result(
977            "restore_backup is hand-routed for confirmation; pass dryRun=true for the plan",
978        )
979    }
980
981    #[tool(
982        name = "audit_cleanup",
983        title = "Clean up audit log + old backups",
984        description = "Prune audit entries older than retention.auditDays and backups older than retention.backupDays. Hard size caps trigger an additional 20% trim. VACUUMs the file. Pass dryRun=true to preview.",
985        annotations(
986            destructive_hint = true,
987            idempotent_hint = true,
988            open_world_hint = false
989        )
990    )]
991    fn audit_cleanup(&self, Parameters(p): Parameters<AuditCleanupParams>) -> CallToolResult {
992        let cfg = match self.ctx.config.load() {
993            Ok(c) => c,
994            Err(e) => return error_tool_result(format!("config load failed: {e}")),
995        };
996        let r = crate::audit::retention::cleanup_audit(&self.ctx.audit, &cfg.retention, p.dry_run);
997        json_tool_result(json!({
998            "auditDeleted": r.audit_deleted,
999            "auditDeletedByCategory": serde_json::Map::from_iter(
1000                r.audit_deleted_by_category
1001                    .iter()
1002                    .map(|(k, v)| (k.to_string(), serde_json::json!(v))),
1003            ),
1004            "backupDeleted": r.backup_deleted,
1005            "bytesReclaimed": r.bytes_reclaimed,
1006            "ranAt": r.ran_at,
1007            "dryRun": p.dry_run,
1008        }))
1009    }
1010
1011    #[tool(
1012        name = "set_retention",
1013        title = "Update retention / cleanup config",
1014        description = "Configure per-category retention (read=7, write=30, ddl=90, admin=180, txCtrl=7 by default), backup retention, hard size caps, and how often auto-cleanup runs on server boot. Pass any subset; missing fields keep current values.",
1015        annotations(idempotent_hint = true, open_world_hint = false)
1016    )]
1017    fn set_retention(&self, Parameters(p): Parameters<RetentionParams>) -> CallToolResult {
1018        let cfg = match self.ctx.config.load() {
1019            Ok(c) => c,
1020            Err(e) => return error_tool_result(format!("config load failed: {e}")),
1021        };
1022        let mut next = cfg.retention.clone();
1023        if let Some(days) = p.retention_days_by_category {
1024            let d = &mut next.retention_days_by_category;
1025            if let Some(v) = days.read {
1026                d.read = v;
1027            }
1028            if let Some(v) = days.write {
1029                d.write = v;
1030            }
1031            if let Some(v) = days.ddl {
1032                d.ddl = v;
1033            }
1034            if let Some(v) = days.admin {
1035                d.admin = v;
1036            }
1037            if let Some(v) = days.tx_ctrl {
1038                d.tx_ctrl = v;
1039            }
1040        }
1041        if let Some(v) = p.backup_days {
1042            next.backup_days = v;
1043        }
1044        if let Some(v) = p.audit_max_mb {
1045            next.audit_max_mb = v.max(10);
1046        }
1047        if let Some(v) = p.backup_max_mb {
1048            next.backup_max_mb = v.max(10);
1049        }
1050        if let Some(v) = p.auto_cleanup_hours {
1051            next.auto_cleanup_hours = v.min(720);
1052        }
1053        if let Some(v) = p.redact_sql_in_log {
1054            next.redact_sql_in_log = v;
1055        }
1056        if let Some(v) = p.tamper_evident_chain {
1057            next.tamper_evident_chain = v;
1058        }
1059        let revision = cfg.revision;
1060        let to_persist = next.clone();
1061        match self.ctx.config.update(revision, move |c| {
1062            c.retention = to_persist;
1063            Ok(())
1064        }) {
1065            Ok(()) => json_tool_result(serde_json::to_value(&next).unwrap_or_default()),
1066            Err(e) => error_tool_result(e.to_string()),
1067        }
1068    }
1069
1070    #[tool(
1071        name = "history_search",
1072        title = "Unified history (MCP audit + Sequel Ace)",
1073        description = "Merge our audit_log with Sequel Ace queryHistory.db, sorted by timestamp DESC. Each row has a source field (mcp | sequel-ace). Use source=mcp or source=sequel-ace to filter to one. Useful when you want a single timeline regardless of where a query was run.",
1074        annotations(read_only_hint = true, idempotent_hint = true, open_world_hint = false)
1075    )]
1076    fn history_search(&self, Parameters(p): Parameters<HistorySearchParams>) -> CallToolResult {
1077        let source = p.source.as_deref().unwrap_or("both");
1078        let limit = p.limit.unwrap_or(200).min(5000);
1079        let mut out: Vec<serde_json::Value> = Vec::new();
1080
1081        if source == "mcp" || source == "both" {
1082            let filters = crate::audit::AuditSearchFilters {
1083                since: p.since_iso.clone(),
1084                until: p.until_iso.clone(),
1085                connection: p.connection.clone(),
1086                category: None,
1087                outcome: None,
1088                limit: limit.saturating_mul(2),
1089            };
1090            if let Ok(rows) = crate::audit::search_audit_log(&self.ctx.audit, &filters) {
1091                for r in rows {
1092                    let sql = r.sql_redacted.clone();
1093                    if let Some(needle) = &p.search
1094                        && !sql.to_lowercase().contains(&needle.to_lowercase())
1095                    {
1096                        continue;
1097                    }
1098                    out.push(json!({
1099                        "source": "mcp",
1100                        "ts": r.ts,
1101                        "sql": sql,
1102                        "connection": r.connection,
1103                        "category": r.category,
1104                        "outcome": r.outcome,
1105                        "decision": r.decision,
1106                        "databases": r.databases,
1107                        "durationMs": r.duration_ms,
1108                        "affectedRows": r.affected_rows,
1109                        "backupId": r.backup_id,
1110                    }));
1111                }
1112            }
1113        }
1114
1115        if source == "sequel-ace" || source == "both" {
1116            let filters = crate::importer::history::SequelAceHistoryFilters {
1117                since_iso: p.since_iso.as_deref(),
1118                search: p.search.as_deref(),
1119                limit: Some(limit.saturating_mul(2)),
1120            };
1121            for r in crate::importer::read_sequel_ace_history(&filters, None) {
1122                if let Some(until) = &p.until_iso
1123                    && r.created_at_iso.as_str() >= until.as_str()
1124                {
1125                    continue;
1126                }
1127                out.push(json!({
1128                    "source": "sequel-ace",
1129                    "ts": r.created_at_iso,
1130                    "sql": r.query,
1131                    "sequelAceId": r.id,
1132                }));
1133            }
1134        }
1135
1136        out.sort_by(|a, b| b["ts"].as_str().cmp(&a["ts"].as_str()));
1137        out.truncate(limit as usize);
1138        json_tool_result(json!({ "count": out.len(), "rows": out }))
1139    }
1140
1141    #[tool(
1142        name = "sequel_ace_history",
1143        title = "Read Sequel Ace query history",
1144        description = "Read the queryHistory.db that Sequel Ace maintains in its sandbox. Returns distinct queries the user has run in the GUI (deduplicated by Sequel Ace, with latest createdTime). Read-only — no modification. Optional sinceIso, search (LIKE %text%), limit (default 200, max 5000).",
1145        annotations(read_only_hint = true, idempotent_hint = true, open_world_hint = false)
1146    )]
1147    fn sequel_ace_history(
1148        &self,
1149        Parameters(p): Parameters<SequelAceHistoryParams>,
1150    ) -> CallToolResult {
1151        let stat = crate::importer::stat_sequel_ace_history(None);
1152        if !stat.exists {
1153            return error_tool_result(format!(
1154                "Sequel Ace queryHistory.db not found at {}. Open Sequel Ace and run at least one query first, or check that Sequel Ace is installed.",
1155                stat.path.display()
1156            ));
1157        }
1158        let filters = crate::importer::history::SequelAceHistoryFilters {
1159            since_iso: p.since_iso.as_deref(),
1160            search: p.search.as_deref(),
1161            limit: p.limit,
1162        };
1163        let rows: Vec<serde_json::Value> = crate::importer::read_sequel_ace_history(&filters, None)
1164            .into_iter()
1165            .map(|r| {
1166                json!({
1167                    "id": r.id,
1168                    "query": r.query,
1169                    "createdTime": r.created_time,
1170                    "createdAtIso": r.created_at_iso,
1171                })
1172            })
1173            .collect();
1174        let returned = rows.len();
1175        json_tool_result(json!({
1176            "source": "sequel-ace",
1177            "path": stat.path.display().to_string(),
1178            "totalAvailable": stat.entry_count,
1179            "returned": returned,
1180            "note": "Sequel Ace dedupes by query text — only the latest createdTime is kept per distinct query.",
1181            "rows": rows,
1182        }))
1183    }
1184
1185    #[tool(
1186        name = "import_from_sequel_ace",
1187        title = "Import connections from Sequel Ace",
1188        description = "Read Sequel Ace Favorites.plist, copy connections (and optionally passwords via /usr/bin/security; macOS will prompt user to allow access) into our config + keychain. Sequel Ace data is never modified.",
1189        annotations(idempotent_hint = true, open_world_hint = false)
1190    )]
1191    fn import_from_sequel_ace(&self, Parameters(p): Parameters<ImportParams>) -> CallToolResult {
1192        let cfg = match self.ctx.config.load() {
1193            Ok(c) => c,
1194            Err(e) => return error_tool_result(format!("config load failed: {e}")),
1195        };
1196        let revision = cfg.revision;
1197        let copy = p.copy_passwords;
1198        let secrets = self.ctx.secrets.clone();
1199        let result = match self.ctx.config.update(revision, move |c| {
1200            Ok(crate::importer::import_from_sequel_ace(
1201                c, &*secrets, copy, None, None,
1202            ))
1203        }) {
1204            Ok(r) => r,
1205            Err(e) => return error_tool_result(e.to_string()),
1206        };
1207        json_tool_result(json!({
1208            "total": result.total,
1209            "imported": result.imported,
1210            "withPasswords": result.with_passwords,
1211            "skipped": result.skipped.iter()
1212                .map(|(name, reason)| json!({ "name": name, "reason": reason }))
1213                .collect::<Vec<_>>(),
1214        }))
1215    }
1216
1217    #[tool(
1218        name = "doctor",
1219        title = "Diagnostic report",
1220        description = "Print a sanitized JSON diagnostic of the MCP install: runtime versions, config state, every configured connection (host/user/db, policy), password presence. Contains zero passwords and zero secrets — redact hostnames before posting publicly.",
1221        annotations(read_only_hint = true, idempotent_hint = true, open_world_hint = false)
1222    )]
1223    fn doctor(&self, _p: Parameters<EmptyParams>) -> CallToolResult {
1224        let cfg = match self.ctx.config.load() {
1225            Ok(c) => c,
1226            Err(e) => return error_tool_result(format!("config load failed: {e}")),
1227        };
1228        let default = cfg.default_connection.as_deref();
1229        let connections: Vec<serde_json::Value> = cfg
1230            .connections
1231            .iter()
1232            .map(|c| {
1233                json!({
1234                    "name": c.name(),
1235                    "driver": if c.is_mysql() { "mysql" } else { "sqlite" },
1236                    "database": c.database(),
1237                    "policy": c.policy(),
1238                    "isDefault": Some(c.name()) == default,
1239                })
1240            })
1241            .collect();
1242        json_tool_result(json!({
1243            "app": crate::PACKAGE_NAME,
1244            "version": crate::PACKAGE_VERSION,
1245            "runtime": {
1246                "platform": std::env::consts::OS,
1247                "arch": std::env::consts::ARCH,
1248            },
1249            "defaultConnection": cfg.default_connection,
1250            "connections": connections,
1251            "note": "no passwords or Keychain secrets included; hostnames/usernames/SQLite paths/key paths ARE included — redact before posting publicly.",
1252        }))
1253    }
1254}
1255
1256impl SequelServer {
1257    /// Run one statement through the gate with no interactive approvals
1258    /// (read-only helper tools; confirm-gated statements report
1259    /// unavailable — use query/execute for those).
1260    fn run_sql_blocking(&self, args: RunSqlArgs, expect_read_only: bool) -> CallToolResult {
1261        let deps = self.gate_deps_blocking(Box::new(gate::UnavailableSink));
1262        match gate::run_sql(&deps, &args, expect_read_only) {
1263            Ok(out) => json_tool_result(gate::outcome_to_json(&out)),
1264            Err(e) => error_tool_result(e.to_string()),
1265        }
1266    }
1267
1268    /// Modern-era (2026-07-28) query/execute: MRTR approvals. The first
1269    /// confirm-required call returns `input_required` with an
1270    /// elicitation/create input request and an OPAQUE SERVER-SIDE
1271    /// one-shot `requestState` (a random token; every binding — tool,
1272    /// connection, operation digest, policy revision, DDL plan targets,
1273    /// expiry — lives in the in-process pending store, never on the
1274    /// wire). The retry echoes the token plus `inputResponses`; after
1275    /// atomic single-use consumption and revalidation the statement
1276    /// executes through the normal gate with the pre-decided outcome
1277    /// injected. For MySQL DROP statements the plan-time preflight fixes
1278    /// the approved target set BEFORE the approval is issued, and the
1279    /// retry fails closed (`ddl_precondition_changed`) if any target
1280    /// changed existence in between.
1281    async fn call_sql_modern(
1282        &self,
1283        request: rmcp::model::CallToolRequestParams,
1284        p: SqlParams,
1285        expect_read_only: bool,
1286        tool: &'static str,
1287    ) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
1288        use rmcp::model::{
1289            ElicitRequestParams, ElicitationSchema, InputRequest, InputRequiredResult,
1290        };
1291
1292        // Cheap pure recomputation of the operation identity + decision.
1293        let cfg = self
1294            .ctx
1295            .config
1296            .load()
1297            .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
1298        let conn = cfg
1299            .resolve(p.connection.as_deref())
1300            .cloned()
1301            .ok_or_else(|| {
1302                rmcp::ErrorData::invalid_params(
1303                    gate::no_connection_message(p.connection.as_deref()),
1304                    None,
1305                )
1306            })?;
1307        let dialect = if conn.is_mysql() {
1308            crate::policy::classifier::Dialect::MySql
1309        } else {
1310            crate::policy::classifier::Dialect::SQLite
1311        };
1312        let classified =
1313            crate::policy::classifier::classify_statement(&p.sql, dialect).map_err(|e| {
1314                rmcp::ErrorData::invalid_params(format!("cannot classify: {}", e.message()), None)
1315            })?;
1316        if expect_read_only && classified.category != crate::policy::model::SqlCategory::Read {
1317            return Ok(CallToolResponse::Complete(error_tool_result(format!(
1318                "query tool only accepts read statements (got {}). Use the \"execute\" tool for non-read statements.",
1319                classified.category
1320            ))));
1321        }
1322        let fallback = p
1323            .database
1324            .clone()
1325            .or_else(|| conn.database().map(str::to_string));
1326        let resolution = crate::policy::resolver::resolve(&conn, &classified, fallback.as_deref());
1327
1328        let run_with_sink =
1329            |outcome: Box<dyn gate::ApprovalSink>, expected_ddl: Option<Vec<(String, String)>>| {
1330                let args = RunSqlArgs {
1331                    connection: p.connection.clone(),
1332                    sql: p.sql.clone(),
1333                    database: p.database.clone(),
1334                    expected_ddl_targets: expected_ddl,
1335                };
1336                let deps = self.gate_deps_blocking(outcome);
1337                tokio::task::spawn_blocking(move || gate::run_sql(&deps, &args, expect_read_only))
1338            };
1339
1340        if resolution.action != crate::policy::model::PolicyAction::Confirm {
1341            let out = run_with_sink(Box::new(gate::UnavailableSink), None)
1342                .await
1343                .map_err(|e| rmcp::ErrorData::internal_error(format!("gate join: {e}"), None))?
1344                .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
1345            return Ok(CallToolResponse::Complete(super::json_tool_result(
1346                gate::outcome_to_json(&out),
1347            )));
1348        }
1349
1350        // Plan-time DDL preflight for MySQL DROP statements: fix the
1351        // approved target set BEFORE any approval is issued. The retry
1352        // can then only ever execute targets that existed when the user
1353        // approved — a target created in between fails closed.
1354        let mut approved_ddl: Option<Vec<(String, String)>> = None;
1355        let mut plan_split: Option<(Vec<String>, Vec<String>)> = None;
1356        if conn.is_mysql()
1357            && classified.category == crate::policy::model::SqlCategory::Ddl
1358            && classified.ast_type == "drop"
1359        {
1360            let Connection::Mysql(mysql_conn) = &conn else {
1361                unreachable!("checked is_mysql above");
1362            };
1363            let pw = match self
1364                .ctx
1365                .secrets
1366                .get_password(&mysql_conn.name, &mysql_conn.user)
1367            {
1368                Ok(pw) => pw,
1369                Err(_) => {
1370                    return Err(rmcp::ErrorData::invalid_params(
1371                        format!(
1372                            "no password stored for connection {:?}; cannot plan the approval",
1373                            mysql_conn.name
1374                        ),
1375                        None,
1376                    ));
1377                }
1378            };
1379            let preflight = async {
1380                // Tunnel connections plan through the SAME tunnel runtime
1381                // as the gate (a tunnel-only database must be plannable,
1382                // and the plan's pool must carry the tunnel generation).
1383                let (host, port, tunnel_generation) = if let Some(ssh) = &mysql_conn.ssh {
1384                    let ssh_pw = self
1385                        .ctx
1386                        .secrets
1387                        .get_password(&format!("{}::ssh", mysql_conn.name), &ssh.user)
1388                        .ok();
1389                    let lease = crate::sql::ssh::tunnel_endpoint(
1390                        &mysql_conn.name,
1391                        ssh,
1392                        ssh_pw.as_ref().map(|p| p.as_str()),
1393                        &mysql_conn.host,
1394                        mysql_conn.port,
1395                        cfg.revision,
1396                    )
1397                    .await
1398                    .map_err(|e| format!("ssh tunnel: {e}"))?;
1399                    (lease.host, lease.port, Some(lease.generation))
1400                } else {
1401                    (mysql_conn.host.clone(), mysql_conn.port, None)
1402                };
1403                let pool = crate::sql::mysql::pool_manager()
1404                    .verified_pool(
1405                        mysql_conn,
1406                        &pw,
1407                        fallback.as_deref(),
1408                        cfg.revision,
1409                        Some(&host),
1410                        Some(port),
1411                        tunnel_generation,
1412                    )
1413                    .await
1414                    .map_err(|e| format!("pool initialization failed: {e}"))?;
1415                let mut pconn = pool
1416                    .get_conn()
1417                    .await
1418                    .map_err(|e| format!("connection failed: {e}"))?;
1419                crate::sql::ddl::preflight_ddl(&mut pconn, &classified, fallback.as_deref())
1420                    .await
1421                    .map_err(|e| e.to_string())
1422            };
1423            match preflight.await {
1424                Ok(crate::sql::ddl::DdlPreflight::Present) => {
1425                    let mut targets = Vec::new();
1426                    for t in &classified.mutated_tables {
1427                        if let Some(schema) = t.database.clone().or_else(|| fallback.clone()) {
1428                            targets.push((schema, t.table.clone()));
1429                        }
1430                    }
1431                    approved_ddl = Some(targets);
1432                }
1433                Ok(crate::sql::ddl::DdlPreflight::Mixed { existing, missing }) => {
1434                    plan_split = Some((
1435                        existing.iter().map(|(s, t)| format!("{s}.{t}")).collect(),
1436                        missing.iter().map(|(s, t)| format!("{s}.{t}")).collect(),
1437                    ));
1438                    approved_ddl = Some(existing);
1439                }
1440                Ok(crate::sql::ddl::DdlPreflight::MissingNoOp(missing)) => {
1441                    plan_split = Some((
1442                        Vec::new(),
1443                        missing.iter().map(|(s, t)| format!("{s}.{t}")).collect(),
1444                    ));
1445                    approved_ddl = Some(Vec::new());
1446                }
1447                Err(e) => {
1448                    return Ok(CallToolResponse::Complete(error_tool_result(format!(
1449                        "cannot plan the approval (DDL preflight failed; nothing executed): {e}"
1450                    ))));
1451                }
1452            }
1453        }
1454
1455        // Bind the approval to the effective database scope as well:
1456        // replaying the same SQL against a different database arg (or a
1457        // changed connection default) must fail with mrtr_invalid_state.
1458        let digest =
1459            super::mrtr::operation_digest(&p.sql, conn.name(), fallback.as_deref(), cfg.revision);
1460
1461        // Retry: consume + validate the echoed state, parse the response,
1462        // execute with the plan-approved DDL target set.
1463        if let Some(responses) = &request.input_responses {
1464            if serde_json::to_string(responses)
1465                .map(|s| s.len())
1466                .unwrap_or(usize::MAX)
1467                > super::limits::MAX_INPUT_RESPONSES_BYTES
1468            {
1469                return Ok(CallToolResponse::Complete(error_tool_result(format!(
1470                    "[argument_too_large] inputResponses exceeds {} bytes",
1471                    super::limits::MAX_INPUT_RESPONSES_BYTES
1472                ))));
1473            }
1474            let state = request
1475                .request_state
1476                .as_deref()
1477                .ok_or_else(|| rmcp::ErrorData::invalid_params("missing requestState", None))?;
1478            if state.len() > super::limits::MAX_REQUEST_STATE_BYTES {
1479                return Err(rmcp::ErrorData::invalid_params(
1480                    format!(
1481                        "[mrtr_invalid_state] requestState exceeds {} bytes",
1482                        super::limits::MAX_REQUEST_STATE_BYTES
1483                    ),
1484                    None,
1485                ));
1486            }
1487            let pending = super::mrtr::take(state, tool, conn.name(), &digest, cfg.revision)
1488                .map_err(|e| {
1489                    rmcp::ErrorData::invalid_params(
1490                        format!("{}; approval rejected", e.message()),
1491                        None,
1492                    )
1493                })?;
1494            let value = responses.get("approval").cloned().ok_or_else(|| {
1495                rmcp::ErrorData::invalid_params("missing approval response", None)
1496            })?;
1497            match super::mrtr::parse_response(&value) {
1498                crate::approval::ConfirmOutcome::Chosen(crate::approval::GrantChoice::Decline) => {
1499                    Ok(CallToolResponse::Complete(error_tool_result(
1500                        "User declined confirmation. Statement not executed.",
1501                    )))
1502                }
1503                outcome @ crate::approval::ConfirmOutcome::Chosen(_) => {
1504                    let out = run_with_sink(
1505                        Box::new(PreDecidedSink(outcome)),
1506                        pending.approved_ddl_targets,
1507                    )
1508                    .await
1509                    .map_err(|e| rmcp::ErrorData::internal_error(format!("gate join: {e}"), None))?
1510                    .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
1511                    Ok(CallToolResponse::Complete(super::json_tool_result(
1512                        gate::outcome_to_json(&out),
1513                    )))
1514                }
1515                crate::approval::ConfirmOutcome::Unavailable { reason } => {
1516                    Ok(CallToolResponse::Complete(error_tool_result(format!(
1517                        "confirmation required, but no prompt could be shown: {reason}. Statement not executed - nothing was changed. This is not a refusal."
1518                    ))))
1519                }
1520            }
1521        } else {
1522            // First call: emit the input request (redacted SQL preview +
1523            // the plan-time target split for mixed DROP statements).
1524            let snippet = if p.sql.len() > 800 {
1525                format!("{}…", &p.sql[..800])
1526            } else {
1527                p.sql.clone()
1528            };
1529            let tables: Vec<String> = resolution
1530                .contributions
1531                .iter()
1532                .map(|c| format!("{}.{}", c.table.database, c.table.table))
1533                .collect();
1534            let plan_note = match &plan_split {
1535                Some((exec, absent)) => format!(
1536                    "\n\nConfirmed to exist at plan time (will be affected): {}\nAbsent at plan time (skipped, recorded in audit): {}",
1537                    if exec.is_empty() {
1538                        "(none — this will be a no-op)".to_string()
1539                    } else {
1540                        exec.join(", ")
1541                    },
1542                    absent.join(", ")
1543                ),
1544                None => String::new(),
1545            };
1546            let message = format!(
1547                "About to run a {} statement on {}.\n\n--- SQL ---\n{}\n--- end ---\n\nAffected tables: {}{}\n\nPick an authorization scope.",
1548                classified.category,
1549                conn.name(),
1550                snippet,
1551                if tables.is_empty() {
1552                    "(statement scope)".to_string()
1553                } else {
1554                    tables.join(", ")
1555                },
1556                plan_note,
1557            );
1558            let mut input_requests = std::collections::BTreeMap::new();
1559            input_requests.insert(
1560                "approval".to_string(),
1561                InputRequest::Elicitation(rmcp::model::ElicitRequest::new(
1562                    ElicitRequestParams::FormElicitationParams {
1563                        meta: None,
1564                        message,
1565                        requested_schema: ElicitationSchema::from_json_schema(
1566                            serde_json::json!({
1567                                "type": "object",
1568                                "properties": {
1569                                    "choice": {
1570                                        "type": "string",
1571                                        "title": "Authorization",
1572                                        "enum": ["once", "session", "decline"]
1573                                    }
1574                                },
1575                                "required": ["choice"]
1576                            })
1577                            .as_object()
1578                            .cloned()
1579                            .unwrap_or_default(),
1580                        )
1581                        .map_err(|e| {
1582                            rmcp::ErrorData::internal_error(
1583                                format!("elicitation schema: {e}"),
1584                                None,
1585                            )
1586                        })?,
1587                    },
1588                )),
1589            );
1590            let pending = super::mrtr::PendingApproval {
1591                tool,
1592                connection: conn.name().to_string(),
1593                operation_digest: digest,
1594                policy_revision: cfg.revision,
1595                approved_ddl_targets: approved_ddl,
1596                expires_at: std::time::Instant::now() + super::mrtr::TTL,
1597            };
1598            let state = super::mrtr::issue(pending);
1599            Ok(rmcp::model::CallToolResponse::InputRequired(
1600                InputRequiredResult::new(Some(input_requests), Some(state)),
1601            ))
1602        }
1603    }
1604}
1605
1606impl SequelServer {
1607    /// add_connection: validates arguments, elicits the password through
1608    /// the session peer (never via tool args), stores it in the secret
1609    /// store (macOS Keychain; the in-memory store under test mode), and
1610    /// upserts the connection. Any decline/cancel/unavailable prompt
1611    /// leaves config AND secrets untouched.
1612    async fn call_add_connection(
1613        &self,
1614        p: AddConnectionParams,
1615        peer: rmcp::service::Peer<rmcp::RoleServer>,
1616    ) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
1617        use crate::config::{BridgeTool, MySqlConnection, SshDocker, SshHostKeyPolicy, SshTunnel};
1618
1619        let preset = match parse_preset(&p.policy_preset) {
1620            Ok(v) => v,
1621            Err(e) => {
1622                return Ok(CallToolResponse::Complete(error_tool_result(e)));
1623            }
1624        };
1625        if p.name.trim().is_empty() {
1626            return Ok(CallToolResponse::Complete(error_tool_result(
1627                "connection name must not be empty",
1628            )));
1629        }
1630        if p.host.trim().is_empty() {
1631            return Ok(CallToolResponse::Complete(error_tool_result(
1632                "host must not be empty",
1633            )));
1634        }
1635        if p.user.trim().is_empty() {
1636            return Ok(CallToolResponse::Complete(error_tool_result(
1637                "user must not be empty",
1638            )));
1639        }
1640
1641        let bridge_tool = match p.ssh_docker_bridge_tool.as_deref() {
1642            None => BridgeTool::Nc,
1643            Some("nc") => BridgeTool::Nc,
1644            Some("ncat") => BridgeTool::Ncat,
1645            Some("socat") => BridgeTool::Socat,
1646            Some(other) => {
1647                return Ok(CallToolResponse::Complete(error_tool_result(format!(
1648                    "unknown bridge tool {other:?} (nc | ncat | socat)"
1649                ))));
1650            }
1651        };
1652        let host_key_policy = match p.ssh_host_key_policy.as_deref() {
1653            None => None,
1654            Some("lenient") => Some(SshHostKeyPolicy::Lenient),
1655            Some("strict") => Some(SshHostKeyPolicy::Strict),
1656            Some(other) => {
1657                return Ok(CallToolResponse::Complete(error_tool_result(format!(
1658                    "unknown host key policy {other:?} (lenient | strict)"
1659                ))));
1660            }
1661        };
1662
1663        let ssh = match (p.ssh_host.as_deref(), p.ssh_user.as_deref()) {
1664            (Some(host), Some(user)) if !host.trim().is_empty() && !user.trim().is_empty() => {
1665                Some(SshTunnel {
1666                    host: host.trim().to_string(),
1667                    port: p.ssh_port.unwrap_or(22),
1668                    user: user.trim().to_string(),
1669                    auth_method: if p.ssh_key_path.is_some() {
1670                        crate::config::SshAuthMethod::Key
1671                    } else {
1672                        crate::config::SshAuthMethod::Password
1673                    },
1674                    private_key_path: p.ssh_key_path.clone(),
1675                    docker: p
1676                        .ssh_docker_container
1677                        .as_deref()
1678                        .map(|container| SshDocker {
1679                            container: container.to_string(),
1680                            bridge_tool,
1681                        }),
1682                    host_key_policy,
1683                    host_key_policy_migrated: false,
1684                    known_hosts_path: p.ssh_known_hosts_path.clone(),
1685                })
1686            }
1687            (None, None) => None,
1688            _ => {
1689                return Ok(CallToolResponse::Complete(error_tool_result(
1690                    "ssh tunnel requires both ssh_host and ssh_user",
1691                )));
1692            }
1693        };
1694
1695        let port = p.port.unwrap_or(3306);
1696        let mut mc = MySqlConnection {
1697            name: p.name.clone(),
1698            host: p.host.trim().to_string(),
1699            port,
1700            user: p.user.trim().to_string(),
1701            database: p.database.clone(),
1702            ssl: p.ssl.unwrap_or(false),
1703            ssl_server_name: p.ssl_server_name.clone(),
1704            ssl_ca_path: p.ssl_ca_path.clone(),
1705            ssh,
1706            ..MySqlConnection::default()
1707        };
1708        mc.policy = crate::policy::model::policy_from_preset(preset);
1709        let conn = Connection::Mysql(mc);
1710        if let Err(e) = conn.validate() {
1711            return Ok(CallToolResponse::Complete(error_tool_result(format!(
1712                "{e}"
1713            ))));
1714        }
1715
1716        // Elicit the password LAST, after every argument check, so a
1717        // prompt only ever appears for a connection that would save.
1718        let message = format!(
1719            "Enter MySQL/MariaDB password for {}@{}:{} (connection \"{}\"). Stored locally in the macOS Keychain; never in tool arguments or logs.",
1720            p.user.trim(),
1721            p.host.trim(),
1722            port,
1723            p.name
1724        );
1725        let password = match super::confirm::run_password_elicitation(&peer, message).await {
1726            Ok(pw) => pw,
1727            Err(reason) => {
1728                return Ok(CallToolResponse::Complete(error_tool_result(format!(
1729                    "Password capture cancelled. Connection not saved. ({reason})"
1730                ))));
1731            }
1732        };
1733
1734        let cfg = self
1735            .ctx
1736            .config
1737            .load()
1738            .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
1739        let (name, user, preset_name) = (
1740            p.name.clone(),
1741            p.user.trim().to_string(),
1742            preset.as_str().to_string(),
1743        );
1744        match self.ctx.config.update(cfg.revision, move |c| {
1745            upsert(c, conn);
1746            Ok(())
1747        }) {
1748            Ok(()) => {
1749                if let Err(e) = self.ctx.secrets.set_password(&name, &user, &password) {
1750                    // The connection saved but the secret did not: say so
1751                    // plainly rather than pretending all is well.
1752                    return Ok(CallToolResponse::Complete(error_tool_result(format!(
1753                        "Saved connection \"{name}\", but storing the password failed: {e}. Re-add the connection or store the password manually."
1754                    ))));
1755                }
1756                Ok(CallToolResponse::Complete(text_tool_result(format!(
1757                    "Saved connection \"{name}\" with policy preset \"{preset_name}\". Password stored in the secret store (macOS Keychain)."
1758                ))))
1759            }
1760            Err(e) => Ok(CallToolResponse::Complete(error_tool_result(format!(
1761                "{e}"
1762            )))),
1763        }
1764    }
1765
1766    /// restore_backup: dry-run returns the plan; execution is
1767    /// confirmation-gated (MRTR in the modern era, elicitation in the
1768    /// legacy era) and replays the plan on ONE connection inside ONE
1769    /// transaction, after a policy deny-check. Faithful to the legacy
1770    /// tool, which confirmed once and executed directly.
1771    async fn call_restore(
1772        &self,
1773        p: RestoreParams,
1774        modern: bool,
1775        peer: rmcp::service::Peer<rmcp::RoleServer>,
1776        request: rmcp::model::CallToolRequestParams,
1777    ) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
1778        use crate::backup::restore::{RestoreDialect, plan_restore};
1779
1780        let cfg = self
1781            .ctx
1782            .config
1783            .load()
1784            .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
1785        let Some(detail) = crate::backup::restore::get_backup(&self.ctx.audit, p.backup_id) else {
1786            return Ok(CallToolResponse::Complete(error_tool_result(format!(
1787                "Backup #{} not found.",
1788                p.backup_id
1789            ))));
1790        };
1791        let Some(conn) = cfg.resolve(Some(detail.connection.as_str())).cloned() else {
1792            return Ok(CallToolResponse::Complete(error_tool_result(format!(
1793                "Connection {:?} referenced by backup no longer exists.",
1794                detail.connection
1795            ))));
1796        };
1797        let dialect = if conn.is_mysql() {
1798            RestoreDialect::MySql
1799        } else {
1800            RestoreDialect::SQLite
1801        };
1802        let plan = match plan_restore(&self.ctx.audit, p.backup_id, dialect) {
1803            Ok(plan) => plan,
1804            Err(e) => {
1805                return Ok(CallToolResponse::Complete(error_tool_result(format!(
1806                    "Cannot plan restore: {e}"
1807                ))));
1808            }
1809        };
1810
1811        if p.dry_run {
1812            // Per-statement policy preview (review P1): show what the
1813            // two-layer gate says about EVERY replayed statement before
1814            // anything executes — the manual-review surface.
1815            let preview = match resolve_restore_plan(&conn, &detail, &plan) {
1816                Ok(res) => res
1817                    .iter()
1818                    .enumerate()
1819                    .map(|(i, (c, r))| {
1820                        json!({
1821                            "statement": i,
1822                            "category": c.category.as_str(),
1823                            "action": r.action,
1824                            "databases": c.target_databases,
1825                        })
1826                    })
1827                    .collect::<Vec<_>>(),
1828                Err(reason) => vec![json!({ "wouldDeny": true, "reason": reason })],
1829            };
1830            return Ok(CallToolResponse::Complete(json_tool_result(json!({
1831                "backupId": p.backup_id,
1832                "connection": detail.connection,
1833                "rowCount": plan.row_count,
1834                "statementCount": plan.statements.len(),
1835                "warnings": plan.warnings,
1836                "isInsertHintDelete": plan.is_insert_hint_delete,
1837                "firstStatementPreview":
1838                    plan.statements.first().map(|s| s.chars().take(240).collect::<String>()),
1839                "policy": preview,
1840                "note": "dry-run; pass dryRun=false to actually execute",
1841            }))));
1842        }
1843        if plan.statements.is_empty() {
1844            return Ok(CallToolResponse::Complete(json_tool_result(json!({
1845                "backupId": p.backup_id,
1846                "executedStatements": 0,
1847                "warnings": plan.warnings,
1848                "note": "nothing to restore",
1849            }))));
1850        }
1851
1852        // Confirmation: modern era = MRTR one-shot state bound to the
1853        // backup id + policy revision; legacy era = elicitation.
1854        let digest = {
1855            use sha2::Digest;
1856            let mut h = sha2::Sha256::new();
1857            h.update(b"sequel-mcp/restore/v1\n");
1858            h.update(detail.connection.as_bytes());
1859            h.update(b"\n");
1860            h.update(p.backup_id.to_le_bytes());
1861            h.update(cfg.revision.to_le_bytes());
1862            d_hex(h)
1863        };
1864
1865        if modern {
1866            if let Some(responses) = &request.input_responses {
1867                let state = request
1868                    .request_state
1869                    .as_deref()
1870                    .ok_or_else(|| rmcp::ErrorData::invalid_params("missing requestState", None))?;
1871                if state.len() > super::limits::MAX_REQUEST_STATE_BYTES {
1872                    return Err(rmcp::ErrorData::invalid_params(
1873                        format!(
1874                            "[mrtr_invalid_state] requestState exceeds {} bytes",
1875                            super::limits::MAX_REQUEST_STATE_BYTES
1876                        ),
1877                        None,
1878                    ));
1879                }
1880                let _pending = super::mrtr::take(
1881                    state,
1882                    "restore_backup",
1883                    &detail.connection,
1884                    &digest,
1885                    cfg.revision,
1886                )
1887                .map_err(|e| {
1888                    rmcp::ErrorData::invalid_params(
1889                        format!("{}; approval rejected", e.message()),
1890                        None,
1891                    )
1892                })?;
1893                let value = responses.get("approval").cloned().ok_or_else(|| {
1894                    rmcp::ErrorData::invalid_params("missing approval response", None)
1895                })?;
1896                match super::mrtr::parse_response(&value) {
1897                    crate::approval::ConfirmOutcome::Chosen(
1898                        crate::approval::GrantChoice::Decline,
1899                    ) => Ok(CallToolResponse::Complete(error_tool_result(
1900                        "Restore declined.",
1901                    ))),
1902                    outcome @ crate::approval::ConfirmOutcome::Chosen(_) => {
1903                        let _ = outcome;
1904                        self.execute_restore_plan(
1905                            &conn,
1906                            &detail,
1907                            &plan,
1908                            cfg.revision,
1909                            &crate::audit::WriteOptions {
1910                                redact_sql_in_log: cfg.retention.redact_sql_in_log,
1911                                tamper_evident_chain: cfg.retention.tamper_evident_chain,
1912                            },
1913                        )
1914                        .await
1915                    }
1916                    crate::approval::ConfirmOutcome::Unavailable { reason } => {
1917                        Ok(CallToolResponse::Complete(error_tool_result(format!(
1918                            "Restore needs confirmation, but no prompt could be shown: {reason}. Nothing was restored. This is not a refusal - set an explicit write policy, or restore outside this tool."
1919                        ))))
1920                    }
1921                }
1922            } else {
1923                use rmcp::model::{
1924                    ElicitRequestParams, ElicitationSchema, InputRequest, InputRequiredResult,
1925                };
1926                let message = format!(
1927                    "Restore backup #{}: {} statement(s) into {}.{}.{}\n\nWarnings: {}\n\nPick an authorization scope.",
1928                    p.backup_id,
1929                    plan.statements.len(),
1930                    detail.connection,
1931                    detail.database.as_deref().unwrap_or("<default>"),
1932                    detail.table_name,
1933                    if plan.warnings.is_empty() {
1934                        "(none)".to_string()
1935                    } else {
1936                        plan.warnings.join("; ")
1937                    }
1938                );
1939                let mut input_requests = std::collections::BTreeMap::new();
1940                input_requests.insert(
1941                    "approval".to_string(),
1942                    InputRequest::Elicitation(rmcp::model::ElicitRequest::new(
1943                        ElicitRequestParams::FormElicitationParams {
1944                            meta: None,
1945                            message,
1946                            requested_schema: ElicitationSchema::from_json_schema(
1947                                serde_json::json!({
1948                                    "type": "object",
1949                                    "properties": {
1950                                        "choice": {
1951                                            "type": "string",
1952                                            "title": "Authorization",
1953                                            "enum": ["once", "decline"]
1954                                        }
1955                                    },
1956                                    "required": ["choice"]
1957                                })
1958                                .as_object()
1959                                .cloned()
1960                                .unwrap_or_default(),
1961                            )
1962                            .map_err(|e| {
1963                                rmcp::ErrorData::internal_error(
1964                                    format!("elicitation schema: {e}"),
1965                                    None,
1966                                )
1967                            })?,
1968                        },
1969                    )),
1970                );
1971                let pending = super::mrtr::PendingApproval {
1972                    tool: "restore_backup",
1973                    connection: detail.connection.clone(),
1974                    operation_digest: digest,
1975                    policy_revision: cfg.revision,
1976                    approved_ddl_targets: None,
1977                    expires_at: std::time::Instant::now() + super::mrtr::TTL,
1978                };
1979                let state = super::mrtr::issue(pending);
1980                Ok(rmcp::model::CallToolResponse::InputRequired(
1981                    InputRequiredResult::new(Some(input_requests), Some(state)),
1982                ))
1983            }
1984        } else {
1985            // Legacy era: elicit directly through the session peer.
1986            let message = format!(
1987                "About to RESTORE backup #{}: {} statement(s) into {}.{}.{}",
1988                p.backup_id,
1989                plan.statements.len(),
1990                detail.connection,
1991                detail.database.as_deref().unwrap_or("<default>"),
1992                detail.table_name
1993            );
1994            let outcome = super::confirm::run_elicitation(&peer, message).await;
1995            match outcome {
1996                crate::approval::ConfirmOutcome::Chosen(crate::approval::GrantChoice::Decline) => {
1997                    Ok(CallToolResponse::Complete(error_tool_result(
1998                        "Restore declined.",
1999                    )))
2000                }
2001                crate::approval::ConfirmOutcome::Chosen(_) => {
2002                    self.execute_restore_plan(
2003                        &conn,
2004                        &detail,
2005                        &plan,
2006                        cfg.revision,
2007                        &crate::audit::WriteOptions {
2008                            redact_sql_in_log: cfg.retention.redact_sql_in_log,
2009                            tamper_evident_chain: cfg.retention.tamper_evident_chain,
2010                        },
2011                    )
2012                    .await
2013                }
2014                crate::approval::ConfirmOutcome::Unavailable { reason } => {
2015                    Ok(CallToolResponse::Complete(error_tool_result(format!(
2016                        "Restore needs confirmation, but no prompt could be shown: {reason}. Nothing was restored. This is not a refusal."
2017                    ))))
2018                }
2019            }
2020        }
2021    }
2022
2023    /// Replay the plan on one connection in one transaction, after a
2024    /// policy deny-check (writes must not be denied for the backup's
2025    /// table scope).
2026    async fn execute_restore_plan(
2027        &self,
2028        conn: &crate::config::Connection,
2029        detail: &crate::backup::restore::BackupDetail,
2030        plan: &crate::backup::restore::RestorePlan,
2031        revision: u64,
2032        write_opts: &crate::audit::WriteOptions,
2033    ) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
2034        // Per-statement policy resolution (review P1): EVERY replayed
2035        // statement must pass the two-layer gate, not just the first —
2036        // an outer approval for "restore A" must never silently cover a
2037        // statement the policy denies.
2038        let resolutions = match resolve_restore_plan(conn, detail, plan) {
2039            Ok(res) => res,
2040            Err(reason) => {
2041                // A denied restore still leaves its trace, linked to the
2042                // backup (same discipline as gate denials).
2043                let entry = crate::audit::AuditEntry {
2044                    request_id: format!("restore-{}-{}", detail.id, uuid::Uuid::new_v4()),
2045                    connection: detail.connection.clone(),
2046                    databases: Vec::new(),
2047                    category: crate::policy::model::SqlCategory::Write,
2048                    ast_type: None,
2049                    sql: plan
2050                        .statements
2051                        .first()
2052                        .map(|s| s.chars().take(800).collect::<String>())
2053                        .unwrap_or_default(),
2054                    decision: crate::policy::model::PolicyAction::Deny,
2055                    confirmed: false,
2056                    outcome: crate::approval::outcomes::ApprovalOutcome::Denied,
2057                    affected_rows: None,
2058                    duration_ms: None,
2059                    error: Some(reason.clone()),
2060                    backup_id: Some(detail.id),
2061                    approval_scope: None,
2062                    approval_digest: None,
2063                    policy_revision: Some(revision),
2064                };
2065                let _ = crate::audit::write_audit_entry(&self.ctx.audit, &entry, write_opts);
2066                return Ok(CallToolResponse::Complete(error_tool_result(format!(
2067                    "Restore denied by policy: {reason}"
2068                ))));
2069            }
2070        };
2071
2072        let started = std::time::Instant::now();
2073        let result: Result<crate::backup::restore::RestoreOutcome, String> = match conn {
2074            crate::config::Connection::Sqlite(sc) => {
2075                let sc = sc.clone();
2076                let stmts = plan.statements.clone();
2077                tokio::task::spawn_blocking(move || {
2078                    let db = crate::sql::sqlite::open_sqlite_database(
2079                        &sc,
2080                        false,
2081                        sc.policy.stmt_timeout_ms,
2082                    )
2083                    .map_err(|e| e.to_string())?;
2084                    db.execute_batch("BEGIN IMMEDIATE")
2085                        .map_err(|e| e.to_string())?;
2086                    match crate::backup::restore::execute_restore_sqlite(&db, &fake_plan(&stmts)) {
2087                        Ok(r) => {
2088                            db.execute_batch("COMMIT").map_err(|e| e.to_string())?;
2089                            Ok(r)
2090                        }
2091                        Err(e) => {
2092                            let _ = db.execute_batch("ROLLBACK");
2093                            Err(e.to_string())
2094                        }
2095                    }
2096                })
2097                .await
2098                .map_err(|e| format!("join: {e}"))
2099                .and_then(|r| r)
2100            }
2101            crate::config::Connection::Mysql(mc) => {
2102                let Some(password) = self.ctx.secrets.get_password(&mc.name, &mc.user).ok() else {
2103                    return Ok(CallToolResponse::Complete(error_tool_result(format!(
2104                        "No password for {:?}.",
2105                        mc.name
2106                    ))));
2107                };
2108                let (host, port) = match &mc.ssh {
2109                    Some(ssh) => {
2110                        let ssh_pw = self
2111                            .ctx
2112                            .secrets
2113                            .get_password(&format!("{}::ssh", mc.name), &ssh.user)
2114                            .ok();
2115                        match crate::sql::ssh::tunnel_endpoint(
2116                            &mc.name,
2117                            ssh,
2118                            ssh_pw.as_ref().map(|p| p.as_str()),
2119                            &mc.host,
2120                            mc.port,
2121                            revision,
2122                        )
2123                        .await
2124                        {
2125                            Ok(lease) => (lease.host, lease.port),
2126                            Err(e) => {
2127                                return Ok(CallToolResponse::Complete(error_tool_result(format!(
2128                                    "ssh tunnel: {e}"
2129                                ))));
2130                            }
2131                        }
2132                    }
2133                    None => (mc.host.clone(), mc.port),
2134                };
2135                let mc = mc.clone();
2136                let db = detail.database.clone();
2137                let stmts = plan.statements.clone();
2138                tokio::task::block_in_place(|| {
2139                    tokio::runtime::Handle::current().block_on(async {
2140                        let opts = crate::sql::mysql::build_opts(
2141                            &mc,
2142                            &password,
2143                            db.as_deref(),
2144                            Some(&host),
2145                            Some(port),
2146                        );
2147                        use mysql_async::prelude::Queryable;
2148                        let mut c = mysql_async::Conn::new(opts)
2149                            .await
2150                            .map_err(|e| format!("connect: {e}"))?;
2151                        c.query_drop("START TRANSACTION READ WRITE")
2152                            .await
2153                            .map_err(|e| format!("start tx: {e}"))?;
2154                        let mut affected: u64 = 0;
2155                        let mut run = 0usize;
2156                        for stmt in &stmts {
2157                            match c.query_iter(stmt.as_str()).await {
2158                                Ok(_) => {
2159                                    affected += c.affected_rows();
2160                                    run += 1;
2161                                }
2162                                Err(e) => {
2163                                    let _ = c.query_drop("ROLLBACK").await;
2164                                    return Err(format!("statement {run} failed: {e}"));
2165                                }
2166                            }
2167                        }
2168                        c.query_drop("COMMIT")
2169                            .await
2170                            .map_err(|e| format!("commit: {e}"))?;
2171                        let _ = c.disconnect().await;
2172                        Ok(crate::backup::restore::RestoreOutcome {
2173                            statements_run: run,
2174                            affected,
2175                        })
2176                    })
2177                })
2178            }
2179        };
2180
2181        match result {
2182            Ok(r) => {
2183                // Per-statement audit rows (review P1): every replayed
2184                // statement leaves its own row, linked to the backup.
2185                // Write failures are SURFACED (never swallowed — the
2186                // lesson from the audit-write blocker) but the restore
2187                // already committed, so they become result warnings, not
2188                // errors that would invite a retry.
2189                let base_request = format!("restore-{}-{}", detail.id, uuid::Uuid::new_v4());
2190                let mut audit_warnings: Vec<String> = Vec::new();
2191                for (i, (classified, resolution)) in resolutions.iter().enumerate() {
2192                    let entry = crate::audit::AuditEntry {
2193                        request_id: format!("{base_request}-{i}"),
2194                        connection: detail.connection.clone(),
2195                        databases: classified.target_databases.clone(),
2196                        category: classified.category,
2197                        ast_type: Some(classified.ast_type.to_string()),
2198                        sql: plan.statements[i].clone(),
2199                        decision: resolution.action,
2200                        confirmed: true,
2201                        outcome: crate::approval::outcomes::ApprovalOutcome::Approved,
2202                        affected_rows: None,
2203                        duration_ms: None,
2204                        error: None,
2205                        backup_id: Some(detail.id),
2206                        approval_scope: Some("restore".to_string()),
2207                        approval_digest: None,
2208                        policy_revision: Some(revision),
2209                    };
2210                    if let Err(e) =
2211                        crate::audit::write_audit_entry(&self.ctx.audit, &entry, write_opts)
2212                    {
2213                        eprintln!(
2214                            "sequel-mcp: AUDIT WRITE FAILED for restore statement {i} of backup #{}: {e}",
2215                            detail.id
2216                        );
2217                        audit_warnings.push(format!("audit-write-failed (statement {i}): {e}"));
2218                    }
2219                }
2220                Ok(CallToolResponse::Complete(json_tool_result(json!({
2221                    "backupId": detail.id,
2222                    "executedStatements": r.statements_run,
2223                    "affected": r.affected,
2224                    "warnings": plan.warnings,
2225                    "auditWarnings": audit_warnings,
2226                    "durationMs": started.elapsed().as_millis() as u64,
2227                }))))
2228            }
2229            Err(e) => {
2230                // Failure still links the attempt to the backup.
2231                let entry = crate::audit::AuditEntry {
2232                    request_id: format!("restore-{}-{}", detail.id, uuid::Uuid::new_v4()),
2233                    connection: detail.connection.clone(),
2234                    databases: Vec::new(),
2235                    category: resolutions
2236                        .first()
2237                        .map(|(c, _)| c.category)
2238                        .unwrap_or(crate::policy::model::SqlCategory::Write),
2239                    ast_type: None,
2240                    sql: plan
2241                        .statements
2242                        .first()
2243                        .map(|s| s.chars().take(800).collect::<String>())
2244                        .unwrap_or_default(),
2245                    decision: crate::policy::model::PolicyAction::Confirm,
2246                    confirmed: true,
2247                    outcome: crate::approval::outcomes::ApprovalOutcome::ExecutionError,
2248                    affected_rows: None,
2249                    duration_ms: Some(started.elapsed().as_millis() as i64),
2250                    error: Some(e.clone()),
2251                    backup_id: Some(detail.id),
2252                    approval_scope: Some("restore".to_string()),
2253                    approval_digest: None,
2254                    policy_revision: Some(revision),
2255                };
2256                if let Err(audit_err) =
2257                    crate::audit::write_audit_entry(&self.ctx.audit, &entry, write_opts)
2258                {
2259                    eprintln!(
2260                        "sequel-mcp: audit write also failed for the errored restore: {audit_err}"
2261                    );
2262                }
2263                Ok(CallToolResponse::Complete(error_tool_result(format!(
2264                    "Restore failed: {e}"
2265                ))))
2266            }
2267        }
2268    }
2269}
2270
2271fn d_hex(h: sha2::Sha256) -> String {
2272    use sha2::Digest;
2273    h.finalize().iter().map(|b| format!("{b:02x}")).collect()
2274}
2275
2276/// Per-statement classification + two-layer resolution for a restore
2277/// plan (review P1): EVERY replayed statement must pass the gate, not
2278/// just the first. Any Deny is a typed refusal naming the offending
2279/// statement and its table scope; unclassifiable statements refuse too.
2280#[allow(clippy::type_complexity)]
2281fn resolve_restore_plan(
2282    conn: &crate::config::Connection,
2283    detail: &crate::backup::restore::BackupDetail,
2284    plan: &crate::backup::restore::RestorePlan,
2285) -> Result<
2286    Vec<(
2287        crate::policy::classifier::ClassifiedStatement,
2288        crate::policy::resolver::Resolution,
2289    )>,
2290    String,
2291> {
2292    let dialect = if conn.is_mysql() {
2293        crate::policy::classifier::Dialect::MySql
2294    } else {
2295        crate::policy::classifier::Dialect::SQLite
2296    };
2297    let fallback = detail
2298        .database
2299        .clone()
2300        .or_else(|| conn.database().map(str::to_string));
2301    let mut out = Vec::with_capacity(plan.statements.len());
2302    for (i, stmt) in plan.statements.iter().enumerate() {
2303        let classified = crate::policy::classifier::classify_statement(stmt, dialect)
2304            .map_err(|e| format!("cannot classify restore statement {i}: {}", e.message()))?;
2305        let resolution = crate::policy::resolver::resolve(conn, &classified, fallback.as_deref());
2306        if resolution.action == crate::policy::model::PolicyAction::Deny {
2307            let tables: Vec<String> = resolution
2308                .contributions
2309                .iter()
2310                .map(|c| format!("{}.{}", c.table.database, c.table.table))
2311                .collect();
2312            return Err(format!(
2313                "statement {i} ({}) is denied by policy{}",
2314                stmt.chars().take(120).collect::<String>(),
2315                if tables.is_empty() {
2316                    String::new()
2317                } else {
2318                    format!(" (tables: {})", tables.join(", "))
2319                }
2320            ));
2321        }
2322        out.push((classified, resolution));
2323    }
2324    Ok(out)
2325}
2326
2327fn fake_plan(stmts: &[String]) -> crate::backup::restore::RestorePlan {
2328    crate::backup::restore::RestorePlan {
2329        backup_id: 0,
2330        statements: stmts.to_vec(),
2331        row_count: 0,
2332        warnings: Vec::new(),
2333        is_insert_hint_delete: false,
2334    }
2335}
2336
2337/// Approval sink with a pre-decided outcome (MRTR retry path).
2338struct PreDecidedSink(crate::approval::ConfirmOutcome);
2339
2340impl gate::ApprovalSink for PreDecidedSink {
2341    fn confirm(&self, _request: gate::ApprovalRequest) -> crate::approval::ConfirmOutcome {
2342        self.0.clone()
2343    }
2344}
2345
2346fn mysql_user(c: &Connection) -> Option<String> {
2347    match c {
2348        Connection::Mysql(m) => Some(m.user.clone()),
2349        Connection::Sqlite(_) => None,
2350    }
2351}
2352
2353fn upsert(cfg: &mut crate::config::Config, conn: Connection) {
2354    match cfg.connections.iter_mut().find(|c| c.name() == conn.name()) {
2355        Some(slot) => *slot = conn,
2356        None => cfg.connections.push(conn),
2357    }
2358}
2359
2360#[tool_handler]
2361impl ServerHandler for SequelServer {
2362    fn get_info(&self) -> rmcp::model::ServerInfo {
2363        super::build_server_info()
2364    }
2365
2366    // ---- Prompts (legacy `server/prompts.ts` parity) ----
2367
2368    fn list_prompts(
2369        &self,
2370        _request: Option<rmcp::model::PaginatedRequestParams>,
2371        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
2372    ) -> impl Future<Output = Result<rmcp::model::ListPromptsResult, rmcp::ErrorData>> + '_ {
2373        use rmcp::model::{Prompt, PromptArgument};
2374        let setup = Prompt::new(
2375            "setup-connection",
2376            Some("Walks through adding either a MySQL/MariaDB connection with Keychain password capture or a SQLite file connection with no password."),
2377            Some(vec![PromptArgument::new("suggestedName")
2378                .with_title("Suggested name")
2379                .with_description("Optional name to prefill")]),
2380        )
2381        .with_title("Set up a new database connection");
2382        let analyze = Prompt::new(
2383            "analyze-table",
2384            Some("Read-only investigation: schema, row count, indexes, sample rows."),
2385            Some(vec![
2386                PromptArgument::new("connection")
2387                    .with_title("Connection")
2388                    .with_required(true),
2389                PromptArgument::new("database").with_title("Database"),
2390                PromptArgument::new("table")
2391                    .with_title("Table")
2392                    .with_required(true),
2393            ]),
2394        )
2395        .with_title("Analyze a table");
2396        std::future::ready(Ok(rmcp::model::ListPromptsResult::with_all_items(vec![
2397            setup, analyze,
2398        ])))
2399    }
2400
2401    async fn get_prompt(
2402        &self,
2403        request: rmcp::model::GetPromptRequestParams,
2404        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
2405    ) -> Result<rmcp::model::GetPromptResponse, rmcp::ErrorData> {
2406        use rmcp::model::{GetPromptResult, PromptMessage, Role};
2407        let arg = |k: &str| -> Option<String> {
2408            request
2409                .arguments
2410                .as_ref()
2411                .and_then(|a| a.get(k))
2412                .and_then(|v| v.as_str())
2413                .map(str::to_string)
2414        };
2415        let (text, description): (String, Option<&str>) = match request.name.as_str() {
2416            "setup-connection" => {
2417                let name = arg("suggestedName");
2418                let named = match &name {
2419                    Some(n) => format!(" called \"{n}\""),
2420                    None => String::new(),
2421                };
2422                (
2423                    format!(
2424                        "I want to add a new database connection{named}.\n\n\
2425First ask whether it is MySQL/MariaDB or SQLite. For MySQL/MariaDB, use \"add_connection\" and ask for: name, host, port (default 3306), user, database (optional), ssl (default false), policy preset (read-only | dev | admin), and optional SSH tunnel (host/port/user/keyPath). The tool will then prompt me for the password via elicitation. Do NOT include the password in the tool arguments. For SQLite, use \"add_sqlite_connection\" and ask for name, path, database/schema (usually main), and policy preset; no password is used."
2426                    ),
2427                    Some("Set up a new database connection"),
2428                )
2429            }
2430            "analyze-table" => {
2431                let Some(connection) = arg("connection") else {
2432                    return Err(rmcp::ErrorData::invalid_params(
2433                        "missing required argument \"connection\"",
2434                        None,
2435                    ));
2436                };
2437                let Some(table) = arg("table") else {
2438                    return Err(rmcp::ErrorData::invalid_params(
2439                        "missing required argument \"table\"",
2440                        None,
2441                    ));
2442                };
2443                let database = arg("database");
2444                let in_db = match &database {
2445                    Some(d) => format!(" in database `{d}`"),
2446                    None => String::new(),
2447                };
2448                (
2449                    format!(
2450                        "Analyze table `{table}`{in_db} on connection \"{connection}\". \
2451Use only read-only tools: describe_table, list_databases, and query (SELECT/SHOW or read-only SQLite PRAGMA only). Specifically: \
24521) describe schema, 2) inspect indexes (SHOW INDEX for MySQL/MariaDB; PRAGMA index_list/index_info for SQLite), 3) SELECT COUNT(*), 4) SELECT * LIMIT 5. Summarize findings."
2453                    ),
2454                    Some("Analyze a table"),
2455                )
2456            }
2457            other => {
2458                return Err(rmcp::ErrorData::invalid_params(
2459                    format!("unknown prompt: {other:?}"),
2460                    None,
2461                ));
2462            }
2463        };
2464        Ok(rmcp::model::GetPromptResponse::from(
2465            GetPromptResult::new(vec![PromptMessage::new_text(Role::User, text)])
2466                .with_description(description.unwrap_or_default()),
2467        ))
2468    }
2469
2470    // ---- Resources (legacy `server/resources.ts` parity) ----
2471
2472    fn list_resources(
2473        &self,
2474        _request: Option<rmcp::model::PaginatedRequestParams>,
2475        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
2476    ) -> impl Future<Output = Result<rmcp::model::ListResourcesResult, rmcp::ErrorData>> + '_ {
2477        let resource = rmcp::model::Resource::new("sequel-mcp://connections", "connections")
2478            .with_title("Configured connections")
2479            .with_description("JSON listing of saved connections (no secrets).")
2480            .with_mime_type("application/json");
2481        std::future::ready(Ok(rmcp::model::ListResourcesResult::with_all_items(vec![
2482            resource,
2483        ])))
2484    }
2485
2486    async fn read_resource(
2487        &self,
2488        request: rmcp::model::ReadResourceRequestParams,
2489        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
2490    ) -> Result<rmcp::model::ReadResourceResponse, rmcp::ErrorData> {
2491        use rmcp::model::{ReadResourceResult, ResourceContents};
2492        if request.uri != *"sequel-mcp://connections" {
2493            return Err(rmcp::ErrorData::invalid_params(
2494                format!("unknown resource: {:?}", request.uri),
2495                None,
2496            ));
2497        }
2498        let cfg = self
2499            .ctx
2500            .config
2501            .load()
2502            .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
2503        let presets: Vec<&str> = crate::policy::model::POLICY_PRESET_NAMES.to_vec();
2504        let items: Vec<serde_json::Value> = cfg
2505            .connections
2506            .iter()
2507            .map(|c| {
2508                let (host, port, user, ssh_json) = match c {
2509                    crate::config::Connection::Mysql(m) => {
2510                        let ssh_json = m.ssh.as_ref().map(|ssh| {
2511                            json!({
2512                                "host": ssh.host,
2513                                "user": ssh.user,
2514                                "docker": ssh.docker.as_ref().map(|d| json!({
2515                                    "container": d.container,
2516                                    "bridgeTool": d.bridge_tool.as_str(),
2517                                })),
2518                            })
2519                        });
2520                        (json!(m.host), json!(m.port), json!(m.user), ssh_json)
2521                    }
2522                    crate::config::Connection::Sqlite(_) => (
2523                        serde_json::Value::Null,
2524                        serde_json::Value::Null,
2525                        serde_json::Value::Null,
2526                        None,
2527                    ),
2528                };
2529                let has_password = c.is_mysql()
2530                    && self
2531                        .ctx
2532                        .secrets
2533                        .has_password(c.name(), mysql_user(c).as_deref().unwrap_or(""));
2534                json!({
2535                    "name": c.name(),
2536                    "driver": if c.is_mysql() { "mysql" } else { "sqlite" },
2537                    "host": host,
2538                    "port": port,
2539                    "user": user,
2540                    "path": if c.is_mysql() { serde_json::Value::Null } else {
2541                        match c {
2542                            crate::config::Connection::Sqlite(s) => json!(s.path),
2543                            _ => serde_json::Value::Null,
2544                        }
2545                    },
2546                    "database": c.database(),
2547                    "ssh": ssh_json,
2548                    "policy": c.policy(),
2549                    "presets": presets,
2550                    "hasPassword": has_password,
2551                })
2552            })
2553            .collect();
2554        let text = serde_json::to_string_pretty(&json!({ "connections": items }))
2555            .unwrap_or_else(|_| "{\n  \"connections\": []\n}".into());
2556        Ok(rmcp::model::ReadResourceResponse::from(
2557            ReadResourceResult::new(vec![
2558                ResourceContents::text(text, "sequel-mcp://connections")
2559                    .with_mime_type("application/json"),
2560            ]),
2561        ))
2562    }
2563
2564    async fn call_tool(
2565        &self,
2566        request: rmcp::model::CallToolRequestParams,
2567        context: rmcp::service::RequestContext<rmcp::RoleServer>,
2568    ) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
2569        let name = request.name.as_ref();
2570        if name == "add_connection" {
2571            let arg_value =
2572                serde_json::Value::Object(request.arguments.clone().unwrap_or_default());
2573            let Ok(p) = serde_json::from_value::<AddConnectionParams>(arg_value) else {
2574                return Ok(CallToolResponse::Complete(error_tool_result(
2575                    "invalid arguments for add_connection",
2576                )));
2577            };
2578            return self.call_add_connection(p, context.peer.clone()).await;
2579        }
2580        if name == "restore_backup" {
2581            let arg_value =
2582                serde_json::Value::Object(request.arguments.clone().unwrap_or_default());
2583            let Ok(p) = serde_json::from_value::<RestoreParams>(arg_value) else {
2584                return Ok(CallToolResponse::Complete(error_tool_result(
2585                    "invalid arguments for restore_backup",
2586                )));
2587            };
2588            let modern = context
2589                .meta
2590                .protocol_version()
2591                .map(|v| v >= rmcp::model::ProtocolVersion::V_2026_07_28)
2592                .unwrap_or(false);
2593            return self
2594                .call_restore(p, modern, context.peer.clone(), request)
2595                .await;
2596        }
2597        if name == "query" || name == "execute" {
2598            let expect_read_only = name == "query";
2599            let tool: &'static str = if expect_read_only { "query" } else { "execute" };
2600            let arg_value =
2601                serde_json::Value::Object(request.arguments.clone().unwrap_or_default());
2602            let params: Result<SqlParams, _> = serde_json::from_value(arg_value);
2603            let Ok(p) = params else {
2604                return Ok(CallToolResponse::Complete(error_tool_result(
2605                    "invalid arguments for query/execute",
2606                )));
2607            };
2608            if p.sql.len() > super::limits::MAX_TOOL_ARGUMENT_BYTES {
2609                return Ok(CallToolResponse::Complete(error_tool_result(format!(
2610                    "[argument_too_large] sql argument exceeds {} bytes",
2611                    super::limits::MAX_TOOL_ARGUMENT_BYTES
2612                ))));
2613            }
2614
2615            // Modern (2026-07-28) era: rmcp's stdio server does not
2616            // propagate per-request _meta capabilities into the legacy
2617            // elicit path, so approvals go through MRTR here. The wire
2618            // _meta lives in the RequestContext, not the deserialized
2619            // params.
2620            let modern = context
2621                .meta
2622                .protocol_version()
2623                .map(|v| v >= rmcp::model::ProtocolVersion::V_2026_07_28)
2624                .unwrap_or(false);
2625
2626            if modern {
2627                return self
2628                    .call_sql_modern(request, p, expect_read_only, tool)
2629                    .await;
2630            }
2631
2632            let args = RunSqlArgs {
2633                connection: p.connection,
2634                sql: p.sql,
2635                database: p.database,
2636                expected_ddl_targets: None,
2637            };
2638            // Gate on the blocking pool; elicitation asks flow back over a
2639            // channel and are answered from the async runtime by a pump
2640            // thread (block_on from a blocking thread is legal).
2641            let (ask_tx, ask_rx) = std::sync::mpsc::sync_channel::<super::confirm::ElicitAsk>(4);
2642            let sink = super::confirm::ElicitationSink::new(ask_tx);
2643            let deps = self.gate_deps_blocking(Box::new(sink));
2644            let handle =
2645                tokio::task::spawn_blocking(move || gate::run_sql(&deps, &args, expect_read_only));
2646            let peer = context.peer.clone();
2647            let ipc_hub = self.ctx.approval_ipc.clone();
2648            let pump = tokio::task::spawn_blocking(move || {
2649                while let Ok(ask) = ask_rx.recv() {
2650                    match ask {
2651                        super::confirm::ElicitAsk::Request { message, reply } => {
2652                            let mut outcome = tokio::runtime::Handle::current().block_on(async {
2653                                super::confirm::run_elicitation(&peer, message.clone()).await
2654                            });
2655                            // Elicitation unavailable (client cannot
2656                            // prompt): fall back to the authenticated
2657                            // companion IPC and fail closed there too.
2658                            if matches!(
2659                                outcome,
2660                                crate::approval::ConfirmOutcome::Unavailable { .. }
2661                            ) && let Some(hub) = &ipc_hub
2662                            {
2663                                let request = gate::ApprovalRequest::from_message(&message);
2664                                outcome =
2665                                    tokio::runtime::Handle::current().block_on(hub.ask(request));
2666                            }
2667                            let _ = reply.send(outcome);
2668                        }
2669                    }
2670                }
2671            });
2672            let outcome = match handle.await {
2673                Ok(r) => r,
2674                Err(e) => Err(gate::GateError::Execution(format!("gate task failed: {e}"))),
2675            };
2676            // The pump exits once the gate (and its sink) drop the channel.
2677            let _ = pump.await;
2678            let result = match outcome {
2679                Ok(out) => super::json_tool_result(gate::outcome_to_json(&out)),
2680                Err(e) => super::error_tool_result(e.to_string()),
2681            };
2682            return Ok(CallToolResponse::Complete(result));
2683        }
2684
2685        // Everything else: delegate to the macro-generated router.
2686        let tcc = ToolCallContext::new(self, request, context);
2687        Self::tool_router().call(tcc).await
2688    }
2689}