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