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