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