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