Skip to main content

basalt/
mcp.rs

1//! MCP server frontend for agents that need to inspect and operate a Basalt database.
2//!
3//! The wire protocol is provided by the official `rmcp` implementation.  This
4//! module only owns the database-facing tools, bounded result conversion, and
5//! the small `basalt mcp` command-line parser.
6
7use std::collections::BTreeMap;
8use std::fmt;
9use std::sync::{Arc, Mutex, MutexGuard};
10use std::time::{Duration, Instant};
11
12use rmcp::handler::server::{
13    router::tool::ToolRouter,
14    tool::{InputResponses as ToolInputResponses, RequestState as ToolRequestState},
15    wrapper::Parameters,
16};
17use rmcp::model::{
18    CallToolResponse, CallToolResult, ElicitRequest, ElicitRequestParams, ElicitResult,
19    ElicitationAction, ElicitationSchema, Implementation, InputRequest, InputRequiredResult,
20    InputResponses, ListResourcesResult, ReadResourceRequestParams, ReadResourceResponse,
21    ReadResourceResult, RequestStateCodec, Resource, ResourceContents, SealOptions,
22    ServerCapabilities, ServerInfo,
23};
24use rmcp::service::RequestContext;
25use rmcp::{
26    ErrorData, Json, RoleServer, ServerHandler, ServiceExt, tool, tool_handler, tool_router,
27};
28use schemars::JsonSchema;
29use serde::{Deserialize, Serialize};
30use sha2::{Digest, Sha256};
31use uuid::Uuid;
32
33use crate::database::{Connection, Database};
34use crate::db::{Column, StatementResult};
35use crate::engine::MCP_EXECUTION_WORK_LIMIT;
36use crate::sql::ast::Statement;
37use crate::sql::parser::parse;
38use crate::types::{ColumnType, Value};
39
40pub const HELP: &str = "Basalt MCP server\n\n\
41Usage:\n  basalt mcp [OPTIONS] [DATABASE_PATH | :memory:]\n\n\
42Options:\n  -d, --database PATH  Database path (default: :memory:)\n  -h, --help           Print this help\n\n\
43Workspace mode:\n  --workspace PATH     Open a Basalt workspace (read-only by default)\n  --init-workspace     Create --workspace PATH when it does not exist\n  --allow-writes       Enable workspace apply/undo and direct SQL writes\n\n\
44The server speaks MCP over stdin/stdout. Diagnostics go to stderr.\n";
45
46const DEFAULT_MAX_ROWS: usize = 100;
47const MAX_ROWS: usize = 1_000;
48const MAX_SQL_BYTES: usize = 1_048_576;
49const MAX_STATEMENTS: usize = 100;
50const MAX_MUTATING_STATEMENTS: usize = 32;
51const MAX_OUTPUT_BYTES: usize = 1_048_576;
52const SCHEMA_URI: &str = "basalt://schema";
53const WRITE_APPROVAL_INPUT_KEY: &str = "basalt_write_approval";
54const WRITE_APPROVAL_STATE_VERSION: u8 = 1;
55const WRITE_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
56
57/// Parsed options for the `basalt mcp` subcommand.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct McpOptions {
60    pub database: String,
61    pub workspace: Option<String>,
62    pub init_workspace: bool,
63    pub allow_writes: bool,
64    pub help: bool,
65}
66
67impl Default for McpOptions {
68    fn default() -> Self {
69        Self {
70            database: ":memory:".into(),
71            workspace: None,
72            init_workspace: false,
73            allow_writes: false,
74            help: false,
75        }
76    }
77}
78
79/// Parse arguments after the `mcp` subcommand.
80pub fn parse_args(args: &[String]) -> Result<McpOptions, McpCliError> {
81    let mut options = McpOptions::default();
82    let mut database = None;
83    let mut workspace = None;
84    let mut init_workspace = false;
85    let mut allow_writes = false;
86    let mut positional_only = false;
87    let mut index = 0;
88
89    while index < args.len() {
90        let argument = args[index].as_str();
91        if !positional_only && argument == "--" {
92            positional_only = true;
93            index += 1;
94            continue;
95        }
96
97        if !positional_only {
98            match argument {
99                "-h" | "--help" => {
100                    options.help = true;
101                    index += 1;
102                    continue;
103                }
104                "-d" | "--database" => {
105                    index += 1;
106                    let value = args.get(index).ok_or_else(|| {
107                        McpCliError::new(format!("{argument} requires a value; try --help"))
108                    })?;
109                    set_database(&mut database, value)?;
110                    index += 1;
111                    continue;
112                }
113                "--workspace" => {
114                    index += 1;
115                    let value = args.get(index).ok_or_else(|| {
116                        McpCliError::new(format!("{argument} requires a value; try --help"))
117                    })?;
118                    set_workspace(&mut workspace, value)?;
119                    index += 1;
120                    continue;
121                }
122                "--init-workspace" => {
123                    init_workspace = true;
124                    index += 1;
125                    continue;
126                }
127                "--allow-writes" | "--write" => {
128                    allow_writes = true;
129                    index += 1;
130                    continue;
131                }
132                _ => {}
133            }
134
135            if let Some(value) = argument.strip_prefix("--database=") {
136                set_database(&mut database, value)?;
137                index += 1;
138                continue;
139            }
140            if let Some(value) = argument.strip_prefix("--workspace=") {
141                set_workspace(&mut workspace, value)?;
142                index += 1;
143                continue;
144            }
145            if argument.starts_with('-') {
146                return Err(McpCliError::new(format!(
147                    "unknown option {argument:?}; try --help"
148                )));
149            }
150        }
151
152        set_database(&mut database, argument)?;
153        index += 1;
154    }
155
156    if workspace.is_some() && database.is_some() {
157        return Err(McpCliError::new(
158            "choose --workspace or --database, not both",
159        ));
160    }
161    if init_workspace && workspace.is_none() {
162        return Err(McpCliError::new(
163            "--init-workspace requires --workspace PATH",
164        ));
165    }
166    if let Some(database) = database {
167        options.database = database;
168    }
169    options.workspace = workspace;
170    options.init_workspace = init_workspace;
171    options.allow_writes = allow_writes;
172    Ok(options)
173}
174
175fn set_database(database: &mut Option<String>, value: &str) -> Result<(), McpCliError> {
176    if value.is_empty() {
177        return Err(McpCliError::new("database path cannot be empty"));
178    }
179    if database.is_some() {
180        return Err(McpCliError::new(
181            "database path provided more than once; choose one path",
182        ));
183    }
184    *database = Some(value.into());
185    Ok(())
186}
187
188fn set_workspace(workspace: &mut Option<String>, value: &str) -> Result<(), McpCliError> {
189    if value.is_empty() {
190        return Err(McpCliError::new("workspace path cannot be empty"));
191    }
192    if workspace.is_some() {
193        return Err(McpCliError::new(
194            "workspace path provided more than once; choose one path",
195        ));
196    }
197    *workspace = Some(value.into());
198    Ok(())
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct McpCliError {
203    message: String,
204}
205
206impl McpCliError {
207    fn new(message: impl Into<String>) -> Self {
208        Self {
209            message: message.into(),
210        }
211    }
212}
213
214impl fmt::Display for McpCliError {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        f.write_str(&self.message)
217    }
218}
219
220impl std::error::Error for McpCliError {}
221
222#[derive(Clone)]
223enum McpTarget {
224    Database(Database),
225    Workspace(crate::workspace::Workspace),
226}
227
228impl McpTarget {
229    fn workspace(&self) -> Result<crate::workspace::Workspace, String> {
230        match self {
231            McpTarget::Workspace(workspace) => Ok(workspace.clone()),
232            McpTarget::Database(_) => Err(
233                "this tool requires `basalt mcp --workspace PATH`; direct database mode has no workspace lifecycle"
234                    .to_string(),
235            ),
236        }
237    }
238
239    fn is_workspace(&self) -> bool {
240        matches!(self, McpTarget::Workspace(_))
241    }
242}
243
244/// Start the stdio MCP server for a direct database in read-only mode.
245pub fn run(database: Database) -> Result<(), String> {
246    run_database(database, false)
247}
248
249pub fn run_database(database: Database, allow_writes: bool) -> Result<(), String> {
250    run_target(McpTarget::Database(database), allow_writes)
251}
252
253pub fn run_workspace(
254    workspace: crate::workspace::Workspace,
255    allow_writes: bool,
256) -> Result<(), String> {
257    run_target(McpTarget::Workspace(workspace), allow_writes)
258}
259
260fn run_target(target: McpTarget, allow_writes: bool) -> Result<(), String> {
261    let runtime = tokio::runtime::Builder::new_multi_thread()
262        .enable_io()
263        .enable_time()
264        .build()
265        .map_err(|error| format!("could not start async runtime: {error}"))?;
266
267    runtime.block_on(async move {
268        let server = BasaltMcp::new(target, allow_writes);
269        let service = server
270            .serve(rmcp::transport::stdio())
271            .await
272            .map_err(|error| format!("could not start MCP transport: {error:?}"))?;
273        service
274            .waiting()
275            .await
276            .map(|_| ())
277            .map_err(|error| format!("MCP transport stopped with an error: {error:?}"))
278    })
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
282struct SqlInput {
283    /// SQL to execute. The server accepts up to 1 MiB, 100 statements, 32 mutating statements, and a bounded execution budget per call.
284    sql: String,
285    /// Maximum rows returned for each SELECT result. Defaults to 100 and is capped at 1,000.
286    #[serde(default)]
287    max_rows: Option<u64>,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
291struct TableInput {
292    /// Table name, matched case-insensitively.
293    table: String,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
297struct WorkspaceSqlInput {
298    /// A write statement or statement sequence to preview.
299    sql: String,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
303struct WorkspaceImportInput {
304    /// New table name for the imported rows. The table must not already exist.
305    table: String,
306    /// `csv`, `json`, or `jsonl`.
307    format: String,
308    /// UTF-8 input content. MCP imports are capped at 16 MiB, 10,000 rows, 256 columns, and 1,000,000 cells; they never read a filesystem path.
309    content: String,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
313struct PlanInput {
314    /// The exact plan identifier returned by workspace_preview.
315    plan_id: String,
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
319struct ChangeInput {
320    /// A change identifier returned by workspace_apply or workspace_undo.
321    change_id: String,
322}
323
324#[derive(Debug, Deserialize, JsonSchema)]
325struct WriteApproval {
326    /// Set to true only when the user approves the described workspace change.
327    approved: bool,
328}
329
330rmcp::elicit_safe!(WriteApproval);
331
332#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)]
333#[serde(rename_all = "snake_case")]
334enum WriteOperation {
335    Import,
336    Apply,
337    Undo,
338}
339
340impl WriteOperation {
341    fn name(self) -> &'static str {
342        match self {
343            Self::Import => "import",
344            Self::Apply => "apply",
345            Self::Undo => "undo",
346        }
347    }
348}
349
350#[derive(Debug, Deserialize, Serialize)]
351struct WriteApprovalState {
352    version: u8,
353    operation: WriteOperation,
354    identity: String,
355}
356
357enum WorkspaceWriteApproval {
358    Approved,
359    InputRequired(InputRequiredResult),
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
363struct DiffInput {
364    /// Optional change identifier; defaults to the latest committed change.
365    #[serde(default)]
366    change_id: Option<String>,
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
370struct ExportInput {
371    /// Table name, matched case-insensitively.
372    table: String,
373    /// `csv`, `jsonl`, or `sql`.
374    format: String,
375}
376
377#[derive(Debug, Clone, Serialize, JsonSchema)]
378struct SqlResult {
379    /// Results in statement order.
380    results: Vec<StatementOutput>,
381    /// Committed database generation after the call.
382    generation: u64,
383    /// Whether a transaction remains open on this MCP session's connection.
384    transaction_open: bool,
385    /// Milliseconds spent executing and converting the result.
386    duration_ms: u64,
387    /// Whether one or more SELECT results were capped by max_rows.
388    rows_truncated: bool,
389}
390
391#[derive(Debug, Clone, Serialize, JsonSchema)]
392#[serde(tag = "type", rename_all = "snake_case")]
393enum StatementOutput {
394    Select {
395        columns: Vec<String>,
396        rows: Vec<Vec<OutputValue>>,
397        rows_total: usize,
398        truncated: bool,
399    },
400    Insert {
401        rows_affected: usize,
402    },
403    Update {
404        rows_affected: usize,
405    },
406    Delete {
407        rows_affected: usize,
408    },
409    CreateTable {
410        name: String,
411    },
412    DropTable {
413        name: String,
414    },
415    CreateIndex {
416        name: String,
417        table: String,
418        column: String,
419    },
420    DropIndex {
421        name: String,
422    },
423    Explain {
424        plan: String,
425    },
426    Begin,
427    Commit,
428    Rollback,
429    Checkpoint,
430    Echo {
431        value: String,
432    },
433}
434
435#[derive(Debug, Clone, Serialize, JsonSchema)]
436#[serde(tag = "type", content = "value", rename_all = "snake_case")]
437enum OutputValue {
438    Null,
439    Integer(i64),
440    Real(String),
441    Text(String),
442    Boolean(bool),
443}
444
445#[derive(Debug, Clone, Serialize, JsonSchema)]
446struct ListTablesResult {
447    tables: Vec<TableInfo>,
448    generation: u64,
449}
450
451#[derive(Debug, Clone, Serialize, JsonSchema)]
452struct TableInfo {
453    name: String,
454    columns: Vec<ColumnInfo>,
455}
456
457#[derive(Debug, Clone, Serialize, JsonSchema)]
458struct ColumnInfo {
459    name: String,
460    r#type: String,
461    not_null: bool,
462    unique: bool,
463    primary_key: bool,
464}
465
466#[derive(Debug, Clone, Serialize, JsonSchema)]
467struct CheckpointResult {
468    generation: u64,
469}
470
471#[derive(Clone)]
472struct BasaltMcp {
473    target: McpTarget,
474    connection: Arc<Mutex<Connection>>,
475    workspace_operation_lock: Arc<Mutex<()>>,
476    request_state_codec: RequestStateCodec,
477    allow_writes: bool,
478    tool_router: ToolRouter<Self>,
479}
480
481impl BasaltMcp {
482    fn new(target: McpTarget, allow_writes: bool) -> Self {
483        let connection_database = match &target {
484            McpTarget::Database(database) => database.clone(),
485            McpTarget::Workspace(_) => Database::in_memory(),
486        };
487        let connection = connection_database.connect();
488        let mut tool_router = Self::tool_router();
489        if target.is_workspace() {
490            tool_router.remove_route("execute");
491        } else {
492            for tool in [
493                "workspace_apply",
494                "workspace_diff",
495                "workspace_export",
496                "workspace_history",
497                "workspace_inspect",
498                "workspace_import",
499                "workspace_plan",
500                "workspace_preview",
501                "workspace_undo",
502            ] {
503                tool_router.remove_route(tool);
504            }
505        }
506        let mut request_state_key = Vec::with_capacity(32);
507        request_state_key.extend_from_slice(Uuid::new_v4().as_bytes());
508        request_state_key.extend_from_slice(Uuid::new_v4().as_bytes());
509        let request_state_codec = RequestStateCodec::try_new(request_state_key)
510            .expect("two UUIDs always provide a sufficiently long request-state key");
511        Self {
512            target,
513            connection: Arc::new(Mutex::new(connection)),
514            workspace_operation_lock: Arc::new(Mutex::new(())),
515            request_state_codec,
516            allow_writes,
517            tool_router,
518        }
519    }
520}
521
522#[tool_router]
523impl BasaltMcp {
524    /// Read rows without allowing a query to mutate the database.
525    #[tool(
526        name = "query",
527        description = "Run bounded read-only SQL. Accepts SELECT and EXPLAIN SELECT only. Use execute for writes, DDL, transactions, and CHECKPOINT. Results are typed, capped by max_rows, limited to a 1 MiB response, and protected by an execution work budget.",
528        annotations(
529            title = "Read from Basalt",
530            read_only_hint = true,
531            destructive_hint = false,
532            idempotent_hint = true,
533            open_world_hint = false
534        )
535    )]
536    async fn query(
537        &self,
538        Parameters(input): Parameters<SqlInput>,
539    ) -> Result<Json<SqlResult>, String> {
540        if self.target.is_workspace() {
541            execute_workspace_sql(
542                self.target.workspace()?,
543                input,
544                true,
545                self.workspace_operation_lock.clone(),
546            )
547            .await
548            .map(Json)
549        } else {
550            execute_sql(self.connection.clone(), input, true)
551                .await
552                .map(Json)
553        }
554    }
555
556    /// Execute arbitrary SQL on the session connection.
557    #[tool(
558        name = "execute",
559        description = "Execute bounded SQL against a configured direct Basalt database. This tool is unavailable in workspace mode. Use for INSERT, UPDATE, DELETE, CREATE/DROP, transactions, CHECKPOINT, and SELECT when needed. Statements run in order on one connection; use BEGIN and COMMIT for an explicit multi-call transaction. This tool can change or delete data.",
560        annotations(
561            title = "Execute SQL",
562            read_only_hint = false,
563            destructive_hint = true,
564            idempotent_hint = false,
565            open_world_hint = false
566        )
567    )]
568    async fn execute(
569        &self,
570        Parameters(input): Parameters<SqlInput>,
571    ) -> Result<Json<SqlResult>, String> {
572        if self.target.is_workspace() {
573            return Err(
574                "direct SQL writes are disabled in workspace mode; use workspace_preview followed by workspace_apply"
575                    .to_string(),
576            );
577        }
578        if !self.allow_writes {
579            return Err(
580                "direct SQL writes are disabled; restart with --allow-writes after explicit operator approval"
581                    .to_string(),
582            );
583        }
584        execute_sql(self.connection.clone(), input, false)
585            .await
586            .map(Json)
587    }
588
589    /// List table names and column metadata.
590    #[tool(
591        name = "list_tables",
592        description = "List every table and its column metadata in deterministic order. This reads committed schema state without returning table rows.",
593        annotations(
594            title = "List Basalt tables",
595            read_only_hint = true,
596            destructive_hint = false,
597            idempotent_hint = true,
598            open_world_hint = false
599        )
600    )]
601    async fn list_tables(&self) -> Result<Json<ListTablesResult>, String> {
602        let connection = self.connection.clone();
603        let target = self.target.clone();
604        let workspace_operation_lock = self.workspace_operation_lock.clone();
605        tokio::task::spawn_blocking(move || match target {
606            McpTarget::Database(database) => {
607                with_connection(&connection, |_| table_info(&database))
608            }
609            McpTarget::Workspace(workspace) => {
610                let _operation = lock_workspace_operations(&workspace_operation_lock)?;
611                let database = workspace
612                    .database()
613                    .map_err(|error| format!("could not open workspace database: {error}"))?;
614                table_info(&database)
615            }
616        })
617        .await
618        .map_err(|error| format!("table listing task failed: {error}"))?
619        .and_then(|response| {
620            ensure_output_size(&response, "table metadata")?;
621            Ok(response)
622        })
623        .map(Json)
624    }
625
626    /// Describe one table.
627    #[tool(
628        name = "describe_table",
629        description = "Return the columns and constraints for one table. Table names are matched case-insensitively.",
630        annotations(
631            title = "Describe a Basalt table",
632            read_only_hint = true,
633            destructive_hint = false,
634            idempotent_hint = true,
635            open_world_hint = false
636        )
637    )]
638    async fn describe_table(
639        &self,
640        Parameters(input): Parameters<TableInput>,
641    ) -> Result<Json<TableInfo>, String> {
642        let connection = self.connection.clone();
643        let target = self.target.clone();
644        let workspace_operation_lock = self.workspace_operation_lock.clone();
645        tokio::task::spawn_blocking(move || {
646            let operation = |database: &Database| {
647                let name = database
648                    .table_names()
649                    .map_err(|error| format!("could not describe table: {error}"))?
650                    .into_iter()
651                    .find(|name| name.eq_ignore_ascii_case(&input.table))
652                    .ok_or_else(|| {
653                        format!("could not describe table: no such table: {}", input.table)
654                    })?;
655                let columns = database
656                    .columns(&name)
657                    .map_err(|error| format!("could not describe table: {error}"))?;
658                let response = TableInfo {
659                    name,
660                    columns: columns.into_iter().map(column_info).collect(),
661                };
662                ensure_output_size(&response, "table metadata")?;
663                Ok(response)
664            };
665            match target {
666                McpTarget::Database(database) => {
667                    with_connection(&connection, |_| operation(&database))
668                }
669                McpTarget::Workspace(workspace) => {
670                    let _operation = lock_workspace_operations(&workspace_operation_lock)?;
671                    let database = workspace
672                        .database()
673                        .map_err(|error| format!("could not open workspace database: {error}"))?;
674                    operation(&database)
675                }
676            }
677        })
678        .await
679        .map_err(|error| format!("table description task failed: {error}"))?
680        .map(Json)
681    }
682
683    /// Flush durable state to the snapshot and clear the WAL.
684    #[tool(
685        name = "checkpoint",
686        description = "Flush committed state to the durable snapshot and clear old WAL frames. It is safe to call repeatedly and is a no-op for :memory: databases. It fails while an explicit transaction is open.",
687        annotations(
688            title = "Checkpoint Basalt",
689            read_only_hint = false,
690            destructive_hint = false,
691            idempotent_hint = true,
692            open_world_hint = false
693        )
694    )]
695    async fn checkpoint(&self) -> Result<Json<CheckpointResult>, String> {
696        if !self.allow_writes {
697            return Err(
698                "checkpoint changes durable files; restart with --allow-writes after explicit operator approval"
699                    .to_string(),
700            );
701        }
702        let connection = self.connection.clone();
703        let target = self.target.clone();
704        let workspace_operation_lock = self.workspace_operation_lock.clone();
705        tokio::task::spawn_blocking(move || match target {
706            McpTarget::Database(_) => with_connection(&connection, |connection| {
707                connection
708                    .execute_sql("CHECKPOINT")
709                    .map_err(|error| format!("checkpoint failed: {error}"))?;
710                let response = CheckpointResult {
711                    generation: connection.generation(),
712                };
713                ensure_output_size(&response, "checkpoint result")?;
714                Ok(response)
715            }),
716            McpTarget::Workspace(workspace) => {
717                let _operation = lock_workspace_operations(&workspace_operation_lock)?;
718                let database = workspace
719                    .database()
720                    .map_err(|error| format!("could not open workspace database: {error}"))?;
721                database
722                    .checkpoint()
723                    .map_err(|error| format!("checkpoint failed: {error}"))?;
724                let response = CheckpointResult {
725                    generation: database.generation(),
726                };
727                ensure_output_size(&response, "checkpoint result")?;
728                Ok(response)
729            }
730        })
731        .await
732        .map_err(|error| format!("checkpoint task failed: {error}"))?
733        .map(Json)
734    }
735
736    /// Inspect the configured workspace without returning table rows.
737    #[tool(
738        name = "workspace_inspect",
739        description = "Inspect the configured Basalt workspace, including its format version, tables, columns, and row counts. This tool is available only in --workspace mode.",
740        annotations(
741            title = "Inspect Basalt workspace",
742            read_only_hint = true,
743            destructive_hint = false,
744            idempotent_hint = true,
745            open_world_hint = false
746        )
747    )]
748    async fn workspace_inspect(&self) -> Result<Json<crate::workspace::InspectReport>, String> {
749        let workspace = self.target.workspace()?;
750        let workspace_operation_lock = self.workspace_operation_lock.clone();
751        let response = tokio::task::spawn_blocking(move || {
752            let _operation = lock_workspace_operations(&workspace_operation_lock)
753                .map_err(crate::workspace::WorkspaceError::Invalid)?;
754            crate::workspace::mcp_inspect(&workspace)
755        })
756        .await
757        .map_err(|error| format!("workspace inspection task failed: {error}"))?
758        .map_err(|error| error.to_string())?;
759        ensure_output_size(&response, "workspace inspection")?;
760        Ok(Json(response))
761    }
762
763    /// Import bounded structured-data content into a workspace with recovery.
764    #[tool(
765        name = "workspace_import",
766        description = "Import bounded UTF-8 CSV, JSON, or JSON Lines content into a new workspace table and create a recoverable change record. Content is limited to 16 MiB, 10,000 rows, 256 columns, and 1,000,000 cells. No filesystem path is accepted. Writes are disabled unless the MCP process was started with --allow-writes; modern clients advertising form elicitation receive an input_required approval request and retry with the response, while legacy initialized clients receive elicitation/create. Use the CLI for SQL dump imports or larger imports.",
767        output_schema = rmcp::handler::server::tool::schema_for_output::<crate::workspace::ImportReport>(),
768        annotations(
769            title = "Import workspace data",
770            read_only_hint = false,
771            destructive_hint = false,
772            idempotent_hint = true,
773            open_world_hint = false
774        )
775    )]
776    async fn workspace_import(
777        &self,
778        Parameters(input): Parameters<WorkspaceImportInput>,
779        ToolInputResponses(input_responses): ToolInputResponses,
780        ToolRequestState(request_state): ToolRequestState,
781        context: RequestContext<RoleServer>,
782    ) -> Result<CallToolResponse, String> {
783        if !self.allow_writes {
784            return Err(
785                "workspace writes are disabled; restart with --allow-writes after explicit operator approval"
786                    .to_string(),
787            );
788        }
789        if input.content.len() > crate::workspace::MAX_MCP_IMPORT_BYTES {
790            return Err(format!(
791                "MCP import content exceeds the {} MiB limit",
792                crate::workspace::MAX_MCP_IMPORT_BYTES / (1024 * 1024)
793            ));
794        }
795        let identity = write_operation_identity(
796            WriteOperation::Import,
797            &[&input.format, &input.table, &input.content],
798        );
799        if let WorkspaceWriteApproval::InputRequired(result) = self
800            .request_workspace_write_approval(
801                &context,
802                WriteOperation::Import,
803                &identity,
804                format!(
805                    "Approve importing {} bytes of {} content into workspace table {:?}?",
806                    input.content.len(),
807                    input.format,
808                    input.table
809                ),
810                request_state,
811                input_responses,
812            )
813            .await?
814        {
815            return Ok(result.into());
816        }
817        let workspace = self.target.workspace()?;
818        let workspace_operation_lock = self.workspace_operation_lock.clone();
819        let response = tokio::task::spawn_blocking(move || {
820            let _operation = lock_workspace_operations(&workspace_operation_lock)
821                .map_err(crate::workspace::WorkspaceError::Invalid)?;
822            crate::workspace::mcp_import(
823                &workspace,
824                Some(&input.table),
825                &input.format,
826                &input.content,
827            )
828        })
829        .await
830        .map_err(|error| format!("workspace import task failed: {error}"))?
831        .map_err(|error| error.to_string())?;
832        ensure_output_size(&response, "workspace import")?;
833        complete_json(response)
834    }
835
836    /// Preview a workspace mutation and persist its exact plan.
837    #[tool(
838        name = "workspace_preview",
839        description = "Preview a mutating SQL sequence in an isolated transaction and return the exact SQL, impact summary, and plan ID. A workspace MCP plan may affect at most 10,000 rows. The workspace data is not changed; apply the returned plan explicitly.",
840        annotations(
841            title = "Preview workspace write",
842            read_only_hint = false,
843            destructive_hint = false,
844            idempotent_hint = true,
845            open_world_hint = false
846        )
847    )]
848    async fn workspace_preview(
849        &self,
850        Parameters(input): Parameters<WorkspaceSqlInput>,
851    ) -> Result<Json<crate::workspace::PlanReport>, String> {
852        let workspace = self.target.workspace()?;
853        let workspace_operation_lock = self.workspace_operation_lock.clone();
854        let response = tokio::task::spawn_blocking(move || {
855            let _operation = lock_workspace_operations(&workspace_operation_lock)
856                .map_err(crate::workspace::WorkspaceError::Invalid)?;
857            crate::workspace::mcp_preview(&workspace, &input.sql, MAX_OUTPUT_BYTES)
858        })
859        .await
860        .map_err(|error| format!("workspace preview task failed: {error}"))?
861        .map_err(|error| error.to_string())?;
862        ensure_output_size(&response, "workspace preview")?;
863        Ok(Json(response))
864    }
865
866    /// Reload a persisted workspace plan by its stable identifier.
867    #[tool(
868        name = "workspace_plan",
869        description = "Load one persisted workspace plan by ID and return its exact SQL, base state, and impact summary. Use this to recover review context after a restart; it never changes workspace data.",
870        annotations(
871            title = "Load workspace plan",
872            read_only_hint = true,
873            destructive_hint = false,
874            idempotent_hint = true,
875            open_world_hint = false
876        )
877    )]
878    async fn workspace_plan(
879        &self,
880        Parameters(input): Parameters<PlanInput>,
881    ) -> Result<Json<crate::workspace::PlanReport>, String> {
882        let workspace = self.target.workspace()?;
883        let workspace_operation_lock = self.workspace_operation_lock.clone();
884        let response = tokio::task::spawn_blocking(move || {
885            let _operation = lock_workspace_operations(&workspace_operation_lock)
886                .map_err(crate::workspace::WorkspaceError::Invalid)?;
887            crate::workspace::mcp_plan(&workspace, &input.plan_id, MAX_OUTPUT_BYTES)
888        })
889        .await
890        .map_err(|error| format!("workspace plan task failed: {error}"))?
891        .map_err(|error| error.to_string())?;
892        ensure_output_size(&response, "workspace plan")?;
893        Ok(Json(response))
894    }
895
896    /// Apply one exact workspace plan when writes are enabled.
897    #[tool(
898        name = "workspace_apply",
899        description = "Apply exactly one plan returned by workspace_preview. A workspace MCP plan may affect at most 10,000 rows. Writes are disabled unless the MCP process was started with --allow-writes; modern clients advertising form elicitation receive an input_required approval request and retry with the response, while legacy initialized clients receive elicitation/create. Stale plans are rejected and a recovery point is created first.",
900        output_schema = rmcp::handler::server::tool::schema_for_output::<crate::workspace::ApplyReport>(),
901        annotations(
902            title = "Apply workspace plan",
903            read_only_hint = false,
904            destructive_hint = true,
905            idempotent_hint = true,
906            open_world_hint = false
907        )
908    )]
909    async fn workspace_apply(
910        &self,
911        Parameters(input): Parameters<PlanInput>,
912        ToolInputResponses(input_responses): ToolInputResponses,
913        ToolRequestState(request_state): ToolRequestState,
914        context: RequestContext<RoleServer>,
915    ) -> Result<CallToolResponse, String> {
916        if !self.allow_writes {
917            return Err(
918                "workspace writes are disabled; restart with --allow-writes after explicit operator approval"
919                .to_string(),
920            );
921        }
922        let identity = write_operation_identity(WriteOperation::Apply, &[&input.plan_id]);
923        if let WorkspaceWriteApproval::InputRequired(result) = self
924            .request_workspace_write_approval(
925                &context,
926                WriteOperation::Apply,
927                &identity,
928                format!(
929                    "Approve applying Basalt workspace plan {}? Review its exact SQL and impact with workspace_plan first.",
930                    input.plan_id
931                ),
932                request_state,
933                input_responses,
934            )
935            .await?
936        {
937            return Ok(result.into());
938        }
939        let workspace = self.target.workspace()?;
940        let workspace_operation_lock = self.workspace_operation_lock.clone();
941        let response = tokio::task::spawn_blocking(move || {
942            let _operation = lock_workspace_operations(&workspace_operation_lock)
943                .map_err(crate::workspace::WorkspaceError::Invalid)?;
944            crate::workspace::mcp_apply(&workspace, &input.plan_id)
945        })
946        .await
947        .map_err(|error| format!("workspace apply task failed: {error}"))?
948        .map_err(|error| error.to_string())?;
949        ensure_output_size(&response, "workspace apply")?;
950        complete_json(response)
951    }
952
953    /// Return the workspace change ledger.
954    #[tool(
955        name = "workspace_history",
956        description = "List workspace apply and undo records, including recovery status. This tool is available only in --workspace mode.",
957        annotations(
958            title = "Read workspace history",
959            read_only_hint = false,
960            destructive_hint = false,
961            idempotent_hint = true,
962            open_world_hint = false
963        )
964    )]
965    async fn workspace_history(&self) -> Result<Json<Vec<crate::workspace::HistoryEntry>>, String> {
966        let workspace = self.target.workspace()?;
967        let workspace_operation_lock = self.workspace_operation_lock.clone();
968        let response = tokio::task::spawn_blocking(move || {
969            let _operation = lock_workspace_operations(&workspace_operation_lock)
970                .map_err(crate::workspace::WorkspaceError::Invalid)?;
971            crate::workspace::mcp_history(&workspace)
972        })
973        .await
974        .map_err(|error| format!("workspace history task failed: {error}"))?
975        .map_err(|error| error.to_string())?;
976        ensure_output_size(&response, "workspace history")?;
977        Ok(Json(response))
978    }
979
980    /// Compare a committed change recovery point with current workspace state.
981    #[tool(
982        name = "workspace_diff",
983        description = "Compare a committed workspace change recovery point with the current state at table level. The result does not claim row-by-row patch precision and refuses comparisons larger than 10,000 rows; use the CLI diff for larger workspaces.",
984        annotations(
985            title = "Diff workspace change",
986            read_only_hint = false,
987            destructive_hint = false,
988            idempotent_hint = true,
989            open_world_hint = false
990        )
991    )]
992    async fn workspace_diff(
993        &self,
994        Parameters(input): Parameters<DiffInput>,
995    ) -> Result<Json<crate::workspace::DiffReport>, String> {
996        let workspace = self.target.workspace()?;
997        let workspace_operation_lock = self.workspace_operation_lock.clone();
998        tokio::task::spawn_blocking(move || {
999            let _operation = lock_workspace_operations(&workspace_operation_lock)
1000                .map_err(crate::workspace::WorkspaceError::Invalid)?;
1001            crate::workspace::mcp_diff(&workspace, input.change_id.as_deref())
1002        })
1003        .await
1004        .map_err(|error| format!("workspace diff task failed: {error}"))?
1005        .map_err(|error| error.to_string())
1006        .and_then(|response| {
1007            ensure_output_size(&response, "workspace diff")?;
1008            Ok(Json(response))
1009        })
1010    }
1011
1012    /// Undo the latest committed workspace change when writes are enabled.
1013    #[tool(
1014        name = "workspace_undo",
1015        description = "Undo the latest committed workspace change by restoring its verified recovery point. Writes are disabled unless --allow-writes is enabled; modern clients advertising form elicitation receive an input_required approval request and retry with the response, while legacy initialized clients receive elicitation/create. Later work is never discarded implicitly.",
1016        output_schema = rmcp::handler::server::tool::schema_for_output::<crate::workspace::UndoReport>(),
1017        annotations(
1018            title = "Undo workspace change",
1019            read_only_hint = false,
1020            destructive_hint = true,
1021            idempotent_hint = true,
1022            open_world_hint = false
1023        )
1024    )]
1025    async fn workspace_undo(
1026        &self,
1027        Parameters(input): Parameters<ChangeInput>,
1028        ToolInputResponses(input_responses): ToolInputResponses,
1029        ToolRequestState(request_state): ToolRequestState,
1030        context: RequestContext<RoleServer>,
1031    ) -> Result<CallToolResponse, String> {
1032        if !self.allow_writes {
1033            return Err(
1034                "workspace writes are disabled; restart with --allow-writes after explicit operator approval"
1035                .to_string(),
1036            );
1037        }
1038        let identity = write_operation_identity(WriteOperation::Undo, &[&input.change_id]);
1039        if let WorkspaceWriteApproval::InputRequired(result) = self
1040            .request_workspace_write_approval(
1041                &context,
1042                WriteOperation::Undo,
1043                &identity,
1044                format!(
1045                    "Approve undoing the latest Basalt workspace change {}? Later work is never discarded implicitly.",
1046                    input.change_id
1047                ),
1048                request_state,
1049                input_responses,
1050            )
1051            .await?
1052        {
1053            return Ok(result.into());
1054        }
1055        let workspace = self.target.workspace()?;
1056        let workspace_operation_lock = self.workspace_operation_lock.clone();
1057        let response = tokio::task::spawn_blocking(move || {
1058            let _operation = lock_workspace_operations(&workspace_operation_lock)
1059                .map_err(crate::workspace::WorkspaceError::Invalid)?;
1060            crate::workspace::mcp_undo(&workspace, &input.change_id)
1061        })
1062        .await
1063        .map_err(|error| format!("workspace undo task failed: {error}"))?
1064        .map_err(|error| error.to_string())?;
1065        ensure_output_size(&response, "workspace undo")?;
1066        complete_json(response)
1067    }
1068
1069    /// Export one workspace table as bounded UTF-8 content.
1070    #[tool(
1071        name = "workspace_export",
1072        description = "Export one workspace table as bounded CSV, JSON Lines, or SQL content. The tool returns content instead of accepting an arbitrary filesystem path.",
1073        annotations(
1074            title = "Export workspace table",
1075            read_only_hint = true,
1076            destructive_hint = false,
1077            idempotent_hint = true,
1078            open_world_hint = false
1079        )
1080    )]
1081    async fn workspace_export(
1082        &self,
1083        Parameters(input): Parameters<ExportInput>,
1084    ) -> Result<Json<crate::workspace::ExportReport>, String> {
1085        let workspace = self.target.workspace()?;
1086        let workspace_operation_lock = self.workspace_operation_lock.clone();
1087        let response = tokio::task::spawn_blocking(move || {
1088            let _operation = lock_workspace_operations(&workspace_operation_lock)
1089                .map_err(crate::workspace::WorkspaceError::Invalid)?;
1090            crate::workspace::mcp_export(&workspace, &input.table, &input.format, MAX_OUTPUT_BYTES)
1091        })
1092        .await
1093        .map_err(|error| format!("workspace export task failed: {error}"))?
1094        .map_err(|error| error.to_string())?;
1095        ensure_output_size(&response, "workspace export")?;
1096        Ok(Json(response))
1097    }
1098}
1099
1100#[tool_handler(router = self.tool_router)]
1101impl ServerHandler for BasaltMcp {
1102    fn get_info(&self) -> ServerInfo {
1103        let instructions = if self.target.is_workspace() {
1104            "Basalt workspace mode is local and read-only by default. Use workspace_import only for approved bounded CSV, JSON, or JSON Lines content, query or workspace_inspect to inspect data, workspace_preview to create an exact write plan, workspace_plan to reload a saved plan after a lost response or restart, and workspace_apply only when writes are explicitly enabled. Modern clients advertising form elicitation receive an input_required approval request before workspace imports, applies, and undos; legacy initialized clients receive elicitation/create. Use workspace_history, workspace_diff, and workspace_undo for recovery. Results are bounded."
1105        } else if self.allow_writes {
1106            "Basalt direct database mode has write access because --allow-writes was explicitly provided. Use query for read-only SELECT or EXPLAIN SELECT; use execute for writes and transaction control. Results are bounded."
1107        } else {
1108            "Basalt direct database mode is read-only. Use query for SELECT or EXPLAIN SELECT. Restart with --allow-writes only after explicit operator approval for direct SQL writes. Results are bounded."
1109        };
1110        ServerInfo::new(
1111            ServerCapabilities::builder()
1112                .enable_tools()
1113                .enable_resources()
1114                .build(),
1115        )
1116        .with_server_info(Implementation::new("basalt", env!("CARGO_PKG_VERSION")))
1117        .with_instructions(instructions)
1118    }
1119
1120    async fn list_resources(
1121        &self,
1122        _request: Option<rmcp::model::PaginatedRequestParams>,
1123        _context: RequestContext<RoleServer>,
1124    ) -> Result<ListResourcesResult, ErrorData> {
1125        Ok(ListResourcesResult::with_all_items(vec![
1126            Resource::new(SCHEMA_URI, "schema")
1127                .with_title("Basalt schema")
1128                .with_description("Current table and column metadata as JSON.")
1129                .with_mime_type("application/json"),
1130        ]))
1131    }
1132
1133    async fn read_resource(
1134        &self,
1135        request: ReadResourceRequestParams,
1136        _context: RequestContext<RoleServer>,
1137    ) -> Result<ReadResourceResponse, ErrorData> {
1138        if request.uri != SCHEMA_URI {
1139            return Err(ErrorData::resource_not_found(
1140                "unknown Basalt resource",
1141                Some(serde_json::json!({ "uri": request.uri })),
1142            ));
1143        }
1144
1145        let target = self.target.clone();
1146        let connection = self.connection.clone();
1147        let workspace_operation_lock = self.workspace_operation_lock.clone();
1148        let schema = tokio::task::spawn_blocking(move || match target {
1149            McpTarget::Database(database) => {
1150                with_connection(&connection, |_| schema_json(&database))
1151            }
1152            McpTarget::Workspace(workspace) => {
1153                let _operation = lock_workspace_operations(&workspace_operation_lock)?;
1154                let database = workspace
1155                    .database()
1156                    .map_err(|error| format!("could not open workspace database: {error}"))?;
1157                schema_json(&database)
1158            }
1159        })
1160        .await
1161        .map_err(|error| ErrorData::internal_error(format!("schema task failed: {error}"), None))?
1162        .map_err(|error| {
1163            ErrorData::internal_error(format!("could not read schema: {error}"), None)
1164        })?;
1165
1166        Ok(ReadResourceResult::new(vec![
1167            ResourceContents::text(schema, SCHEMA_URI).with_mime_type("application/json"),
1168        ])
1169        .into())
1170    }
1171}
1172
1173async fn execute_sql(
1174    connection: Arc<Mutex<Connection>>,
1175    input: SqlInput,
1176    read_only: bool,
1177) -> Result<SqlResult, String> {
1178    let max_rows = row_limit(input.max_rows)?;
1179    validate_sql(&input.sql, read_only)?;
1180    tokio::task::spawn_blocking(move || {
1181        let started = Instant::now();
1182        let mut connection = connection
1183            .lock()
1184            .map_err(|_| "database connection lock poisoned".to_string())?;
1185        let results = connection
1186            .execute_sql_with_budget(&input.sql, MCP_EXECUTION_WORK_LIMIT)
1187            .map_err(|error| format!("SQL execution failed: {error}"))?;
1188        let (results, rows_truncated) = convert_results(results, max_rows);
1189        let response = SqlResult {
1190            results,
1191            generation: connection.generation(),
1192            transaction_open: connection.in_transaction(),
1193            duration_ms: started.elapsed().as_millis() as u64,
1194            rows_truncated,
1195        };
1196        ensure_output_size(&response, "SQL result")?;
1197        Ok(response)
1198    })
1199    .await
1200    .map_err(|error| format!("SQL execution task failed: {error}"))?
1201}
1202
1203impl BasaltMcp {
1204    async fn request_workspace_write_approval(
1205        &self,
1206        context: &RequestContext<RoleServer>,
1207        operation: WriteOperation,
1208        identity: &str,
1209        message: String,
1210        request_state: Option<String>,
1211        input_responses: Option<InputResponses>,
1212    ) -> Result<WorkspaceWriteApproval, String> {
1213        if !supports_form_elicitation(context) {
1214            return Ok(WorkspaceWriteApproval::Approved);
1215        }
1216        if uses_modern_mcp(context) {
1217            return self.request_modern_write_approval(
1218                operation,
1219                identity,
1220                message,
1221                request_state,
1222                input_responses,
1223            );
1224        }
1225
1226        request_legacy_write_approval(context, message).await?;
1227        Ok(WorkspaceWriteApproval::Approved)
1228    }
1229
1230    fn request_modern_write_approval(
1231        &self,
1232        operation: WriteOperation,
1233        identity: &str,
1234        message: String,
1235        request_state: Option<String>,
1236        input_responses: Option<InputResponses>,
1237    ) -> Result<WorkspaceWriteApproval, String> {
1238        match (request_state, input_responses) {
1239            (None, None) => Ok(WorkspaceWriteApproval::InputRequired(
1240                self.modern_write_approval_request(operation, identity, message)?,
1241            )),
1242            (None, Some(_)) => Err(
1243                "workspace write approval response is missing its request state; repeat the original tool call"
1244                    .to_string(),
1245            ),
1246            (Some(_), None) => Err(
1247                "workspace write approval response is missing input responses; repeat the original tool call"
1248                    .to_string(),
1249            ),
1250            (Some(request_state), Some(input_responses)) => {
1251                validate_modern_write_approval(
1252                    &self.request_state_codec,
1253                    operation,
1254                    identity,
1255                    &request_state,
1256                    &input_responses,
1257                )?;
1258                Ok(WorkspaceWriteApproval::Approved)
1259            }
1260        }
1261    }
1262
1263    fn modern_write_approval_request(
1264        &self,
1265        operation: WriteOperation,
1266        identity: &str,
1267        message: String,
1268    ) -> Result<InputRequiredResult, String> {
1269        let schema = ElicitationSchema::from_type::<WriteApproval>()
1270            .map_err(|error| format!("could not build workspace write approval schema: {error}"))?;
1271        let mut input_requests = BTreeMap::new();
1272        input_requests.insert(
1273            WRITE_APPROVAL_INPUT_KEY.to_string(),
1274            InputRequest::Elicitation(ElicitRequest::new(
1275                ElicitRequestParams::FormElicitationParams {
1276                    meta: None,
1277                    message,
1278                    requested_schema: schema,
1279                },
1280            )),
1281        );
1282        let state = WriteApprovalState {
1283            version: WRITE_APPROVAL_STATE_VERSION,
1284            operation,
1285            identity: identity.to_string(),
1286        };
1287        let request_state = self
1288            .request_state_codec
1289            .seal_json_with(
1290                &state,
1291                &SealOptions::new()
1292                    .associated_data(identity.as_bytes())
1293                    .ttl(WRITE_APPROVAL_TIMEOUT),
1294            )
1295            .map_err(|error| format!("could not create workspace write approval state: {error}"))?;
1296        Ok(InputRequiredResult::new(
1297            Some(input_requests),
1298            Some(request_state),
1299        ))
1300    }
1301}
1302
1303async fn request_legacy_write_approval(
1304    context: &RequestContext<RoleServer>,
1305    message: String,
1306) -> Result<(), String> {
1307    let schema = ElicitationSchema::from_type::<WriteApproval>()
1308        .map_err(|error| format!("could not build workspace write approval schema: {error}"))?;
1309    let response = context
1310        .peer
1311        .create_elicitation_with_timeout(
1312            ElicitRequestParams::FormElicitationParams {
1313                meta: None,
1314                message,
1315                requested_schema: schema,
1316            },
1317            Some(WRITE_APPROVAL_TIMEOUT),
1318        )
1319        .await
1320        .map_err(|error| format!("workspace write approval request failed: {error}"))?;
1321    validate_write_approval_response(response)
1322}
1323
1324fn validate_modern_write_approval(
1325    codec: &RequestStateCodec,
1326    operation: WriteOperation,
1327    identity: &str,
1328    request_state: &str,
1329    input_responses: &InputResponses,
1330) -> Result<(), String> {
1331    let state = codec
1332        .open_json_with::<WriteApprovalState>(request_state, identity.as_bytes())
1333        .map_err(|_| {
1334            "workspace write approval state is invalid or expired; repeat the original tool call"
1335                .to_string()
1336        })?;
1337    if state.version != WRITE_APPROVAL_STATE_VERSION
1338        || state.operation != operation
1339        || state.identity != identity
1340    {
1341        return Err(
1342            "workspace write approval does not match this operation; repeat the original tool call"
1343                .to_string(),
1344        );
1345    }
1346    let response = input_responses
1347        .get(WRITE_APPROVAL_INPUT_KEY)
1348        .ok_or_else(|| "workspace write approval response was not provided".to_string())?;
1349    let response: ElicitResult = serde_json::from_value(response.clone())
1350        .map_err(|error| format!("workspace write approval response was invalid: {error}"))?;
1351    validate_write_approval_response(response)
1352}
1353
1354fn validate_write_approval_response(response: ElicitResult) -> Result<(), String> {
1355    match response.action {
1356        ElicitationAction::Accept => {
1357            let content = response.content.ok_or_else(|| {
1358                "workspace write approval was accepted without a response".to_string()
1359            })?;
1360            let approval: WriteApproval = serde_json::from_value(content)
1361                .map_err(|error| format!("workspace write approval was invalid: {error}"))?;
1362            if approval.approved {
1363                Ok(())
1364            } else {
1365                Err("workspace write was not approved by the user".to_string())
1366            }
1367        }
1368        ElicitationAction::Decline => Err("workspace write was declined by the user".to_string()),
1369        ElicitationAction::Cancel => Err("workspace write approval was cancelled".to_string()),
1370        _ => Err("workspace write approval returned an unknown action".to_string()),
1371    }
1372}
1373
1374fn supports_form_elicitation(context: &RequestContext<RoleServer>) -> bool {
1375    context
1376        .client_capabilities()
1377        .and_then(|capabilities| capabilities.elicitation)
1378        .is_some_and(|capability| capability.form.is_some() || capability.url.is_none())
1379}
1380
1381fn uses_modern_mcp(context: &RequestContext<RoleServer>) -> bool {
1382    context
1383        .protocol_version()
1384        .is_some_and(|version| version.as_str() >= "2026-07-28")
1385}
1386
1387fn write_operation_identity(operation: WriteOperation, parts: &[&str]) -> String {
1388    let mut hasher = Sha256::new();
1389    hasher.update(b"basalt-mcp-write-approval-v1\0");
1390    hasher.update(operation.name().as_bytes());
1391    for part in parts {
1392        hasher.update((part.len() as u64).to_be_bytes());
1393        hasher.update(part.as_bytes());
1394    }
1395    format!("{:x}", hasher.finalize())
1396}
1397
1398fn complete_json<T: Serialize>(value: T) -> Result<CallToolResponse, String> {
1399    let value = serde_json::to_value(value)
1400        .map_err(|error| format!("could not serialize structured tool output: {error}"))?;
1401    Ok(CallToolResult::structured(value).into())
1402}
1403
1404async fn execute_workspace_sql(
1405    workspace: crate::workspace::Workspace,
1406    input: SqlInput,
1407    read_only: bool,
1408    workspace_operation_lock: Arc<Mutex<()>>,
1409) -> Result<SqlResult, String> {
1410    let max_rows = row_limit(input.max_rows)?;
1411    validate_sql(&input.sql, read_only)?;
1412    tokio::task::spawn_blocking(move || {
1413        let _operation = lock_workspace_operations(&workspace_operation_lock)?;
1414        let started = Instant::now();
1415        let database = workspace
1416            .database()
1417            .map_err(|error| format!("workspace database open failed: {error}"))?;
1418        let results = database
1419            .execute_sql_with_budget(&input.sql, MCP_EXECUTION_WORK_LIMIT)
1420            .map_err(|error| format!("SQL execution failed: {error}"))?;
1421        let (results, rows_truncated) = convert_results(results, max_rows);
1422        let response = SqlResult {
1423            results,
1424            generation: database.generation(),
1425            transaction_open: false,
1426            duration_ms: started.elapsed().as_millis() as u64,
1427            rows_truncated,
1428        };
1429        ensure_output_size(&response, "SQL result")?;
1430        Ok(response)
1431    })
1432    .await
1433    .map_err(|error| format!("workspace SQL task failed: {error}"))?
1434}
1435
1436fn validate_sql(sql: &str, read_only: bool) -> Result<(), String> {
1437    if sql.is_empty() {
1438        return Err("SQL must not be empty".into());
1439    }
1440    if sql.len() > MAX_SQL_BYTES {
1441        return Err(format!(
1442            "SQL is {} bytes; request limit is {MAX_SQL_BYTES} bytes",
1443            sql.len()
1444        ));
1445    }
1446    let statements = parse(sql).map_err(|error| {
1447        format!(
1448            "SQL parse failed: {} at byte {}",
1449            error.message, error.offset
1450        )
1451    })?;
1452    if statements.is_empty() {
1453        return Err("SQL must contain at least one statement".into());
1454    }
1455    if statements.len() > MAX_STATEMENTS {
1456        return Err(format!(
1457            "request contains {}; limit is {MAX_STATEMENTS} statements",
1458            statements.len()
1459        ));
1460    }
1461    if read_only && statements.iter().any(|statement| !is_read_only(statement)) {
1462        return Err(
1463            "query accepts SELECT and EXPLAIN SELECT only; use execute for database changes".into(),
1464        );
1465    }
1466    let mutating_statements = statements
1467        .iter()
1468        .filter(|statement| is_mutating_statement(statement))
1469        .count();
1470    if mutating_statements > MAX_MUTATING_STATEMENTS {
1471        return Err(format!(
1472            "request contains {mutating_statements} mutating statements; limit is {MAX_MUTATING_STATEMENTS}"
1473        ));
1474    }
1475    Ok(())
1476}
1477
1478fn is_mutating_statement(statement: &Statement) -> bool {
1479    matches!(
1480        statement,
1481        Statement::CreateTable { .. }
1482            | Statement::DropTable { .. }
1483            | Statement::CreateIndex { .. }
1484            | Statement::DropIndex { .. }
1485            | Statement::Insert { .. }
1486            | Statement::InsertSelect { .. }
1487            | Statement::Update { .. }
1488            | Statement::Delete { .. }
1489    )
1490}
1491
1492fn is_read_only(statement: &Statement) -> bool {
1493    match statement {
1494        Statement::Select { .. } => true,
1495        Statement::Explain(inner) => matches!(inner.as_ref(), Statement::Select { .. }),
1496        _ => false,
1497    }
1498}
1499
1500fn row_limit(value: Option<u64>) -> Result<usize, String> {
1501    let value = value.unwrap_or(DEFAULT_MAX_ROWS as u64);
1502    if value == 0 || value > MAX_ROWS as u64 {
1503        return Err(format!("max_rows must be between 1 and {MAX_ROWS}"));
1504    }
1505    Ok(value as usize)
1506}
1507
1508fn convert_results(results: Vec<StatementResult>, max_rows: usize) -> (Vec<StatementOutput>, bool) {
1509    let mut rows_truncated = false;
1510    let results = results
1511        .into_iter()
1512        .map(|result| match result {
1513            StatementResult::Select { columns, rows } => {
1514                let rows_total = rows.len();
1515                let truncated = rows_total > max_rows;
1516                rows_truncated |= truncated;
1517                StatementOutput::Select {
1518                    columns,
1519                    rows: rows
1520                        .into_iter()
1521                        .take(max_rows)
1522                        .map(|row| row.into_iter().map(output_value).collect())
1523                        .collect(),
1524                    rows_total,
1525                    truncated,
1526                }
1527            }
1528            StatementResult::Insert { rows_affected } => StatementOutput::Insert { rows_affected },
1529            StatementResult::Update { rows_affected } => StatementOutput::Update { rows_affected },
1530            StatementResult::Delete { rows_affected } => StatementOutput::Delete { rows_affected },
1531            StatementResult::CreateTable { name } => StatementOutput::CreateTable { name },
1532            StatementResult::DropTable { name } => StatementOutput::DropTable { name },
1533            StatementResult::CreateIndex {
1534                name,
1535                table,
1536                column,
1537            } => StatementOutput::CreateIndex {
1538                name,
1539                table,
1540                column,
1541            },
1542            StatementResult::DropIndex { name } => StatementOutput::DropIndex { name },
1543            StatementResult::Explain(plan) => StatementOutput::Explain { plan },
1544            StatementResult::Begin => StatementOutput::Begin,
1545            StatementResult::Commit => StatementOutput::Commit,
1546            StatementResult::Rollback => StatementOutput::Rollback,
1547            StatementResult::Checkpoint => StatementOutput::Checkpoint,
1548            StatementResult::Echo(value) => StatementOutput::Echo { value },
1549        })
1550        .collect();
1551    (results, rows_truncated)
1552}
1553
1554fn output_value(value: Value) -> OutputValue {
1555    match value {
1556        Value::Null => OutputValue::Null,
1557        Value::Integer(value) => OutputValue::Integer(value),
1558        Value::Real(value) => OutputValue::Real(value.to_string()),
1559        Value::Text(value) => OutputValue::Text(value),
1560        Value::Boolean(value) => OutputValue::Boolean(value),
1561    }
1562}
1563
1564fn with_connection<T>(
1565    connection: &Arc<Mutex<Connection>>,
1566    operation: impl FnOnce(&mut Connection) -> Result<T, String>,
1567) -> Result<T, String> {
1568    let mut connection = connection
1569        .lock()
1570        .map_err(|_| "database connection lock poisoned".to_string())?;
1571    operation(&mut connection)
1572}
1573
1574fn lock_workspace_operations(lock: &Mutex<()>) -> Result<MutexGuard<'_, ()>, String> {
1575    lock.lock()
1576        .map_err(|_| "workspace operation lock poisoned".to_string())
1577}
1578
1579fn table_info(database: &Database) -> Result<ListTablesResult, String> {
1580    let tables = database
1581        .table_names()
1582        .map_err(|error| format!("could not list tables: {error}"))?
1583        .into_iter()
1584        .map(|name| {
1585            let columns = database
1586                .columns(&name)
1587                .map_err(|error| format!("could not read table {name}: {error}"))?;
1588            Ok(TableInfo {
1589                name,
1590                columns: columns.into_iter().map(column_info).collect(),
1591            })
1592        })
1593        .collect::<Result<Vec<_>, String>>()?;
1594    Ok(ListTablesResult {
1595        tables,
1596        generation: database.generation(),
1597    })
1598}
1599
1600fn schema_json(database: &Database) -> Result<String, String> {
1601    let schema =
1602        serde_json::to_string_pretty(&table_info(database)?).map_err(|error| error.to_string())?;
1603    if schema.len() > MAX_OUTPUT_BYTES {
1604        return Err(format!(
1605            "schema is {} bytes; response limit is {MAX_OUTPUT_BYTES} bytes",
1606            schema.len()
1607        ));
1608    }
1609    Ok(schema)
1610}
1611
1612fn ensure_output_size<T: Serialize>(value: &T, label: &str) -> Result<(), String> {
1613    let output_size = serde_json::to_vec(value)
1614        .map_err(|error| format!("could not encode {label}: {error}"))?
1615        .len();
1616    if output_size > MAX_OUTPUT_BYTES {
1617        return Err(format!(
1618            "{label} is {output_size} bytes; response limit is {MAX_OUTPUT_BYTES} bytes"
1619        ));
1620    }
1621    Ok(())
1622}
1623
1624fn column_info(column: Column) -> ColumnInfo {
1625    ColumnInfo {
1626        name: column.name,
1627        r#type: column_type_name(&column.ty).into(),
1628        not_null: column.not_null,
1629        unique: column.unique,
1630        primary_key: column.primary_key,
1631    }
1632}
1633
1634fn column_type_name(column_type: &ColumnType) -> &'static str {
1635    match column_type {
1636        ColumnType::Integer => "INTEGER",
1637        ColumnType::Real => "REAL",
1638        ColumnType::Text => "TEXT",
1639        ColumnType::Boolean => "BOOLEAN",
1640        ColumnType::Any => "ANY",
1641        ColumnType::Null => "NULL",
1642    }
1643}
1644
1645#[cfg(test)]
1646mod tests {
1647    use super::*;
1648
1649    fn args(values: &[&str]) -> Vec<String> {
1650        values.iter().map(|value| (*value).into()).collect()
1651    }
1652
1653    #[test]
1654    fn mcp_defaults_to_in_memory() {
1655        let options = parse_args(&[]).unwrap();
1656        assert_eq!(options.database, ":memory:");
1657        assert_eq!(options.workspace, None);
1658        assert!(!options.init_workspace);
1659        assert!(!options.allow_writes);
1660    }
1661
1662    #[test]
1663    fn mcp_accepts_positional_and_flag_database_paths() {
1664        assert_eq!(
1665            parse_args(&args(&["data.basalt"])).unwrap().database,
1666            "data.basalt"
1667        );
1668        assert_eq!(
1669            parse_args(&args(&["--database", "data.basalt"]))
1670                .unwrap()
1671                .database,
1672            "data.basalt"
1673        );
1674    }
1675
1676    #[test]
1677    fn mcp_accepts_workspace_and_explicit_write_policy() {
1678        let options = parse_args(&args(&[
1679            "--workspace",
1680            ".basalt-workspace",
1681            "--allow-writes",
1682        ]))
1683        .unwrap();
1684        assert_eq!(options.workspace.as_deref(), Some(".basalt-workspace"));
1685        assert!(!options.init_workspace);
1686        assert!(options.allow_writes);
1687        assert_eq!(options.database, ":memory:");
1688    }
1689
1690    #[test]
1691    fn mcp_accepts_explicit_workspace_initialization() {
1692        let options = parse_args(&args(&[
1693            "--workspace",
1694            ".basalt-workspace",
1695            "--init-workspace",
1696        ]))
1697        .unwrap();
1698        assert_eq!(options.workspace.as_deref(), Some(".basalt-workspace"));
1699        assert!(options.init_workspace);
1700    }
1701
1702    #[test]
1703    fn mcp_rejects_workspace_initialization_without_workspace_mode() {
1704        let error = parse_args(&args(&["--init-workspace"])).unwrap_err();
1705        assert!(error.to_string().contains("requires --workspace"));
1706    }
1707
1708    #[test]
1709    fn mcp_rejects_mixing_workspace_and_database() {
1710        let error = parse_args(&args(&[
1711            "--workspace",
1712            ".basalt-workspace",
1713            "--database",
1714            "app.basalt",
1715        ]))
1716        .unwrap_err();
1717        assert!(error.to_string().contains("workspace or --database"));
1718    }
1719
1720    #[test]
1721    fn mcp_rejects_mutating_query() {
1722        let error = validate_sql("DELETE FROM users", true).unwrap_err();
1723        assert!(error.contains("SELECT"));
1724    }
1725
1726    #[test]
1727    fn mcp_limits_mutating_statements_per_request() {
1728        let sql = (0..=MAX_MUTATING_STATEMENTS)
1729            .map(|index| format!("CREATE TABLE table_{index} (id INTEGER)"))
1730            .collect::<Vec<_>>()
1731            .join("; ");
1732        let error = validate_sql(&sql, false).unwrap_err();
1733        assert!(error.contains("mutating statements"));
1734        assert!(error.contains("limit is 32"));
1735    }
1736
1737    #[test]
1738    fn mcp_limits_rows_and_preserves_statement_order() {
1739        let (outputs, truncated) = convert_results(
1740            vec![StatementResult::Select {
1741                columns: vec!["id".into()],
1742                rows: vec![vec![Value::Integer(1)], vec![Value::Integer(2)]],
1743            }],
1744            1,
1745        );
1746        assert!(truncated);
1747        let StatementOutput::Select {
1748            rows,
1749            rows_total,
1750            truncated,
1751            ..
1752        } = &outputs[0]
1753        else {
1754            panic!("expected select output")
1755        };
1756        assert_eq!(rows.len(), 1);
1757        assert_eq!(*rows_total, 2);
1758        assert!(*truncated);
1759    }
1760
1761    #[test]
1762    fn mcp_encodes_non_finite_reals_as_text() {
1763        let encoded = serde_json::to_value(output_value(Value::Real(f64::INFINITY))).unwrap();
1764        assert_eq!(encoded, serde_json::json!({"type": "real", "value": "inf"}));
1765    }
1766}