1use 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#[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
87pub 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
252pub 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
289type StdioWriter =
296 FramedWrite<tokio::io::Stdout, JsonRpcMessageCodec<TxJsonRpcMessage<RoleServer>>>;
297type SharedStdioWriter = Arc<tokio::sync::Mutex<Option<StdioWriter>>>;
298
299struct 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: String,
462 #[serde(default)]
464 max_rows: Option<u64>,
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
468struct TableInput {
469 table: String,
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
474struct WorkspaceSqlInput {
475 sql: String,
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
480struct WorkspaceImportInput {
481 table: String,
483 format: String,
485 content: String,
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
490struct PlanInput {
491 plan_id: String,
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
496struct ChangeInput {
497 change_id: String,
499}
500
501#[derive(Debug, Deserialize, JsonSchema)]
502struct WriteApproval {
503 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 #[serde(default)]
543 change_id: Option<String>,
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
547struct ExportInput {
548 table: String,
550 format: String,
552}
553
554#[derive(Debug, Clone, Serialize, JsonSchema)]
555struct SqlResult {
556 results: Vec<StatementOutput>,
558 generation: u64,
560 transaction_open: bool,
562 duration_ms: u64,
564 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}