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 format!("{:x}", hasher.finalize())
1573}
1574
1575fn complete_json<T: Serialize>(value: T) -> Result<CallToolResponse, String> {
1576 let value = serde_json::to_value(value)
1577 .map_err(|error| format!("could not serialize structured tool output: {error}"))?;
1578 Ok(CallToolResult::structured(value).into())
1579}
1580
1581async fn execute_workspace_sql(
1582 workspace: crate::workspace::Workspace,
1583 input: SqlInput,
1584 read_only: bool,
1585 workspace_operation_lock: Arc<Mutex<()>>,
1586) -> Result<SqlResult, String> {
1587 let max_rows = row_limit(input.max_rows)?;
1588 validate_sql(&input.sql, read_only)?;
1589 tokio::task::spawn_blocking(move || {
1590 let _operation = lock_workspace_operations(&workspace_operation_lock)?;
1591 let started = Instant::now();
1592 let database = workspace
1593 .database()
1594 .map_err(|error| format!("workspace database open failed: {error}"))?;
1595 let results = database
1596 .execute_sql_with_budget(&input.sql, MCP_EXECUTION_WORK_LIMIT)
1597 .map_err(|error| format!("SQL execution failed: {error}"))?;
1598 let (results, rows_truncated) = convert_results(results, max_rows);
1599 let response = SqlResult {
1600 results,
1601 generation: database.generation(),
1602 transaction_open: false,
1603 duration_ms: started.elapsed().as_millis() as u64,
1604 rows_truncated,
1605 };
1606 ensure_output_size(&response, "SQL result")?;
1607 Ok(response)
1608 })
1609 .await
1610 .map_err(|error| format!("workspace SQL task failed: {error}"))?
1611}
1612
1613fn validate_sql(sql: &str, read_only: bool) -> Result<(), String> {
1614 if sql.is_empty() {
1615 return Err("SQL must not be empty".into());
1616 }
1617 if sql.len() > MAX_SQL_BYTES {
1618 return Err(format!(
1619 "SQL is {} bytes; request limit is {MAX_SQL_BYTES} bytes",
1620 sql.len()
1621 ));
1622 }
1623 let statements = parse(sql).map_err(|error| {
1624 format!(
1625 "SQL parse failed: {} at byte {}",
1626 error.message, error.offset
1627 )
1628 })?;
1629 if statements.is_empty() {
1630 return Err("SQL must contain at least one statement".into());
1631 }
1632 if statements.len() > MAX_STATEMENTS {
1633 return Err(format!(
1634 "request contains {}; limit is {MAX_STATEMENTS} statements",
1635 statements.len()
1636 ));
1637 }
1638 if read_only && statements.iter().any(|statement| !is_read_only(statement)) {
1639 return Err(
1640 "query accepts SELECT and EXPLAIN SELECT only; use execute for database changes".into(),
1641 );
1642 }
1643 let mutating_statements = statements
1644 .iter()
1645 .filter(|statement| is_mutating_statement(statement))
1646 .count();
1647 if mutating_statements > MAX_MUTATING_STATEMENTS {
1648 return Err(format!(
1649 "request contains {mutating_statements} mutating statements; limit is {MAX_MUTATING_STATEMENTS}"
1650 ));
1651 }
1652 Ok(())
1653}
1654
1655fn is_mutating_statement(statement: &Statement) -> bool {
1656 matches!(
1657 statement,
1658 Statement::CreateTable { .. }
1659 | Statement::DropTable { .. }
1660 | Statement::CreateIndex { .. }
1661 | Statement::DropIndex { .. }
1662 | Statement::Insert { .. }
1663 | Statement::InsertSelect { .. }
1664 | Statement::Update { .. }
1665 | Statement::Delete { .. }
1666 )
1667}
1668
1669fn is_read_only(statement: &Statement) -> bool {
1670 match statement {
1671 Statement::Select { .. } => true,
1672 Statement::Explain(inner) => matches!(inner.as_ref(), Statement::Select { .. }),
1673 _ => false,
1674 }
1675}
1676
1677fn row_limit(value: Option<u64>) -> Result<usize, String> {
1678 let value = value.unwrap_or(DEFAULT_MAX_ROWS as u64);
1679 if value == 0 || value > MAX_ROWS as u64 {
1680 return Err(format!("max_rows must be between 1 and {MAX_ROWS}"));
1681 }
1682 Ok(value as usize)
1683}
1684
1685fn convert_results(results: Vec<StatementResult>, max_rows: usize) -> (Vec<StatementOutput>, bool) {
1686 let mut rows_truncated = false;
1687 let results = results
1688 .into_iter()
1689 .map(|result| match result {
1690 StatementResult::Select { columns, rows } => {
1691 let rows_total = rows.len();
1692 let truncated = rows_total > max_rows;
1693 rows_truncated |= truncated;
1694 StatementOutput::Select {
1695 columns,
1696 rows: rows
1697 .into_iter()
1698 .take(max_rows)
1699 .map(|row| row.into_iter().map(output_value).collect())
1700 .collect(),
1701 rows_total,
1702 truncated,
1703 }
1704 }
1705 StatementResult::Insert { rows_affected } => StatementOutput::Insert { rows_affected },
1706 StatementResult::Update { rows_affected } => StatementOutput::Update { rows_affected },
1707 StatementResult::Delete { rows_affected } => StatementOutput::Delete { rows_affected },
1708 StatementResult::CreateTable { name } => StatementOutput::CreateTable { name },
1709 StatementResult::DropTable { name } => StatementOutput::DropTable { name },
1710 StatementResult::CreateIndex {
1711 name,
1712 table,
1713 column,
1714 } => StatementOutput::CreateIndex {
1715 name,
1716 table,
1717 column,
1718 },
1719 StatementResult::DropIndex { name } => StatementOutput::DropIndex { name },
1720 StatementResult::Explain(plan) => StatementOutput::Explain { plan },
1721 StatementResult::Begin => StatementOutput::Begin,
1722 StatementResult::Commit => StatementOutput::Commit,
1723 StatementResult::Rollback => StatementOutput::Rollback,
1724 StatementResult::Checkpoint => StatementOutput::Checkpoint,
1725 StatementResult::Echo(value) => StatementOutput::Echo { value },
1726 })
1727 .collect();
1728 (results, rows_truncated)
1729}
1730
1731fn output_value(value: Value) -> OutputValue {
1732 match value {
1733 Value::Null => OutputValue::Null,
1734 Value::Integer(value) => OutputValue::Integer(value),
1735 Value::Real(value) => OutputValue::Real(value.to_string()),
1736 Value::Text(value) => OutputValue::Text(value),
1737 Value::Boolean(value) => OutputValue::Boolean(value),
1738 }
1739}
1740
1741fn with_connection<T>(
1742 connection: &Arc<Mutex<Connection>>,
1743 operation: impl FnOnce(&mut Connection) -> Result<T, String>,
1744) -> Result<T, String> {
1745 let mut connection = connection
1746 .lock()
1747 .map_err(|_| "database connection lock poisoned".to_string())?;
1748 operation(&mut connection)
1749}
1750
1751fn lock_workspace_operations(lock: &Mutex<()>) -> Result<MutexGuard<'_, ()>, String> {
1752 lock.lock()
1753 .map_err(|_| "workspace operation lock poisoned".to_string())
1754}
1755
1756fn table_info(database: &Database) -> Result<ListTablesResult, String> {
1757 let tables = database
1758 .table_names()
1759 .map_err(|error| format!("could not list tables: {error}"))?
1760 .into_iter()
1761 .map(|name| {
1762 let columns = database
1763 .columns(&name)
1764 .map_err(|error| format!("could not read table {name}: {error}"))?;
1765 Ok(TableInfo {
1766 name,
1767 columns: columns.into_iter().map(column_info).collect(),
1768 })
1769 })
1770 .collect::<Result<Vec<_>, String>>()?;
1771 Ok(ListTablesResult {
1772 tables,
1773 generation: database.generation(),
1774 })
1775}
1776
1777fn schema_json(database: &Database) -> Result<String, String> {
1778 let schema =
1779 serde_json::to_string_pretty(&table_info(database)?).map_err(|error| error.to_string())?;
1780 if schema.len() > MAX_OUTPUT_BYTES {
1781 return Err(format!(
1782 "schema is {} bytes; response limit is {MAX_OUTPUT_BYTES} bytes",
1783 schema.len()
1784 ));
1785 }
1786 Ok(schema)
1787}
1788
1789fn ensure_output_size<T: Serialize>(value: &T, label: &str) -> Result<(), String> {
1790 let output_size = serde_json::to_vec(value)
1791 .map_err(|error| format!("could not encode {label}: {error}"))?
1792 .len();
1793 if output_size > MAX_OUTPUT_BYTES {
1794 return Err(format!(
1795 "{label} is {output_size} bytes; response limit is {MAX_OUTPUT_BYTES} bytes"
1796 ));
1797 }
1798 Ok(())
1799}
1800
1801fn column_info(column: Column) -> ColumnInfo {
1802 ColumnInfo {
1803 name: column.name,
1804 r#type: column_type_name(&column.ty).into(),
1805 not_null: column.not_null,
1806 unique: column.unique,
1807 primary_key: column.primary_key,
1808 }
1809}
1810
1811fn column_type_name(column_type: &ColumnType) -> &'static str {
1812 match column_type {
1813 ColumnType::Integer => "INTEGER",
1814 ColumnType::Real => "REAL",
1815 ColumnType::Text => "TEXT",
1816 ColumnType::Boolean => "BOOLEAN",
1817 ColumnType::Any => "ANY",
1818 ColumnType::Null => "NULL",
1819 }
1820}
1821
1822#[cfg(test)]
1823mod tests {
1824 use super::*;
1825
1826 fn args(values: &[&str]) -> Vec<String> {
1827 values.iter().map(|value| (*value).into()).collect()
1828 }
1829
1830 #[test]
1831 fn mcp_defaults_to_in_memory() {
1832 let options = parse_args(&[]).unwrap();
1833 assert_eq!(options.database, ":memory:");
1834 assert_eq!(options.workspace, None);
1835 assert!(!options.init_workspace);
1836 assert!(!options.allow_writes);
1837 }
1838
1839 #[test]
1840 fn mcp_accepts_positional_and_flag_database_paths() {
1841 assert_eq!(
1842 parse_args(&args(&["data.basalt"])).unwrap().database,
1843 "data.basalt"
1844 );
1845 assert_eq!(
1846 parse_args(&args(&["--database", "data.basalt"]))
1847 .unwrap()
1848 .database,
1849 "data.basalt"
1850 );
1851 }
1852
1853 #[test]
1854 fn mcp_accepts_workspace_and_explicit_write_policy() {
1855 let options = parse_args(&args(&[
1856 "--workspace",
1857 ".basalt-workspace",
1858 "--allow-writes",
1859 ]))
1860 .unwrap();
1861 assert_eq!(options.workspace.as_deref(), Some(".basalt-workspace"));
1862 assert!(!options.init_workspace);
1863 assert!(options.allow_writes);
1864 assert_eq!(options.database, ":memory:");
1865 }
1866
1867 #[test]
1868 fn mcp_accepts_explicit_workspace_initialization() {
1869 let options = parse_args(&args(&[
1870 "--workspace",
1871 ".basalt-workspace",
1872 "--init-workspace",
1873 ]))
1874 .unwrap();
1875 assert_eq!(options.workspace.as_deref(), Some(".basalt-workspace"));
1876 assert!(options.init_workspace);
1877 }
1878
1879 #[test]
1880 fn mcp_rejects_workspace_initialization_without_workspace_mode() {
1881 let error = parse_args(&args(&["--init-workspace"])).unwrap_err();
1882 assert!(error.to_string().contains("requires --workspace"));
1883 }
1884
1885 #[test]
1886 fn mcp_rejects_mixing_workspace_and_database() {
1887 let error = parse_args(&args(&[
1888 "--workspace",
1889 ".basalt-workspace",
1890 "--database",
1891 "app.basalt",
1892 ]))
1893 .unwrap_err();
1894 assert!(error.to_string().contains("workspace or --database"));
1895 }
1896
1897 #[test]
1898 fn mcp_rejects_mutating_query() {
1899 let error = validate_sql("DELETE FROM users", true).unwrap_err();
1900 assert!(error.contains("SELECT"));
1901 }
1902
1903 #[test]
1904 fn mcp_limits_mutating_statements_per_request() {
1905 let sql = (0..=MAX_MUTATING_STATEMENTS)
1906 .map(|index| format!("CREATE TABLE table_{index} (id INTEGER)"))
1907 .collect::<Vec<_>>()
1908 .join("; ");
1909 let error = validate_sql(&sql, false).unwrap_err();
1910 assert!(error.contains("mutating statements"));
1911 assert!(error.contains("limit is 32"));
1912 }
1913
1914 #[test]
1915 fn mcp_limits_rows_and_preserves_statement_order() {
1916 let (outputs, truncated) = convert_results(
1917 vec![StatementResult::Select {
1918 columns: vec!["id".into()],
1919 rows: vec![vec![Value::Integer(1)], vec![Value::Integer(2)]],
1920 }],
1921 1,
1922 );
1923 assert!(truncated);
1924 let StatementOutput::Select {
1925 rows,
1926 rows_total,
1927 truncated,
1928 ..
1929 } = &outputs[0]
1930 else {
1931 panic!("expected select output")
1932 };
1933 assert_eq!(rows.len(), 1);
1934 assert_eq!(*rows_total, 2);
1935 assert!(*truncated);
1936 }
1937
1938 #[test]
1939 fn mcp_encodes_non_finite_reals_as_text() {
1940 let encoded = serde_json::to_value(output_value(Value::Real(f64::INFINITY))).unwrap();
1941 assert_eq!(encoded, serde_json::json!({"type": "real", "value": "inf"}));
1942 }
1943
1944 #[test]
1945 fn mcp_stdio_codec_rejects_oversized_frames() {
1946 let mut codec =
1947 JsonRpcMessageCodec::<serde_json::Value>::new_with_max_length(MAX_MCP_MESSAGE_BYTES);
1948 let mut input = tokio_util::bytes::BytesMut::with_capacity(MAX_MCP_MESSAGE_BYTES + 1);
1949 input.resize(MAX_MCP_MESSAGE_BYTES + 1, b'x');
1950
1951 let error = tokio_util::codec::Decoder::decode(&mut codec, &mut input).unwrap_err();
1952
1953 assert!(matches!(
1954 error,
1955 JsonRpcMessageCodecError::MaxLineLengthExceeded
1956 ));
1957 }
1958
1959 #[test]
1960 fn mcp_stdio_codec_recovers_after_malformed_json() {
1961 let mut codec = RecoveringJsonRpcMessageCodec::<serde_json::Value>::new_with_max_length(
1962 MAX_MCP_MESSAGE_BYTES,
1963 );
1964 let mut input = BytesMut::from(&b"{not-json}\n{\"ok\":true}\n"[..]);
1965
1966 assert!(Decoder::decode(&mut codec, &mut input).unwrap().is_none());
1967 assert_eq!(
1968 Decoder::decode(&mut codec, &mut input).unwrap(),
1969 Some(serde_json::json!({"ok": true}))
1970 );
1971 }
1972
1973 #[test]
1974 fn mcp_stdio_codec_recovers_after_invalid_message_shape() {
1975 let mut codec =
1976 RecoveringJsonRpcMessageCodec::<RxJsonRpcMessage<RoleServer>>::new_with_max_length(
1977 MAX_MCP_MESSAGE_BYTES,
1978 );
1979 let mut input = BytesMut::from(
1980 &br#"[]
1981{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}
1982"#[..],
1983 );
1984
1985 let error = Decoder::decode(&mut codec, &mut input).unwrap_err();
1986 assert!(matches!(
1987 error,
1988 JsonRpcMessageCodecError::Serde(error)
1989 if matches!(error.classify(), serde_json::error::Category::Data)
1990 ));
1991 assert!(Decoder::decode(&mut codec, &mut input).unwrap().is_some());
1992 }
1993}