1use 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 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 #[serde(default)]
44 pub connection: Option<String>,
45 pub sql: String,
47 #[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 pub path: String,
63 #[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 #[serde(default)]
93 pub ssh_docker_container: Option<String>,
94 #[serde(default)]
96 pub ssh_docker_bridge_tool: Option<String>,
97 #[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 #[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 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 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 pub backup_id: i64,
211 #[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 #[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 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 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 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 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 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 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 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 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 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 let digest =
1459 super::mrtr::operation_digest(&p.sql, conn.name(), fallback.as_deref(), cfg.revision);
1460
1461 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 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 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 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 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 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 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 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 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 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 let resolutions = match resolve_restore_plan(conn, detail, plan) {
2039 Ok(res) => res,
2040 Err(reason) => {
2041 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 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 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#[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
2337struct 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 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 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 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 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 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 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 let tcc = ToolCallContext::new(self, request, context);
2687 Self::tool_router().call(tcc).await
2688 }
2689}