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