1use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::sync::Arc;
6#[cfg(target_os = "macos")]
7use things3_core::AppleScriptBackend;
8use things3_core::{
9 BackupManager, DataExporter, McpServerConfig, MutationBackend, PerformanceMonitor, SqlxBackend,
10 ThingsCache, ThingsConfig, ThingsDatabase, ThingsError,
11};
12use thiserror::Error;
13use tokio::sync::Mutex;
14
15pub mod io_wrapper;
16pub mod middleware;
17pub mod test_harness;
19mod tools;
20
21use io_wrapper::{McpIo, StdIo};
22use middleware::{MiddlewareChain, MiddlewareConfig};
23
24#[derive(Error, Debug)]
26pub enum McpError {
27 #[error("Tool not found: {tool_name}")]
28 ToolNotFound { tool_name: String },
29
30 #[error("Resource not found: {uri}")]
31 ResourceNotFound { uri: String },
32
33 #[error("Prompt not found: {prompt_name}")]
34 PromptNotFound { prompt_name: String },
35
36 #[error("Invalid parameter: {parameter_name} - {message}")]
37 InvalidParameter {
38 parameter_name: String,
39 message: String,
40 },
41
42 #[error("Missing required parameter: {parameter_name}")]
43 MissingParameter { parameter_name: String },
44
45 #[error("Invalid format: {format} - supported formats: {supported}")]
46 InvalidFormat { format: String, supported: String },
47
48 #[error("Invalid data type: {data_type} - supported types: {supported}")]
49 InvalidDataType {
50 data_type: String,
51 supported: String,
52 },
53
54 #[error("Database operation failed: {operation}")]
55 DatabaseOperationFailed {
56 operation: String,
57 source: ThingsError,
58 },
59
60 #[error("Backup operation failed: {operation}")]
61 BackupOperationFailed {
62 operation: String,
63 source: ThingsError,
64 },
65
66 #[error("Export operation failed: {operation}")]
67 ExportOperationFailed {
68 operation: String,
69 source: ThingsError,
70 },
71
72 #[error("Performance monitoring failed: {operation}")]
73 PerformanceMonitoringFailed {
74 operation: String,
75 source: ThingsError,
76 },
77
78 #[error("Cache operation failed: {operation}")]
79 CacheOperationFailed {
80 operation: String,
81 source: ThingsError,
82 },
83
84 #[error("Serialization failed: {operation}")]
85 SerializationFailed {
86 operation: String,
87 source: serde_json::Error,
88 },
89
90 #[error("IO operation failed: {operation}")]
91 IoOperationFailed {
92 operation: String,
93 source: std::io::Error,
94 },
95
96 #[error("Configuration error: {message}")]
97 ConfigurationError { message: String },
98
99 #[error("Validation error: {message}")]
100 ValidationError { message: String },
101
102 #[error("Internal error: {message}")]
103 InternalError { message: String },
104}
105
106impl McpError {
107 pub fn tool_not_found(tool_name: impl Into<String>) -> Self {
109 Self::ToolNotFound {
110 tool_name: tool_name.into(),
111 }
112 }
113
114 pub fn resource_not_found(uri: impl Into<String>) -> Self {
116 Self::ResourceNotFound { uri: uri.into() }
117 }
118
119 pub fn prompt_not_found(prompt_name: impl Into<String>) -> Self {
121 Self::PromptNotFound {
122 prompt_name: prompt_name.into(),
123 }
124 }
125
126 pub fn invalid_parameter(
128 parameter_name: impl Into<String>,
129 message: impl Into<String>,
130 ) -> Self {
131 Self::InvalidParameter {
132 parameter_name: parameter_name.into(),
133 message: message.into(),
134 }
135 }
136
137 pub fn missing_parameter(parameter_name: impl Into<String>) -> Self {
139 Self::MissingParameter {
140 parameter_name: parameter_name.into(),
141 }
142 }
143
144 pub fn invalid_format(format: impl Into<String>, supported: impl Into<String>) -> Self {
146 Self::InvalidFormat {
147 format: format.into(),
148 supported: supported.into(),
149 }
150 }
151
152 pub fn invalid_data_type(data_type: impl Into<String>, supported: impl Into<String>) -> Self {
154 Self::InvalidDataType {
155 data_type: data_type.into(),
156 supported: supported.into(),
157 }
158 }
159
160 pub fn database_operation_failed(operation: impl Into<String>, source: ThingsError) -> Self {
162 Self::DatabaseOperationFailed {
163 operation: operation.into(),
164 source,
165 }
166 }
167
168 pub fn backup_operation_failed(operation: impl Into<String>, source: ThingsError) -> Self {
170 Self::BackupOperationFailed {
171 operation: operation.into(),
172 source,
173 }
174 }
175
176 pub fn export_operation_failed(operation: impl Into<String>, source: ThingsError) -> Self {
178 Self::ExportOperationFailed {
179 operation: operation.into(),
180 source,
181 }
182 }
183
184 pub fn performance_monitoring_failed(
186 operation: impl Into<String>,
187 source: ThingsError,
188 ) -> Self {
189 Self::PerformanceMonitoringFailed {
190 operation: operation.into(),
191 source,
192 }
193 }
194
195 pub fn cache_operation_failed(operation: impl Into<String>, source: ThingsError) -> Self {
197 Self::CacheOperationFailed {
198 operation: operation.into(),
199 source,
200 }
201 }
202
203 pub fn serialization_failed(operation: impl Into<String>, source: serde_json::Error) -> Self {
205 Self::SerializationFailed {
206 operation: operation.into(),
207 source,
208 }
209 }
210
211 pub fn io_operation_failed(operation: impl Into<String>, source: std::io::Error) -> Self {
213 Self::IoOperationFailed {
214 operation: operation.into(),
215 source,
216 }
217 }
218
219 pub fn configuration_error(message: impl Into<String>) -> Self {
221 Self::ConfigurationError {
222 message: message.into(),
223 }
224 }
225
226 pub fn validation_error(message: impl Into<String>) -> Self {
228 Self::ValidationError {
229 message: message.into(),
230 }
231 }
232
233 pub fn internal_error(message: impl Into<String>) -> Self {
235 Self::InternalError {
236 message: message.into(),
237 }
238 }
239
240 #[must_use]
242 pub fn to_call_result(self) -> CallToolResult {
243 let error_message = match &self {
244 McpError::ToolNotFound { tool_name } => {
245 format!("Tool '{tool_name}' not found. Available tools can be listed using the list_tools method.")
246 }
247 McpError::ResourceNotFound { uri } => {
248 format!("Resource '{uri}' not found. Available resources can be listed using the list_resources method.")
249 }
250 McpError::PromptNotFound { prompt_name } => {
251 format!("Prompt '{prompt_name}' not found. Available prompts can be listed using the list_prompts method.")
252 }
253 McpError::InvalidParameter {
254 parameter_name,
255 message,
256 } => {
257 format!("Invalid parameter '{parameter_name}': {message}. Please check the parameter format and try again.")
258 }
259 McpError::MissingParameter { parameter_name } => {
260 format!("Missing required parameter '{parameter_name}'. Please provide this parameter and try again.")
261 }
262 McpError::InvalidFormat { format, supported } => {
263 format!("Invalid format '{format}'. Supported formats: {supported}. Please use one of the supported formats.")
264 }
265 McpError::InvalidDataType {
266 data_type,
267 supported,
268 } => {
269 format!("Invalid data type '{data_type}'. Supported types: {supported}. Please use one of the supported types.")
270 }
271 McpError::DatabaseOperationFailed { operation, source } => {
272 format!("Database operation '{operation}' failed: {source}. Please check your database connection and try again.")
273 }
274 McpError::BackupOperationFailed { operation, source } => {
275 format!("Backup operation '{operation}' failed: {source}. Please check backup permissions and try again.")
276 }
277 McpError::ExportOperationFailed { operation, source } => {
278 format!("Export operation '{operation}' failed: {source}. Please check export parameters and try again.")
279 }
280 McpError::PerformanceMonitoringFailed { operation, source } => {
281 format!("Performance monitoring '{operation}' failed: {source}. Please try again later.")
282 }
283 McpError::CacheOperationFailed { operation, source } => {
284 format!("Cache operation '{operation}' failed: {source}. Please try again later.")
285 }
286 McpError::SerializationFailed { operation, source } => {
287 format!("Serialization '{operation}' failed: {source}. Please check data format and try again.")
288 }
289 McpError::IoOperationFailed { operation, source } => {
290 format!("IO operation '{operation}' failed: {source}. Please check file permissions and try again.")
291 }
292 McpError::ConfigurationError { message } => {
293 format!("Configuration error: {message}. Please check your configuration and try again.")
294 }
295 McpError::ValidationError { message } => {
296 format!("Validation error: {message}. Please check your input and try again.")
297 }
298 McpError::InternalError { message } => {
299 format!("Internal error: {message}. Please try again later or contact support if the issue persists.")
300 }
301 };
302
303 CallToolResult {
304 content: vec![Content::Text {
305 text: error_message,
306 }],
307 is_error: true,
308 }
309 }
310
311 #[must_use]
313 pub fn to_prompt_result(self) -> GetPromptResult {
314 let error_message = match &self {
315 McpError::PromptNotFound { prompt_name } => {
316 format!("Prompt '{prompt_name}' not found. Available prompts can be listed using the list_prompts method.")
317 }
318 McpError::InvalidParameter {
319 parameter_name,
320 message,
321 } => {
322 format!("Invalid parameter '{parameter_name}': {message}. Please check the parameter format and try again.")
323 }
324 McpError::MissingParameter { parameter_name } => {
325 format!("Missing required parameter '{parameter_name}'. Please provide this parameter and try again.")
326 }
327 McpError::DatabaseOperationFailed { operation, source } => {
328 format!("Database operation '{operation}' failed: {source}. Please check your database connection and try again.")
329 }
330 McpError::SerializationFailed { operation, source } => {
331 format!("Serialization '{operation}' failed: {source}. Please check data format and try again.")
332 }
333 McpError::ValidationError { message } => {
334 format!("Validation error: {message}. Please check your input and try again.")
335 }
336 McpError::InternalError { message } => {
337 format!("Internal error: {message}. Please try again later or contact support if the issue persists.")
338 }
339 _ => {
340 format!("Error: {self}. Please try again later.")
341 }
342 };
343
344 GetPromptResult {
345 content: vec![Content::Text {
346 text: error_message,
347 }],
348 is_error: true,
349 }
350 }
351
352 #[must_use]
354 pub fn to_resource_result(self) -> ReadResourceResult {
355 let error_message = match &self {
356 McpError::ResourceNotFound { uri } => {
357 format!("Resource '{uri}' not found. Available resources can be listed using the list_resources method.")
358 }
359 McpError::DatabaseOperationFailed { operation, source } => {
360 format!("Database operation '{operation}' failed: {source}. Please check your database connection and try again.")
361 }
362 McpError::SerializationFailed { operation, source } => {
363 format!("Serialization '{operation}' failed: {source}. Please check data format and try again.")
364 }
365 McpError::InternalError { message } => {
366 format!("Internal error: {message}. Please try again later or contact support if the issue persists.")
367 }
368 _ => {
369 format!("Error: {self}. Please try again later.")
370 }
371 };
372
373 ReadResourceResult {
374 contents: vec![Content::Text {
375 text: error_message,
376 }],
377 }
378 }
379}
380
381pub type McpResult<T> = std::result::Result<T, McpError>;
383
384impl From<ThingsError> for McpError {
386 fn from(error: ThingsError) -> Self {
387 match error {
388 ThingsError::Database(e) => {
389 McpError::database_operation_failed("database operation", ThingsError::Database(e))
390 }
391 ThingsError::Serialization(e) => McpError::serialization_failed("serialization", e),
392 ThingsError::Io(e) => McpError::io_operation_failed("io operation", e),
393 ThingsError::DatabaseNotFound { path } => {
394 McpError::configuration_error(format!("Database not found at: {path}"))
395 }
396 ThingsError::InvalidUuid { uuid } => {
397 McpError::validation_error(format!("Invalid UUID format: {uuid}"))
398 }
399 ThingsError::InvalidDate { date } => {
400 McpError::validation_error(format!("Invalid date format: {date}"))
401 }
402 ThingsError::TaskNotFound { uuid } => {
403 McpError::validation_error(format!("Task not found: {uuid}"))
404 }
405 ThingsError::ProjectNotFound { uuid } => {
406 McpError::validation_error(format!("Project not found: {uuid}"))
407 }
408 ThingsError::AreaNotFound { uuid } => {
409 McpError::validation_error(format!("Area not found: {uuid}"))
410 }
411 ThingsError::Validation { message } => McpError::validation_error(message),
412 ThingsError::InvalidCursor(message) => {
413 McpError::validation_error(format!("Invalid cursor: {message}"))
414 }
415 ThingsError::Configuration { message } => McpError::configuration_error(message),
416 ThingsError::DateValidation(e) => {
417 McpError::validation_error(format!("Date validation failed: {e}"))
418 }
419 ThingsError::DateConversion(e) => {
420 McpError::validation_error(format!("Date conversion failed: {e}"))
421 }
422 ThingsError::AppleScript { message } => {
423 McpError::internal_error(format!("AppleScript automation failed: {message}"))
424 }
425 ThingsError::Unknown { message } => McpError::internal_error(message),
426 e => McpError::internal_error(format!("unhandled Things error: {e:?}")),
427 }
428 }
429}
430
431impl From<serde_json::Error> for McpError {
432 fn from(error: serde_json::Error) -> Self {
433 McpError::serialization_failed("json serialization", error)
434 }
435}
436
437impl From<std::io::Error> for McpError {
438 fn from(error: std::io::Error) -> Self {
439 McpError::io_operation_failed("file operation", error)
440 }
441}
442
443#[derive(Debug, Serialize, Deserialize)]
445pub struct Tool {
446 pub name: String,
447 pub description: String,
448 #[serde(rename = "inputSchema")]
449 pub input_schema: Value,
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize)]
453pub struct CallToolRequest {
454 pub name: String,
455 pub arguments: Option<Value>,
456}
457
458#[derive(Debug, Serialize, Deserialize)]
459pub struct CallToolResult {
460 pub content: Vec<Content>,
461 #[serde(rename = "isError", skip_serializing_if = "std::ops::Not::not")]
462 pub is_error: bool,
463}
464
465#[derive(Debug, Serialize, Deserialize)]
466#[serde(tag = "type", rename_all = "lowercase")]
467pub enum Content {
468 Text { text: String },
469}
470
471#[derive(Debug, Serialize, Deserialize)]
472pub struct ListToolsResult {
473 pub tools: Vec<Tool>,
474}
475
476#[derive(Debug, Serialize, Deserialize)]
478pub struct Resource {
479 pub uri: String,
480 pub name: String,
481 pub description: String,
482 #[serde(rename = "mimeType")]
483 pub mime_type: Option<String>,
484}
485
486#[derive(Debug, Serialize, Deserialize)]
487pub struct ListResourcesResult {
488 pub resources: Vec<Resource>,
489}
490
491#[derive(Debug, Serialize, Deserialize)]
492pub struct ReadResourceRequest {
493 pub uri: String,
494}
495
496#[derive(Debug, Serialize, Deserialize)]
497pub struct ReadResourceResult {
498 pub contents: Vec<Content>,
499}
500
501#[derive(Debug, Serialize, Deserialize)]
503pub struct PromptArgument {
504 pub name: String,
505 #[serde(skip_serializing_if = "Option::is_none")]
506 pub description: Option<String>,
507 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
509 pub required: bool,
510}
511
512#[derive(Debug, Serialize, Deserialize)]
514pub struct Prompt {
515 pub name: String,
516 pub description: String,
517 pub arguments: Vec<PromptArgument>,
518}
519
520#[derive(Debug, Serialize, Deserialize)]
521pub struct ListPromptsResult {
522 pub prompts: Vec<Prompt>,
523}
524
525#[derive(Debug, Serialize, Deserialize)]
526pub struct GetPromptRequest {
527 pub name: String,
528 pub arguments: Option<Value>,
529}
530
531#[derive(Debug, Serialize, Deserialize)]
532pub struct GetPromptResult {
533 pub content: Vec<Content>,
534 pub is_error: bool,
535}
536
537pub struct ThingsMcpServer {
539 #[allow(dead_code)]
540 pub db: Arc<ThingsDatabase>,
541 mutations: Arc<dyn MutationBackend>,
547 unsafe_direct_db: bool,
550 process_check: fn() -> bool,
554 #[allow(dead_code)]
555 cache: Arc<Mutex<ThingsCache>>,
556 #[allow(dead_code)]
557 performance_monitor: Arc<Mutex<PerformanceMonitor>>,
558 #[allow(dead_code)]
559 exporter: DataExporter,
560 #[allow(dead_code)]
561 backup_manager: Arc<Mutex<BackupManager>>,
562 middleware_chain: MiddlewareChain,
564}
565
566fn build_jsonrpc_error_response(
582 id: Option<serde_json::Value>,
583 err: &things3_core::ThingsError,
584) -> Option<serde_json::Value> {
585 use serde_json::json;
586
587 let id = id?;
589
590 Some(json!({
595 "jsonrpc": "2.0",
596 "id": id,
597 "error": {
598 "code": -32601,
599 "message": err.to_string()
600 }
601 }))
602}
603
604#[allow(dead_code)]
605pub async fn start_mcp_server(
610 db: Arc<ThingsDatabase>,
611 config: ThingsConfig,
612 unsafe_direct_db: bool,
613) -> things3_core::Result<()> {
614 let io = StdIo::new();
615 start_mcp_server_generic(db, config, io, unsafe_direct_db).await
616}
617
618pub async fn start_mcp_server_generic<I: McpIo>(
623 db: Arc<ThingsDatabase>,
624 config: ThingsConfig,
625 mut io: I,
626 unsafe_direct_db: bool,
627) -> things3_core::Result<()> {
628 let server = Arc::new(tokio::sync::Mutex::new(ThingsMcpServer::new(
629 db,
630 config,
631 unsafe_direct_db,
632 )));
633
634 loop {
636 let line = io.read_line().await.map_err(|e| {
638 things3_core::ThingsError::unknown(format!("Failed to read from input: {}", e))
639 })?;
640
641 let Some(line) = line else {
643 break;
644 };
645
646 if line.is_empty() {
648 continue;
649 }
650
651 let request: serde_json::Value = serde_json::from_str(&line).map_err(|e| {
653 things3_core::ThingsError::unknown(format!("Failed to parse JSON-RPC request: {}", e))
654 })?;
655
656 let request_id = request.get("id").cloned();
662 let server_clone = Arc::clone(&server);
663 let response_opt = {
664 let server = server_clone.lock().await;
665 match server.handle_jsonrpc_request(request).await {
666 Ok(opt) => opt,
667 Err(e) => build_jsonrpc_error_response(request_id, &e),
668 }
669 };
670
671 if let Some(response) = response_opt {
673 let response_str = serde_json::to_string(&response).map_err(|e| {
674 things3_core::ThingsError::unknown(format!("Failed to serialize response: {}", e))
675 })?;
676
677 io.write_line(&response_str).await.map_err(|e| {
678 things3_core::ThingsError::unknown(format!("Failed to write response: {}", e))
679 })?;
680
681 io.flush().await.map_err(|e| {
682 things3_core::ThingsError::unknown(format!("Failed to flush output: {}", e))
683 })?;
684 }
685 }
687
688 Ok(())
689}
690
691pub async fn start_mcp_server_with_config(
700 db: Arc<ThingsDatabase>,
701 mcp_config: McpServerConfig,
702 unsafe_direct_db: bool,
703) -> things3_core::Result<()> {
704 let io = StdIo::new();
705 start_mcp_server_with_config_generic(db, mcp_config, io, unsafe_direct_db).await
706}
707
708pub async fn start_mcp_server_with_config_generic<I: McpIo>(
710 db: Arc<ThingsDatabase>,
711 mcp_config: McpServerConfig,
712 mut io: I,
713 unsafe_direct_db: bool,
714) -> things3_core::Result<()> {
715 let things_config = ThingsConfig::new(
717 mcp_config.database.path.clone(),
718 mcp_config.database.fallback_to_default,
719 );
720
721 let server = Arc::new(tokio::sync::Mutex::new(
722 ThingsMcpServer::new_with_mcp_config(db, things_config, mcp_config, unsafe_direct_db),
723 ));
724
725 loop {
727 let line = io.read_line().await.map_err(|e| {
729 things3_core::ThingsError::unknown(format!("Failed to read from input: {}", e))
730 })?;
731
732 let Some(line) = line else {
734 break;
735 };
736
737 if line.is_empty() {
739 continue;
740 }
741
742 let request: serde_json::Value = serde_json::from_str(&line).map_err(|e| {
744 things3_core::ThingsError::unknown(format!("Failed to parse JSON-RPC request: {}", e))
745 })?;
746
747 let request_id = request.get("id").cloned();
751 let server_clone = Arc::clone(&server);
752 let response_opt = {
753 let server = server_clone.lock().await;
754 match server.handle_jsonrpc_request(request).await {
755 Ok(opt) => opt,
756 Err(e) => build_jsonrpc_error_response(request_id, &e),
757 }
758 };
759
760 if let Some(response) = response_opt {
762 let response_str = serde_json::to_string(&response).map_err(|e| {
763 things3_core::ThingsError::unknown(format!("Failed to serialize response: {}", e))
764 })?;
765
766 io.write_line(&response_str).await.map_err(|e| {
767 things3_core::ThingsError::unknown(format!("Failed to write response: {}", e))
768 })?;
769
770 io.flush().await.map_err(|e| {
771 things3_core::ThingsError::unknown(format!("Failed to flush output: {}", e))
772 })?;
773 }
774 }
776
777 Ok(())
778}
779
780fn select_default_backend(
787 db: Arc<ThingsDatabase>,
788 unsafe_direct_db: bool,
789) -> Arc<dyn MutationBackend> {
790 #[cfg(target_os = "macos")]
791 {
792 if unsafe_direct_db {
793 Arc::new(SqlxBackend::new(db))
794 } else {
795 Arc::new(AppleScriptBackend::new(db))
796 }
797 }
798 #[cfg(not(target_os = "macos"))]
799 {
800 let _ = unsafe_direct_db;
801 Arc::new(SqlxBackend::new(db))
802 }
803}
804
805fn is_things3_running() -> bool {
812 #[cfg(target_os = "macos")]
813 {
814 std::process::Command::new("pgrep")
815 .args(["-x", "Things3"])
816 .status()
817 .map(|s| s.success())
818 .unwrap_or(false)
819 }
820 #[cfg(not(target_os = "macos"))]
821 {
822 false
823 }
824}
825
826impl ThingsMcpServer {
827 #[must_use]
828 pub fn new(db: Arc<ThingsDatabase>, config: ThingsConfig, unsafe_direct_db: bool) -> Self {
829 let mutations = select_default_backend(Arc::clone(&db), unsafe_direct_db);
830 let mut server = Self::with_mutation_backend(db, mutations, config);
831 server.unsafe_direct_db = unsafe_direct_db;
832 server
833 }
834
835 #[must_use]
843 pub fn with_mutation_backend(
844 db: Arc<ThingsDatabase>,
845 mutations: Arc<dyn MutationBackend>,
846 config: ThingsConfig,
847 ) -> Self {
848 let cache = ThingsCache::new_default();
849 let performance_monitor = PerformanceMonitor::new_default();
850 let exporter = DataExporter::new_default();
851 let backup_manager = BackupManager::new(config);
852 let mut middleware_config = MiddlewareConfig::default();
854 middleware_config.logging.enabled = false; let middleware_chain = middleware_config.build_chain();
856
857 Self {
858 db,
859 mutations,
860 unsafe_direct_db: false,
861 process_check: is_things3_running,
862 cache: Arc::new(Mutex::new(cache)),
863 performance_monitor: Arc::new(Mutex::new(performance_monitor)),
864 exporter,
865 backup_manager: Arc::new(Mutex::new(backup_manager)),
866 middleware_chain,
867 }
868 }
869
870 #[must_use]
872 pub fn with_middleware_config(
873 db: ThingsDatabase,
874 config: ThingsConfig,
875 middleware_config: MiddlewareConfig,
876 unsafe_direct_db: bool,
877 ) -> Self {
878 let db = Arc::new(db);
879 let mutations = select_default_backend(Arc::clone(&db), unsafe_direct_db);
880 let cache = ThingsCache::new_default();
881 let performance_monitor = PerformanceMonitor::new_default();
882 let exporter = DataExporter::new_default();
883 let backup_manager = BackupManager::new(config);
884 let middleware_chain = middleware_config.build_chain();
885
886 Self {
887 db,
888 mutations,
889 unsafe_direct_db,
890 process_check: is_things3_running,
891 cache: Arc::new(Mutex::new(cache)),
892 performance_monitor: Arc::new(Mutex::new(performance_monitor)),
893 exporter,
894 backup_manager: Arc::new(Mutex::new(backup_manager)),
895 middleware_chain,
896 }
897 }
898
899 #[must_use]
901 pub fn new_with_mcp_config(
902 db: Arc<ThingsDatabase>,
903 config: ThingsConfig,
904 mcp_config: McpServerConfig,
905 unsafe_direct_db: bool,
906 ) -> Self {
907 let mutations = select_default_backend(Arc::clone(&db), unsafe_direct_db);
908 let cache = ThingsCache::new_default();
909 let performance_monitor = PerformanceMonitor::new_default();
910 let exporter = DataExporter::new_default();
911 let backup_manager = BackupManager::new(config);
912
913 let middleware_config = MiddlewareConfig {
916 logging: middleware::LoggingConfig {
917 enabled: false, level: mcp_config.logging.level.clone(),
919 },
920 validation: middleware::ValidationConfig {
921 enabled: mcp_config.security.validation.enabled,
922 strict_mode: mcp_config.security.validation.strict_mode,
923 },
924 performance: middleware::PerformanceConfig {
925 enabled: mcp_config.performance.enabled,
926 slow_request_threshold_ms: mcp_config.performance.slow_request_threshold_ms,
927 },
928 security: middleware::SecurityConfig {
929 authentication: middleware::AuthenticationConfig {
930 enabled: mcp_config.security.authentication.enabled,
931 require_auth: mcp_config.security.authentication.require_auth,
932 jwt_secret: mcp_config.security.authentication.jwt_secret,
933 api_keys: mcp_config
934 .security
935 .authentication
936 .api_keys
937 .iter()
938 .map(|key| middleware::ApiKeyConfig {
939 key: key.key.clone(),
940 key_id: key.key_id.clone(),
941 permissions: key.permissions.clone(),
942 expires_at: key.expires_at.clone(),
943 })
944 .collect(),
945 oauth: mcp_config
946 .security
947 .authentication
948 .oauth
949 .as_ref()
950 .map(|oauth| middleware::OAuth2Config {
951 client_id: oauth.client_id.clone(),
952 client_secret: oauth.client_secret.clone(),
953 token_endpoint: oauth.token_endpoint.clone(),
954 scopes: oauth.scopes.clone(),
955 }),
956 },
957 rate_limiting: middleware::RateLimitingConfig {
958 enabled: mcp_config.security.rate_limiting.enabled,
959 requests_per_minute: mcp_config.security.rate_limiting.requests_per_minute,
960 burst_limit: mcp_config.security.rate_limiting.burst_limit,
961 custom_limits: mcp_config.security.rate_limiting.custom_limits.clone(),
962 },
963 },
964 };
965
966 let middleware_chain = middleware_config.build_chain();
967
968 Self {
969 db,
970 mutations,
971 unsafe_direct_db,
972 process_check: is_things3_running,
973 cache: Arc::new(Mutex::new(cache)),
974 performance_monitor: Arc::new(Mutex::new(performance_monitor)),
975 exporter,
976 backup_manager: Arc::new(Mutex::new(backup_manager)),
977 middleware_chain,
978 }
979 }
980
981 #[must_use]
983 pub fn middleware_chain(&self) -> &MiddlewareChain {
984 &self.middleware_chain
985 }
986
987 #[must_use]
991 pub fn backend_kind(&self) -> &'static str {
992 self.mutations.kind()
993 }
994
995 pub fn set_process_check_for_test(&mut self, check: fn() -> bool) {
1000 self.process_check = check;
1001 }
1002
1003 pub fn list_tools(&self) -> McpResult<ListToolsResult> {
1008 Ok(ListToolsResult {
1009 tools: Self::get_available_tools(),
1010 })
1011 }
1012
1013 pub async fn call_tool(&self, request: CallToolRequest) -> McpResult<CallToolResult> {
1018 self.middleware_chain
1019 .execute(
1020 request,
1021 |req| async move { self.handle_tool_call(req).await },
1022 )
1023 .await
1024 }
1025
1026 pub async fn call_tool_with_fallback(&self, request: CallToolRequest) -> CallToolResult {
1031 match self.handle_tool_call(request).await {
1032 Ok(result) => result,
1033 Err(error) => error.to_call_result(),
1034 }
1035 }
1036
1037 pub fn list_resources(&self) -> McpResult<ListResourcesResult> {
1042 Ok(ListResourcesResult {
1043 resources: Self::get_available_resources(),
1044 })
1045 }
1046
1047 pub async fn read_resource(
1052 &self,
1053 request: ReadResourceRequest,
1054 ) -> McpResult<ReadResourceResult> {
1055 self.handle_resource_read(request).await
1056 }
1057
1058 pub async fn read_resource_with_fallback(
1063 &self,
1064 request: ReadResourceRequest,
1065 ) -> ReadResourceResult {
1066 match self.handle_resource_read(request).await {
1067 Ok(result) => result,
1068 Err(error) => error.to_resource_result(),
1069 }
1070 }
1071
1072 pub fn list_prompts(&self) -> McpResult<ListPromptsResult> {
1077 Ok(ListPromptsResult {
1078 prompts: Self::get_available_prompts(),
1079 })
1080 }
1081
1082 pub async fn get_prompt(&self, request: GetPromptRequest) -> McpResult<GetPromptResult> {
1087 self.handle_prompt_request(request).await
1088 }
1089
1090 pub async fn get_prompt_with_fallback(&self, request: GetPromptRequest) -> GetPromptResult {
1095 match self.handle_prompt_request(request).await {
1096 Ok(result) => result,
1097 Err(error) => error.to_prompt_result(),
1098 }
1099 }
1100
1101 fn get_available_tools() -> Vec<Tool> {
1103 let mut tools = Vec::new();
1104 tools.extend(Self::get_data_retrieval_tools());
1105 tools.extend(Self::get_task_management_tools());
1106 tools.extend(Self::get_bulk_operation_tools());
1107 tools.extend(Self::get_tag_management_tools());
1108 tools.extend(Self::get_analytics_tools());
1109 tools.extend(Self::get_backup_tools());
1110 tools.extend(Self::get_system_tools());
1111 tools
1112 }
1113
1114 fn get_data_retrieval_tools() -> Vec<Tool> {
1115 vec![
1116 Tool {
1117 name: "get_inbox".to_string(),
1118 description: "Get tasks from the inbox".to_string(),
1119 input_schema: serde_json::json!({
1120 "type": "object",
1121 "properties": {
1122 "limit": {
1123 "type": "integer",
1124 "description": "Maximum number of tasks to return"
1125 }
1126 }
1127 }),
1128 },
1129 Tool {
1130 name: "get_today".to_string(),
1131 description: "Get tasks scheduled for today".to_string(),
1132 input_schema: serde_json::json!({
1133 "type": "object",
1134 "properties": {
1135 "limit": {
1136 "type": "integer",
1137 "description": "Maximum number of tasks to return"
1138 }
1139 }
1140 }),
1141 },
1142 Tool {
1143 name: "get_projects".to_string(),
1144 description: "Get all projects, optionally filtered by area".to_string(),
1145 input_schema: serde_json::json!({
1146 "type": "object",
1147 "properties": {
1148 "area_uuid": {
1149 "type": "string",
1150 "description": "Optional area UUID to filter projects"
1151 }
1152 }
1153 }),
1154 },
1155 Tool {
1156 name: "get_areas".to_string(),
1157 description: "Get all areas".to_string(),
1158 input_schema: serde_json::json!({
1159 "type": "object",
1160 "properties": {}
1161 }),
1162 },
1163 Tool {
1164 name: "search_tasks".to_string(),
1165 description: "Search for tasks by query".to_string(),
1166 input_schema: serde_json::json!({
1167 "type": "object",
1168 "properties": {
1169 "query": {
1170 "type": "string",
1171 "description": "Search query"
1172 },
1173 "limit": {
1174 "type": "integer",
1175 "description": "Maximum number of tasks to return"
1176 }
1177 },
1178 "required": ["query"]
1179 }),
1180 },
1181 Tool {
1182 name: "get_recent_tasks".to_string(),
1183 description: "Get recently created or modified tasks".to_string(),
1184 input_schema: serde_json::json!({
1185 "type": "object",
1186 "properties": {
1187 "limit": {
1188 "type": "integer",
1189 "description": "Maximum number of tasks to return"
1190 },
1191 "hours": {
1192 "type": "integer",
1193 "description": "Number of hours to look back"
1194 }
1195 }
1196 }),
1197 },
1198 Tool {
1199 name: "logbook_search".to_string(),
1200 description: "Search completed tasks in the Things 3 logbook. Supports text search, date ranges, and filtering by project/area/tags.".to_string(),
1201 input_schema: serde_json::json!({
1202 "type": "object",
1203 "properties": {
1204 "search_text": {
1205 "type": "string",
1206 "description": "Search in task titles and notes (case-insensitive)"
1207 },
1208 "from_date": {
1209 "type": "string",
1210 "format": "date",
1211 "description": "Start date for completion date range (YYYY-MM-DD)"
1212 },
1213 "to_date": {
1214 "type": "string",
1215 "format": "date",
1216 "description": "End date for completion date range (YYYY-MM-DD)"
1217 },
1218 "project_uuid": {
1219 "type": "string",
1220 "format": "uuid",
1221 "description": "Filter by project UUID"
1222 },
1223 "area_uuid": {
1224 "type": "string",
1225 "format": "uuid",
1226 "description": "Filter by area UUID"
1227 },
1228 "tags": {
1229 "type": "array",
1230 "items": { "type": "string" },
1231 "description": "Filter by one or more tags (all must match)"
1232 },
1233 "limit": {
1234 "type": "integer",
1235 "default": 50,
1236 "minimum": 1,
1237 "maximum": 500,
1238 "description": "Maximum number of results to return (default: 50, max: 500)"
1239 },
1240 "offset": {
1241 "type": "integer",
1242 "default": 0,
1243 "minimum": 0,
1244 "description": "Number of results to skip for pagination (default: 0). Applied at the SQL level before tag filtering."
1245 }
1246 }
1247 }),
1248 },
1249 ]
1250 }
1251
1252 fn get_task_management_tools() -> Vec<Tool> {
1253 vec![
1254 Tool {
1255 name: "create_task".to_string(),
1256 description: "Create a new task in Things 3".to_string(),
1257 input_schema: serde_json::json!({
1258 "type": "object",
1259 "properties": {
1260 "title": {
1261 "type": "string",
1262 "description": "Task title (required)"
1263 },
1264 "task_type": {
1265 "type": "string",
1266 "enum": ["to-do", "project", "heading"],
1267 "description": "Task type (default: to-do)"
1268 },
1269 "notes": {
1270 "type": "string",
1271 "description": "Task notes"
1272 },
1273 "start_date": {
1274 "type": "string",
1275 "format": "date",
1276 "description": "Start date (YYYY-MM-DD)"
1277 },
1278 "deadline": {
1279 "type": "string",
1280 "format": "date",
1281 "description": "Deadline (YYYY-MM-DD)"
1282 },
1283 "project_uuid": {
1284 "type": "string",
1285 "format": "uuid",
1286 "description": "Project UUID"
1287 },
1288 "area_uuid": {
1289 "type": "string",
1290 "format": "uuid",
1291 "description": "Area UUID"
1292 },
1293 "parent_uuid": {
1294 "type": "string",
1295 "format": "uuid",
1296 "description": "Parent task UUID (for subtasks)"
1297 },
1298 "tags": {
1299 "type": "array",
1300 "items": {"type": "string"},
1301 "description": "Tag names"
1302 },
1303 "status": {
1304 "type": "string",
1305 "enum": ["incomplete", "completed", "canceled", "trashed"],
1306 "description": "Initial status (default: incomplete)"
1307 }
1308 },
1309 "required": ["title"]
1310 }),
1311 },
1312 Tool {
1313 name: "update_task".to_string(),
1314 description: "Update an existing task (only provided fields will be updated)"
1315 .to_string(),
1316 input_schema: serde_json::json!({
1317 "type": "object",
1318 "properties": {
1319 "uuid": {
1320 "type": "string",
1321 "format": "uuid",
1322 "description": "Task UUID (required)"
1323 },
1324 "title": {
1325 "type": "string",
1326 "description": "New task title"
1327 },
1328 "notes": {
1329 "type": "string",
1330 "description": "New task notes"
1331 },
1332 "start_date": {
1333 "type": "string",
1334 "format": "date",
1335 "description": "New start date (YYYY-MM-DD)"
1336 },
1337 "deadline": {
1338 "type": "string",
1339 "format": "date",
1340 "description": "New deadline (YYYY-MM-DD)"
1341 },
1342 "status": {
1343 "type": "string",
1344 "enum": ["incomplete", "completed", "canceled", "trashed"],
1345 "description": "New task status"
1346 },
1347 "project_uuid": {
1348 "type": "string",
1349 "format": "uuid",
1350 "description": "New project UUID"
1351 },
1352 "area_uuid": {
1353 "type": "string",
1354 "format": "uuid",
1355 "description": "New area UUID"
1356 },
1357 "tags": {
1358 "type": "array",
1359 "items": {"type": "string"},
1360 "description": "New tag names"
1361 }
1362 },
1363 "required": ["uuid"]
1364 }),
1365 },
1366 Tool {
1367 name: "complete_task".to_string(),
1368 description: "Mark a task as completed".to_string(),
1369 input_schema: serde_json::json!({
1370 "type": "object",
1371 "properties": {
1372 "uuid": {
1373 "type": "string",
1374 "format": "uuid",
1375 "description": "UUID of the task to complete"
1376 }
1377 },
1378 "required": ["uuid"]
1379 }),
1380 },
1381 Tool {
1382 name: "uncomplete_task".to_string(),
1383 description: "Mark a completed task as incomplete".to_string(),
1384 input_schema: serde_json::json!({
1385 "type": "object",
1386 "properties": {
1387 "uuid": {
1388 "type": "string",
1389 "format": "uuid",
1390 "description": "UUID of the task to mark incomplete"
1391 }
1392 },
1393 "required": ["uuid"]
1394 }),
1395 },
1396 Tool {
1397 name: "delete_task".to_string(),
1398 description: "Soft delete a task (set trashed=1)".to_string(),
1399 input_schema: serde_json::json!({
1400 "type": "object",
1401 "properties": {
1402 "uuid": {
1403 "type": "string",
1404 "format": "uuid",
1405 "description": "UUID of the task to delete"
1406 },
1407 "child_handling": {
1408 "type": "string",
1409 "enum": ["error", "cascade", "orphan"],
1410 "default": "error",
1411 "description": "How to handle child tasks: error (fail if children exist), cascade (delete children too), orphan (delete parent only)"
1412 }
1413 },
1414 "required": ["uuid"]
1415 }),
1416 },
1417 Tool {
1418 name: "bulk_create_tasks".to_string(),
1419 description: "Create multiple tasks at once".to_string(),
1420 input_schema: serde_json::json!({
1421 "type": "object",
1422 "properties": {
1423 "tasks": {
1424 "type": "array",
1425 "description": "Array of task objects to create (1–1000 items)",
1426 "minItems": 1,
1427 "maxItems": 1000,
1428 "items": {
1429 "type": "object",
1430 "properties": {
1431 "title": {
1432 "type": "string",
1433 "description": "Task title (required)"
1434 },
1435 "task_type": {
1436 "type": "string",
1437 "enum": ["to-do", "project", "heading"],
1438 "description": "Task type (default: to-do)"
1439 },
1440 "notes": {
1441 "type": "string",
1442 "description": "Task notes"
1443 },
1444 "start_date": {
1445 "type": "string",
1446 "format": "date",
1447 "description": "Start date (YYYY-MM-DD)"
1448 },
1449 "deadline": {
1450 "type": "string",
1451 "format": "date",
1452 "description": "Deadline (YYYY-MM-DD)"
1453 },
1454 "project_uuid": {
1455 "type": "string",
1456 "format": "uuid",
1457 "description": "Project UUID"
1458 },
1459 "area_uuid": {
1460 "type": "string",
1461 "format": "uuid",
1462 "description": "Area UUID"
1463 },
1464 "parent_uuid": {
1465 "type": "string",
1466 "format": "uuid",
1467 "description": "Parent task UUID (for subtasks)"
1468 },
1469 "tags": {
1470 "type": "array",
1471 "items": {"type": "string"},
1472 "description": "Tag names"
1473 },
1474 "status": {
1475 "type": "string",
1476 "enum": ["incomplete", "completed", "canceled", "trashed"],
1477 "description": "Initial status (default: incomplete)"
1478 }
1479 },
1480 "required": ["title"]
1481 }
1482 }
1483 },
1484 "required": ["tasks"]
1485 }),
1486 },
1487 Tool {
1488 name: "create_project".to_string(),
1489 description: "Create a new project (a task with type=project)".to_string(),
1490 input_schema: serde_json::json!({
1491 "type": "object",
1492 "properties": {
1493 "title": {
1494 "type": "string",
1495 "description": "Project title (required)"
1496 },
1497 "notes": {
1498 "type": "string",
1499 "description": "Project notes"
1500 },
1501 "area_uuid": {
1502 "type": "string",
1503 "format": "uuid",
1504 "description": "Area UUID"
1505 },
1506 "start_date": {
1507 "type": "string",
1508 "format": "date",
1509 "description": "Start date (YYYY-MM-DD)"
1510 },
1511 "deadline": {
1512 "type": "string",
1513 "format": "date",
1514 "description": "Deadline (YYYY-MM-DD)"
1515 },
1516 "tags": {
1517 "type": "array",
1518 "items": {"type": "string"},
1519 "description": "Tag names"
1520 }
1521 },
1522 "required": ["title"]
1523 }),
1524 },
1525 Tool {
1526 name: "update_project".to_string(),
1527 description: "Update an existing project (only provided fields will be updated)".to_string(),
1528 input_schema: serde_json::json!({
1529 "type": "object",
1530 "properties": {
1531 "uuid": {
1532 "type": "string",
1533 "format": "uuid",
1534 "description": "Project UUID (required)"
1535 },
1536 "title": {
1537 "type": "string",
1538 "description": "New project title"
1539 },
1540 "notes": {
1541 "type": "string",
1542 "description": "New project notes"
1543 },
1544 "area_uuid": {
1545 "type": "string",
1546 "format": "uuid",
1547 "description": "New area UUID"
1548 },
1549 "start_date": {
1550 "type": "string",
1551 "format": "date",
1552 "description": "New start date (YYYY-MM-DD)"
1553 },
1554 "deadline": {
1555 "type": "string",
1556 "format": "date",
1557 "description": "New deadline (YYYY-MM-DD)"
1558 },
1559 "tags": {
1560 "type": "array",
1561 "items": {"type": "string"},
1562 "description": "New tag names"
1563 }
1564 },
1565 "required": ["uuid"]
1566 }),
1567 },
1568 Tool {
1569 name: "complete_project".to_string(),
1570 description: "Mark a project as completed, with options for handling child tasks".to_string(),
1571 input_schema: serde_json::json!({
1572 "type": "object",
1573 "properties": {
1574 "uuid": {
1575 "type": "string",
1576 "format": "uuid",
1577 "description": "UUID of the project to complete"
1578 },
1579 "child_handling": {
1580 "type": "string",
1581 "enum": ["error", "cascade", "orphan"],
1582 "default": "error",
1583 "description": "How to handle child tasks: error (fail if children exist), cascade (complete children too), orphan (move children to inbox)"
1584 }
1585 },
1586 "required": ["uuid"]
1587 }),
1588 },
1589 Tool {
1590 name: "delete_project".to_string(),
1591 description: "Soft delete a project (set trashed=1), with options for handling child tasks".to_string(),
1592 input_schema: serde_json::json!({
1593 "type": "object",
1594 "properties": {
1595 "uuid": {
1596 "type": "string",
1597 "format": "uuid",
1598 "description": "UUID of the project to delete"
1599 },
1600 "child_handling": {
1601 "type": "string",
1602 "enum": ["error", "cascade", "orphan"],
1603 "default": "error",
1604 "description": "How to handle child tasks: error (fail if children exist), cascade (delete children too), orphan (move children to inbox)"
1605 }
1606 },
1607 "required": ["uuid"]
1608 }),
1609 },
1610 Tool {
1611 name: "create_area".to_string(),
1612 description: "Create a new area".to_string(),
1613 input_schema: serde_json::json!({
1614 "type": "object",
1615 "properties": {
1616 "title": {
1617 "type": "string",
1618 "description": "Area title (required)"
1619 }
1620 },
1621 "required": ["title"]
1622 }),
1623 },
1624 Tool {
1625 name: "update_area".to_string(),
1626 description: "Update an existing area".to_string(),
1627 input_schema: serde_json::json!({
1628 "type": "object",
1629 "properties": {
1630 "uuid": {
1631 "type": "string",
1632 "format": "uuid",
1633 "description": "Area UUID (required)"
1634 },
1635 "title": {
1636 "type": "string",
1637 "description": "New area title (required)"
1638 }
1639 },
1640 "required": ["uuid", "title"]
1641 }),
1642 },
1643 Tool {
1644 name: "delete_area".to_string(),
1645 description: "Delete an area (hard delete). All projects in this area will be moved to no area.".to_string(),
1646 input_schema: serde_json::json!({
1647 "type": "object",
1648 "properties": {
1649 "uuid": {
1650 "type": "string",
1651 "format": "uuid",
1652 "description": "UUID of the area to delete"
1653 }
1654 },
1655 "required": ["uuid"]
1656 }),
1657 },
1658 ]
1659 }
1660
1661 fn get_analytics_tools() -> Vec<Tool> {
1662 vec![
1663 Tool {
1664 name: "get_productivity_metrics".to_string(),
1665 description: "Get productivity metrics and statistics".to_string(),
1666 input_schema: serde_json::json!({
1667 "type": "object",
1668 "properties": {
1669 "days": {
1670 "type": "integer",
1671 "description": "Number of days to look back for metrics"
1672 }
1673 }
1674 }),
1675 },
1676 Tool {
1677 name: "export_data".to_string(),
1678 description: "Export data in various formats. When output_path is provided, writes to that file and returns a short confirmation; otherwise returns the data inline. Note: CSV format does not support data_type=all.".to_string(),
1679 input_schema: serde_json::json!({
1680 "type": "object",
1681 "properties": {
1682 "format": {
1683 "type": "string",
1684 "description": "Export format",
1685 "enum": ["json", "csv", "markdown"]
1686 },
1687 "data_type": {
1688 "type": "string",
1689 "description": "Type of data to export",
1690 "enum": ["tasks", "projects", "areas", "all"]
1691 },
1692 "output_path": {
1693 "type": "string",
1694 "description": "Optional absolute path to write the export file. Supports leading ~ for home directory. When provided, returns a confirmation object instead of inline data."
1695 }
1696 },
1697 "required": ["format", "data_type"]
1698 }),
1699 },
1700 ]
1701 }
1702
1703 fn get_backup_tools() -> Vec<Tool> {
1704 vec![
1705 Tool {
1706 name: "backup_database".to_string(),
1707 description: "Create a backup of the Things 3 database".to_string(),
1708 input_schema: serde_json::json!({
1709 "type": "object",
1710 "properties": {
1711 "backup_dir": {
1712 "type": "string",
1713 "description": "Directory to store the backup"
1714 },
1715 "description": {
1716 "type": "string",
1717 "description": "Optional description for the backup"
1718 }
1719 },
1720 "required": ["backup_dir"]
1721 }),
1722 },
1723 Tool {
1724 name: "restore_database".to_string(),
1725 description: "Restore from a backup".to_string(),
1726 input_schema: serde_json::json!({
1727 "type": "object",
1728 "properties": {
1729 "backup_path": {
1730 "type": "string",
1731 "description": "Path to the backup file"
1732 }
1733 },
1734 "required": ["backup_path"]
1735 }),
1736 },
1737 Tool {
1738 name: "list_backups".to_string(),
1739 description: "List available backups".to_string(),
1740 input_schema: serde_json::json!({
1741 "type": "object",
1742 "properties": {
1743 "backup_dir": {
1744 "type": "string",
1745 "description": "Directory containing backups"
1746 }
1747 },
1748 "required": ["backup_dir"]
1749 }),
1750 },
1751 ]
1752 }
1753
1754 fn get_system_tools() -> Vec<Tool> {
1755 vec![
1756 Tool {
1757 name: "get_performance_stats".to_string(),
1758 description: "Get performance statistics and metrics".to_string(),
1759 input_schema: serde_json::json!({
1760 "type": "object",
1761 "properties": {}
1762 }),
1763 },
1764 Tool {
1765 name: "get_system_metrics".to_string(),
1766 description: "Get current system resource metrics".to_string(),
1767 input_schema: serde_json::json!({
1768 "type": "object",
1769 "properties": {}
1770 }),
1771 },
1772 Tool {
1773 name: "get_cache_stats".to_string(),
1774 description: "Get cache statistics and hit rates".to_string(),
1775 input_schema: serde_json::json!({
1776 "type": "object",
1777 "properties": {}
1778 }),
1779 },
1780 ]
1781 }
1782
1783 fn get_bulk_operation_tools() -> Vec<Tool> {
1784 vec![
1785 Tool {
1786 name: "bulk_move".to_string(),
1787 description: "Move multiple tasks to a project or area (transactional)".to_string(),
1788 input_schema: serde_json::json!({
1789 "type": "object",
1790 "properties": {
1791 "task_uuids": {
1792 "type": "array",
1793 "items": {"type": "string"},
1794 "description": "Array of task UUIDs to move"
1795 },
1796 "project_uuid": {
1797 "type": "string",
1798 "format": "uuid",
1799 "description": "Target project UUID (optional)"
1800 },
1801 "area_uuid": {
1802 "type": "string",
1803 "format": "uuid",
1804 "description": "Target area UUID (optional)"
1805 }
1806 },
1807 "required": ["task_uuids"]
1808 }),
1809 },
1810 Tool {
1811 name: "bulk_update_dates".to_string(),
1812 description: "Update dates for multiple tasks with validation (transactional)"
1813 .to_string(),
1814 input_schema: serde_json::json!({
1815 "type": "object",
1816 "properties": {
1817 "task_uuids": {
1818 "type": "array",
1819 "items": {"type": "string"},
1820 "description": "Array of task UUIDs to update"
1821 },
1822 "start_date": {
1823 "type": "string",
1824 "format": "date",
1825 "description": "New start date (YYYY-MM-DD, optional)"
1826 },
1827 "deadline": {
1828 "type": "string",
1829 "format": "date",
1830 "description": "New deadline (YYYY-MM-DD, optional)"
1831 },
1832 "clear_start_date": {
1833 "type": "boolean",
1834 "description": "Clear start date (set to NULL, default: false)"
1835 },
1836 "clear_deadline": {
1837 "type": "boolean",
1838 "description": "Clear deadline (set to NULL, default: false)"
1839 }
1840 },
1841 "required": ["task_uuids"]
1842 }),
1843 },
1844 Tool {
1845 name: "bulk_complete".to_string(),
1846 description: "Mark multiple tasks as completed (transactional)".to_string(),
1847 input_schema: serde_json::json!({
1848 "type": "object",
1849 "properties": {
1850 "task_uuids": {
1851 "type": "array",
1852 "items": {"type": "string"},
1853 "description": "Array of task UUIDs to complete"
1854 }
1855 },
1856 "required": ["task_uuids"]
1857 }),
1858 },
1859 Tool {
1860 name: "bulk_delete".to_string(),
1861 description: "Delete multiple tasks (soft delete, transactional)".to_string(),
1862 input_schema: serde_json::json!({
1863 "type": "object",
1864 "properties": {
1865 "task_uuids": {
1866 "type": "array",
1867 "items": {"type": "string"},
1868 "description": "Array of task UUIDs to delete"
1869 }
1870 },
1871 "required": ["task_uuids"]
1872 }),
1873 },
1874 ]
1875 }
1876
1877 fn get_tag_management_tools() -> Vec<Tool> {
1878 vec![
1879 Tool {
1881 name: "search_tags".to_string(),
1882 description: "Search for existing tags (finds exact and similar matches)"
1883 .to_string(),
1884 input_schema: serde_json::json!({
1885 "type": "object",
1886 "properties": {
1887 "query": {
1888 "type": "string",
1889 "description": "Search query for tag titles"
1890 },
1891 "include_similar": {
1892 "type": "boolean",
1893 "description": "Include fuzzy matches (default: true)"
1894 },
1895 "min_similarity": {
1896 "type": "number",
1897 "description": "Minimum similarity score 0.0-1.0 (default: 0.7)"
1898 }
1899 },
1900 "required": ["query"]
1901 }),
1902 },
1903 Tool {
1904 name: "get_tag_suggestions".to_string(),
1905 description: "Get tag suggestions for a title (prevents duplicates)".to_string(),
1906 input_schema: serde_json::json!({
1907 "type": "object",
1908 "properties": {
1909 "title": {
1910 "type": "string",
1911 "description": "Proposed tag title"
1912 }
1913 },
1914 "required": ["title"]
1915 }),
1916 },
1917 Tool {
1918 name: "get_popular_tags".to_string(),
1919 description: "Get most frequently used tags".to_string(),
1920 input_schema: serde_json::json!({
1921 "type": "object",
1922 "properties": {
1923 "limit": {
1924 "type": "integer",
1925 "description": "Maximum number of tags to return (default: 20)"
1926 }
1927 }
1928 }),
1929 },
1930 Tool {
1931 name: "get_recent_tags".to_string(),
1932 description: "Get recently used tags".to_string(),
1933 input_schema: serde_json::json!({
1934 "type": "object",
1935 "properties": {
1936 "limit": {
1937 "type": "integer",
1938 "description": "Maximum number of tags to return (default: 20)"
1939 }
1940 }
1941 }),
1942 },
1943 Tool {
1945 name: "create_tag".to_string(),
1946 description: "Create a new tag (checks for duplicates first)".to_string(),
1947 input_schema: serde_json::json!({
1948 "type": "object",
1949 "properties": {
1950 "title": {
1951 "type": "string",
1952 "description": "Tag title (required)"
1953 },
1954 "shortcut": {
1955 "type": "string",
1956 "description": "Keyboard shortcut"
1957 },
1958 "parent_uuid": {
1959 "type": "string",
1960 "format": "uuid",
1961 "description": "Parent tag UUID for nesting"
1962 },
1963 "force": {
1964 "type": "boolean",
1965 "description": "Skip duplicate check (default: false)"
1966 }
1967 },
1968 "required": ["title"]
1969 }),
1970 },
1971 Tool {
1972 name: "update_tag".to_string(),
1973 description: "Update an existing tag".to_string(),
1974 input_schema: serde_json::json!({
1975 "type": "object",
1976 "properties": {
1977 "uuid": {
1978 "type": "string",
1979 "format": "uuid",
1980 "description": "Tag UUID (required)"
1981 },
1982 "title": {
1983 "type": "string",
1984 "description": "New title"
1985 },
1986 "shortcut": {
1987 "type": "string",
1988 "description": "New shortcut"
1989 },
1990 "parent_uuid": {
1991 "type": "string",
1992 "format": "uuid",
1993 "description": "New parent UUID"
1994 }
1995 },
1996 "required": ["uuid"]
1997 }),
1998 },
1999 Tool {
2000 name: "delete_tag".to_string(),
2001 description: "Delete a tag".to_string(),
2002 input_schema: serde_json::json!({
2003 "type": "object",
2004 "properties": {
2005 "uuid": {
2006 "type": "string",
2007 "format": "uuid",
2008 "description": "Tag UUID (required)"
2009 },
2010 "remove_from_tasks": {
2011 "type": "boolean",
2012 "description": "Remove tag from all tasks (default: false)"
2013 }
2014 },
2015 "required": ["uuid"]
2016 }),
2017 },
2018 Tool {
2019 name: "merge_tags".to_string(),
2020 description: "Merge two tags (combine source into target)".to_string(),
2021 input_schema: serde_json::json!({
2022 "type": "object",
2023 "properties": {
2024 "source_uuid": {
2025 "type": "string",
2026 "format": "uuid",
2027 "description": "UUID of tag to merge from (will be deleted)"
2028 },
2029 "target_uuid": {
2030 "type": "string",
2031 "format": "uuid",
2032 "description": "UUID of tag to merge into (will remain)"
2033 }
2034 },
2035 "required": ["source_uuid", "target_uuid"]
2036 }),
2037 },
2038 Tool {
2040 name: "add_tag_to_task".to_string(),
2041 description: "Add a tag to a task (suggests existing tags)".to_string(),
2042 input_schema: serde_json::json!({
2043 "type": "object",
2044 "properties": {
2045 "task_uuid": {
2046 "type": "string",
2047 "format": "uuid",
2048 "description": "Task UUID (required)"
2049 },
2050 "tag_title": {
2051 "type": "string",
2052 "description": "Tag title (required)"
2053 }
2054 },
2055 "required": ["task_uuid", "tag_title"]
2056 }),
2057 },
2058 Tool {
2059 name: "remove_tag_from_task".to_string(),
2060 description: "Remove a tag from a task".to_string(),
2061 input_schema: serde_json::json!({
2062 "type": "object",
2063 "properties": {
2064 "task_uuid": {
2065 "type": "string",
2066 "format": "uuid",
2067 "description": "Task UUID (required)"
2068 },
2069 "tag_title": {
2070 "type": "string",
2071 "description": "Tag title (required)"
2072 }
2073 },
2074 "required": ["task_uuid", "tag_title"]
2075 }),
2076 },
2077 Tool {
2078 name: "set_task_tags".to_string(),
2079 description: "Replace all tags on a task".to_string(),
2080 input_schema: serde_json::json!({
2081 "type": "object",
2082 "properties": {
2083 "task_uuid": {
2084 "type": "string",
2085 "format": "uuid",
2086 "description": "Task UUID (required)"
2087 },
2088 "tag_titles": {
2089 "type": "array",
2090 "items": {"type": "string"},
2091 "description": "Array of tag titles"
2092 }
2093 },
2094 "required": ["task_uuid", "tag_titles"]
2095 }),
2096 },
2097 Tool {
2099 name: "get_tag_statistics".to_string(),
2100 description: "Get detailed statistics for a tag".to_string(),
2101 input_schema: serde_json::json!({
2102 "type": "object",
2103 "properties": {
2104 "uuid": {
2105 "type": "string",
2106 "format": "uuid",
2107 "description": "Tag UUID (required)"
2108 }
2109 },
2110 "required": ["uuid"]
2111 }),
2112 },
2113 Tool {
2114 name: "find_duplicate_tags".to_string(),
2115 description: "Find duplicate or highly similar tags".to_string(),
2116 input_schema: serde_json::json!({
2117 "type": "object",
2118 "properties": {
2119 "min_similarity": {
2120 "type": "number",
2121 "description": "Minimum similarity score 0.0-1.0 (default: 0.85)"
2122 }
2123 }
2124 }),
2125 },
2126 Tool {
2127 name: "get_tag_completions".to_string(),
2128 description: "Get tag auto-completions for partial input".to_string(),
2129 input_schema: serde_json::json!({
2130 "type": "object",
2131 "properties": {
2132 "prefix": {
2133 "type": "string",
2134 "description": "Partial tag input to complete"
2135 },
2136 "limit": {
2137 "type": "integer",
2138 "description": "Maximum completions to return (default: 10)"
2139 }
2140 },
2141 "required": ["prefix"]
2144 }),
2145 },
2146 ]
2147 }
2148
2149 async fn handle_tool_call(&self, request: CallToolRequest) -> McpResult<CallToolResult> {
2151 let tool_name = &request.name;
2152 let arguments = request.arguments.unwrap_or_default();
2153
2154 let result = match tool_name.as_str() {
2155 "get_inbox" => self.handle_get_inbox(arguments).await,
2156 "get_today" => self.handle_get_today(arguments).await,
2157 "get_projects" => self.handle_get_projects(arguments).await,
2158 "get_areas" => self.handle_get_areas(arguments).await,
2159 "search_tasks" => self.handle_search_tasks(arguments).await,
2160 "logbook_search" => self.handle_logbook_search(arguments).await,
2161 "create_task" => self.handle_create_task(arguments).await,
2162 "update_task" => self.handle_update_task(arguments).await,
2163 "complete_task" => self.handle_complete_task(arguments).await,
2164 "uncomplete_task" => self.handle_uncomplete_task(arguments).await,
2165 "delete_task" => self.handle_delete_task(arguments).await,
2166 "bulk_move" => self.handle_bulk_move(arguments).await,
2167 "bulk_update_dates" => self.handle_bulk_update_dates(arguments).await,
2168 "bulk_complete" => self.handle_bulk_complete(arguments).await,
2169 "bulk_delete" => self.handle_bulk_delete(arguments).await,
2170 "create_project" => self.handle_create_project(arguments).await,
2171 "update_project" => self.handle_update_project(arguments).await,
2172 "complete_project" => self.handle_complete_project(arguments).await,
2173 "delete_project" => self.handle_delete_project(arguments).await,
2174 "create_area" => self.handle_create_area(arguments).await,
2175 "update_area" => self.handle_update_area(arguments).await,
2176 "delete_area" => self.handle_delete_area(arguments).await,
2177 "get_productivity_metrics" => self.handle_get_productivity_metrics(arguments).await,
2178 "export_data" => self.handle_export_data(arguments).await,
2179 "bulk_create_tasks" => self.handle_bulk_create_tasks(arguments).await,
2180 "get_recent_tasks" => self.handle_get_recent_tasks(arguments).await,
2181 "backup_database" => self.handle_backup_database(arguments).await,
2182 "restore_database" => self.handle_restore_database(arguments).await,
2183 "list_backups" => self.handle_list_backups(arguments).await,
2184 "get_performance_stats" => self.handle_get_performance_stats(arguments).await,
2185 "get_system_metrics" => self.handle_get_system_metrics(arguments).await,
2186 "get_cache_stats" => self.handle_get_cache_stats(arguments).await,
2187 "search_tags" => self.handle_search_tags_tool(arguments).await,
2189 "get_tag_suggestions" => self.handle_get_tag_suggestions(arguments).await,
2190 "get_popular_tags" => self.handle_get_popular_tags(arguments).await,
2191 "get_recent_tags" => self.handle_get_recent_tags(arguments).await,
2192 "create_tag" => self.handle_create_tag(arguments).await,
2194 "update_tag" => self.handle_update_tag(arguments).await,
2195 "delete_tag" => self.handle_delete_tag(arguments).await,
2196 "merge_tags" => self.handle_merge_tags(arguments).await,
2197 "add_tag_to_task" => self.handle_add_tag_to_task(arguments).await,
2199 "remove_tag_from_task" => self.handle_remove_tag_from_task(arguments).await,
2200 "set_task_tags" => self.handle_set_task_tags(arguments).await,
2201 "get_tag_statistics" => self.handle_get_tag_statistics(arguments).await,
2203 "find_duplicate_tags" => self.handle_find_duplicate_tags(arguments).await,
2204 "get_tag_completions" => self.handle_get_tag_completions(arguments).await,
2205 _ => {
2206 return Err(McpError::tool_not_found(tool_name));
2207 }
2208 };
2209
2210 result
2211 }
2212
2213 fn get_available_prompts() -> Vec<Prompt> {
2223 vec![
2224 Self::create_task_review_prompt(),
2225 Self::create_project_planning_prompt(),
2226 Self::create_productivity_analysis_prompt(),
2227 Self::create_backup_strategy_prompt(),
2228 ]
2229 }
2230
2231 fn create_task_review_prompt() -> Prompt {
2232 Prompt {
2233 name: "task_review".to_string(),
2234 description: "Review task for completeness and clarity".to_string(),
2235 arguments: vec![
2236 PromptArgument {
2237 name: "task_title".to_string(),
2238 description: Some("The title of the task to review".to_string()),
2239 required: true,
2240 },
2241 PromptArgument {
2242 name: "task_notes".to_string(),
2243 description: Some("Optional notes or description of the task".to_string()),
2244 required: false,
2245 },
2246 PromptArgument {
2247 name: "context".to_string(),
2248 description: Some("Optional context about the task or project".to_string()),
2249 required: false,
2250 },
2251 ],
2252 }
2253 }
2254
2255 fn create_project_planning_prompt() -> Prompt {
2256 Prompt {
2257 name: "project_planning".to_string(),
2258 description: "Help plan projects with tasks and deadlines".to_string(),
2259 arguments: vec![
2260 PromptArgument {
2261 name: "project_title".to_string(),
2262 description: Some("The title of the project to plan".to_string()),
2263 required: true,
2264 },
2265 PromptArgument {
2266 name: "project_description".to_string(),
2267 description: Some(
2268 "Description of what the project aims to achieve".to_string(),
2269 ),
2270 required: false,
2271 },
2272 PromptArgument {
2273 name: "deadline".to_string(),
2274 description: Some("Optional deadline for the project".to_string()),
2275 required: false,
2276 },
2277 PromptArgument {
2278 name: "complexity".to_string(),
2279 description: Some(
2280 "Project complexity level: simple, medium, or complex".to_string(),
2281 ),
2282 required: false,
2283 },
2284 ],
2285 }
2286 }
2287
2288 fn create_productivity_analysis_prompt() -> Prompt {
2289 Prompt {
2290 name: "productivity_analysis".to_string(),
2291 description: "Analyze productivity patterns".to_string(),
2292 arguments: vec![
2293 PromptArgument {
2294 name: "time_period".to_string(),
2295 description: Some(
2296 "Time period to analyze: week, month, quarter, or year".to_string(),
2297 ),
2298 required: true,
2299 },
2300 PromptArgument {
2301 name: "focus_area".to_string(),
2302 description: Some(
2303 "Specific area to focus on: completion_rate, time_management, task_distribution, or all".to_string(),
2304 ),
2305 required: false,
2306 },
2307 PromptArgument {
2308 name: "include_recommendations".to_string(),
2309 description: Some(
2310 "Whether to include improvement recommendations".to_string(),
2311 ),
2312 required: false,
2313 },
2314 ],
2315 }
2316 }
2317
2318 fn create_backup_strategy_prompt() -> Prompt {
2319 Prompt {
2320 name: "backup_strategy".to_string(),
2321 description: "Suggest backup strategies".to_string(),
2322 arguments: vec![
2323 PromptArgument {
2324 name: "data_volume".to_string(),
2325 description: Some(
2326 "Estimated data volume: small, medium, or large".to_string(),
2327 ),
2328 required: true,
2329 },
2330 PromptArgument {
2331 name: "frequency".to_string(),
2332 description: Some(
2333 "Desired backup frequency: daily, weekly, or monthly".to_string(),
2334 ),
2335 required: true,
2336 },
2337 PromptArgument {
2338 name: "retention_period".to_string(),
2339 description: Some(
2340 "How long to keep backups: 1_month, 3_months, 6_months, 1_year, or indefinite".to_string(),
2341 ),
2342 required: false,
2343 },
2344 PromptArgument {
2345 name: "storage_preference".to_string(),
2346 description: Some(
2347 "Preferred storage type: local, cloud, or hybrid".to_string(),
2348 ),
2349 required: false,
2350 },
2351 ],
2352 }
2353 }
2354
2355 fn get_available_resources() -> Vec<Resource> {
2357 vec![
2358 Resource {
2359 uri: "things://inbox".to_string(),
2360 name: "Inbox Tasks".to_string(),
2361 description: "Current inbox tasks from Things 3".to_string(),
2362 mime_type: Some("application/json".to_string()),
2363 },
2364 Resource {
2365 uri: "things://projects".to_string(),
2366 name: "All Projects".to_string(),
2367 description: "All projects in Things 3".to_string(),
2368 mime_type: Some("application/json".to_string()),
2369 },
2370 Resource {
2371 uri: "things://areas".to_string(),
2372 name: "All Areas".to_string(),
2373 description: "All areas in Things 3".to_string(),
2374 mime_type: Some("application/json".to_string()),
2375 },
2376 Resource {
2377 uri: "things://today".to_string(),
2378 name: "Today's Tasks".to_string(),
2379 description: "Tasks scheduled for today".to_string(),
2380 mime_type: Some("application/json".to_string()),
2381 },
2382 ]
2383 }
2384
2385 pub async fn handle_jsonrpc_request(
2392 &self,
2393 request: serde_json::Value,
2394 ) -> things3_core::Result<Option<serde_json::Value>> {
2395 use serde_json::json;
2396
2397 let method = request["method"].as_str().ok_or_else(|| {
2398 things3_core::ThingsError::unknown("Missing method in JSON-RPC request".to_string())
2399 })?;
2400 let params = request["params"].clone();
2401
2402 let is_notification = request.get("id").is_none();
2405
2406 if is_notification {
2408 match method {
2409 "notifications/initialized" => {
2410 return Ok(None);
2412 }
2413 _ => {
2414 return Ok(None);
2416 }
2417 }
2418 }
2419
2420 let id = request["id"].clone();
2422
2423 let result = match method {
2424 "initialize" => {
2425 let client_version = params
2430 .get("protocolVersion")
2431 .and_then(|v| v.as_str())
2432 .unwrap_or("2024-11-05");
2433 let protocol_version = if client_version >= "2025-03-26" {
2437 "2025-03-26"
2438 } else {
2439 "2024-11-05"
2440 };
2441 json!({
2442 "protocolVersion": protocol_version,
2443 "capabilities": {
2444 "tools": { "listChanged": false },
2445 "resources": { "subscribe": false, "listChanged": false },
2446 "prompts": { "listChanged": false }
2447 },
2448 "serverInfo": {
2449 "name": "things3-mcp",
2450 "version": env!("CARGO_PKG_VERSION")
2451 }
2452 })
2453 }
2454 "tools/list" => {
2455 let tools_result = self.list_tools().map_err(|e| {
2456 things3_core::ThingsError::unknown(format!("Failed to list tools: {}", e))
2457 })?;
2458 json!(tools_result)
2459 }
2460 "tools/call" => {
2461 let tool_name = params["name"]
2462 .as_str()
2463 .ok_or_else(|| {
2464 things3_core::ThingsError::unknown(
2465 "Missing tool name in params".to_string(),
2466 )
2467 })?
2468 .to_string();
2469 let arguments = params["arguments"].clone();
2470
2471 let call_request = CallToolRequest {
2472 name: tool_name,
2473 arguments: Some(arguments),
2474 };
2475
2476 let call_result = self.call_tool_with_fallback(call_request).await;
2482
2483 json!(call_result)
2484 }
2485 "resources/list" => {
2486 let resources_result = self.list_resources().map_err(|e| {
2487 things3_core::ThingsError::unknown(format!("Failed to list resources: {}", e))
2488 })?;
2489 json!(resources_result)
2491 }
2492 "resources/read" => {
2493 let uri = params["uri"]
2494 .as_str()
2495 .ok_or_else(|| {
2496 things3_core::ThingsError::unknown("Missing URI in params".to_string())
2497 })?
2498 .to_string();
2499
2500 let read_request = ReadResourceRequest { uri };
2501 let read_result = self.read_resource_with_fallback(read_request).await;
2503
2504 json!(read_result)
2505 }
2506 "prompts/list" => {
2507 let prompts_result = self.list_prompts().map_err(|e| {
2508 things3_core::ThingsError::unknown(format!("Failed to list prompts: {}", e))
2509 })?;
2510 json!(prompts_result)
2512 }
2513 "prompts/get" => {
2514 let prompt_name = params["name"]
2515 .as_str()
2516 .ok_or_else(|| {
2517 things3_core::ThingsError::unknown(
2518 "Missing prompt name in params".to_string(),
2519 )
2520 })?
2521 .to_string();
2522 let arguments = params.get("arguments").cloned();
2523
2524 let get_request = GetPromptRequest {
2525 name: prompt_name,
2526 arguments,
2527 };
2528
2529 let get_result = self.get_prompt_with_fallback(get_request).await;
2531
2532 json!(get_result)
2533 }
2534 _ => {
2535 return Ok(Some(json!({
2536 "jsonrpc": "2.0",
2537 "id": id,
2538 "error": {
2539 "code": -32601,
2540 "message": format!("Method not found: {}", method)
2541 }
2542 })));
2543 }
2544 };
2545
2546 Ok(Some(json!({
2547 "jsonrpc": "2.0",
2548 "id": id,
2549 "result": result
2550 })))
2551 }
2552}
2553
2554pub(in crate::mcp) fn expand_tilde(path: &str) -> McpResult<std::path::PathBuf> {
2555 if path == "~" || path.starts_with("~/") {
2556 let home = std::env::var("HOME").map_err(|_| {
2557 McpError::invalid_parameter(
2558 "output_path",
2559 "cannot expand ~: HOME environment variable is not set",
2560 )
2561 })?;
2562 Ok(std::path::PathBuf::from(format!("{}{}", home, &path[1..])))
2563 } else if path.starts_with('~') {
2564 Err(McpError::invalid_parameter(
2565 "output_path",
2566 "~user expansion is not supported; use an absolute path or ~/...",
2567 ))
2568 } else {
2569 Ok(std::path::PathBuf::from(path))
2570 }
2571}
2572
2573#[cfg(test)]
2574mod backend_selection_tests {
2575 use super::*;
2576
2577 fn build_server(unsafe_direct_db: bool) -> (ThingsMcpServer, tempfile::NamedTempFile) {
2580 let temp_file = tempfile::NamedTempFile::new().unwrap();
2581 let db_path = temp_file.path().to_path_buf();
2582 let db_path_clone = db_path.clone();
2583
2584 let db = std::thread::spawn(move || {
2585 tokio::runtime::Runtime::new()
2586 .unwrap()
2587 .block_on(async { ThingsDatabase::new(&db_path_clone).await.unwrap() })
2588 })
2589 .join()
2590 .unwrap();
2591
2592 let config = ThingsConfig::new(&db_path, false);
2593 let server = ThingsMcpServer::new(Arc::new(db), config, unsafe_direct_db);
2594 (server, temp_file)
2595 }
2596
2597 #[cfg(target_os = "macos")]
2598 #[tokio::test]
2599 async fn defaults_to_applescript_on_macos() {
2600 let (server, _tmp) = build_server(false);
2601 assert_eq!(server.backend_kind(), "applescript");
2602 }
2603
2604 #[tokio::test]
2605 async fn unsafe_flag_selects_sqlx() {
2606 let (server, _tmp) = build_server(true);
2607 assert_eq!(server.backend_kind(), "sqlx");
2608 }
2609
2610 #[tokio::test]
2611 async fn restore_database_refuses_without_flag() {
2612 let (server, _tmp) = build_server(false);
2613 let err = server
2614 .handle_restore_database(serde_json::json!({"backup_path": "/tmp/x"}))
2615 .await
2616 .expect_err("must refuse when --unsafe-direct-db is not set");
2617 let msg = err.to_string();
2618 assert!(
2619 msg.contains("--unsafe-direct-db"),
2620 "error should name the flag, got: {msg}"
2621 );
2622 }
2623
2624 #[tokio::test]
2625 async fn restore_database_refuses_when_things3_running() {
2626 let (mut server, _tmp) = build_server(true);
2627 server.set_process_check_for_test(|| true);
2628 let err = server
2629 .handle_restore_database(serde_json::json!({"backup_path": "/tmp/x"}))
2630 .await
2631 .expect_err("must refuse while Things 3 is running");
2632 let msg = err.to_string();
2633 assert!(
2634 msg.contains("Things 3"),
2635 "error should mention Things 3, got: {msg}"
2636 );
2637 }
2638}