Skip to main content

things3_cli/mcp/
mod.rs

1//! MCP (Model Context Protocol) server implementation for Things 3 integration
2
3use 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;
17// pub mod performance_tests; // Temporarily disabled due to API changes
18pub mod test_harness;
19mod tools;
20
21use io_wrapper::{McpIo, StdIo};
22use middleware::{MiddlewareChain, MiddlewareConfig};
23
24/// MCP-specific error types for better error handling and user experience
25#[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    /// Create a tool not found error
108    pub fn tool_not_found(tool_name: impl Into<String>) -> Self {
109        Self::ToolNotFound {
110            tool_name: tool_name.into(),
111        }
112    }
113
114    /// Create a resource not found error
115    pub fn resource_not_found(uri: impl Into<String>) -> Self {
116        Self::ResourceNotFound { uri: uri.into() }
117    }
118
119    /// Create a prompt not found error
120    pub fn prompt_not_found(prompt_name: impl Into<String>) -> Self {
121        Self::PromptNotFound {
122            prompt_name: prompt_name.into(),
123        }
124    }
125
126    /// Create an invalid parameter error
127    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    /// Create a missing parameter error
138    pub fn missing_parameter(parameter_name: impl Into<String>) -> Self {
139        Self::MissingParameter {
140            parameter_name: parameter_name.into(),
141        }
142    }
143
144    /// Create an invalid format error
145    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    /// Create an invalid data type error
153    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    /// Create a database operation failed error
161    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    /// Create a backup operation failed error
169    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    /// Create an export operation failed error
177    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    /// Create a performance monitoring failed error
185    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    /// Create a cache operation failed error
196    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    /// Create a serialization failed error
204    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    /// Create an IO operation failed error
212    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    /// Create a configuration error
220    pub fn configuration_error(message: impl Into<String>) -> Self {
221        Self::ConfigurationError {
222            message: message.into(),
223        }
224    }
225
226    /// Create a validation error
227    pub fn validation_error(message: impl Into<String>) -> Self {
228        Self::ValidationError {
229            message: message.into(),
230        }
231    }
232
233    /// Create an internal error
234    pub fn internal_error(message: impl Into<String>) -> Self {
235        Self::InternalError {
236            message: message.into(),
237        }
238    }
239
240    /// Convert error to MCP call result
241    #[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    /// Convert error to MCP prompt result
312    #[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    /// Convert error to MCP resource result
353    #[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
381/// Result type alias for MCP operations
382pub type McpResult<T> = std::result::Result<T, McpError>;
383
384/// From trait implementations for common error types
385impl 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/// Simplified MCP types for our implementation
444#[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/// MCP Resource for data exposure
477#[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/// Describes an argument that an MCP prompt can accept.
502#[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    /// Omitted from JSON when false; `true` serializes as `"required": true`.
508    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
509    pub required: bool,
510}
511
512/// MCP Prompt for reusable templates
513#[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
537/// MCP server for Things 3 integration
538pub struct ThingsMcpServer {
539    #[allow(dead_code)]
540    pub db: Arc<ThingsDatabase>,
541    /// Mutation backend used for all write operations.
542    ///
543    /// On macOS the default is `AppleScriptBackend` (CulturedCode-supported);
544    /// `--unsafe-direct-db` falls back to `SqlxBackend`. On non-macOS the
545    /// default is always `SqlxBackend` (no Things 3 install to corrupt).
546    mutations: Arc<dyn MutationBackend>,
547    /// Whether the user opted into the deprecated direct-DB path. Required to
548    /// run `restore_database` — see `handle_restore_database` for the gate.
549    unsafe_direct_db: bool,
550    /// Stub-able predicate for "is Things 3 currently running?". Defaults to
551    /// `is_things3_running`; tests inject a constant function instead of
552    /// shelling out to `pgrep`.
553    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 for cross-cutting concerns
563    middleware_chain: MiddlewareChain,
564}
565
566/// Build a JSON-RPC error response from a `ThingsError` produced inside the
567/// request loop.
568///
569/// This is the connection-survival path: if `handle_jsonrpc_request` returns
570/// `Err`, we convert it to a structured JSON-RPC error response instead of
571/// propagating with `?` (which would terminate the server loop and drop the
572/// connection — see issue #148).
573///
574/// Returns `None` when the request is a JSON-RPC notification (no `id` field):
575/// the spec forbids responses to notifications, so we silently drop the error.
576///
577/// Tool/resource/prompt errors are NOT routed here — those go through the
578/// `*_with_fallback` variants and surface as `isError: true` envelopes inside
579/// the result. Only protocol-level failures (missing method, malformed params)
580/// reach this helper.
581fn 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    // Notifications (no `id`) must not receive a response per JSON-RPC 2.0.
588    let id = id?;
589
590    // -32601 (Method Not Found) is the most precise fit for the protocol-level
591    // failures that reach here: unknown method names, missing dispatch targets.
592    // Use -32603 (Internal Error) only if we ever distinguish structural/parse
593    // errors, which are caught earlier and already map to -32700 / -32600.
594    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)]
605/// Start the MCP server
606///
607/// # Errors
608/// Returns an error if the server fails to start
609pub 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
618/// Generic MCP server implementation that works with any I/O implementation
619///
620/// This function is generic over the I/O layer, allowing it to work with both
621/// production stdin/stdout (via `StdIo`) and test mocks (via `MockIo`).
622pub 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    // Read JSON-RPC requests line by line
635    loop {
636        // Read a line from input
637        let line = io.read_line().await.map_err(|e| {
638            things3_core::ThingsError::unknown(format!("Failed to read from input: {}", e))
639        })?;
640
641        // EOF reached
642        let Some(line) = line else {
643            break;
644        };
645
646        // Skip empty lines
647        if line.is_empty() {
648            continue;
649        }
650
651        // Parse JSON-RPC request
652        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        // Handle the request. If the handler errors we MUST NOT propagate with
657        // `?` — that terminates the loop and drops the MCP connection (#148).
658        // Convert handler errors into JSON-RPC error responses instead.
659        // Extract `id` before consuming `request` so we can use it in the error
660        // path without cloning the entire request value on every hot-path call.
661        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        // Only write response if this is a request (not a notification)
672        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        // Notifications don't require a response, so we silently continue
686    }
687
688    Ok(())
689}
690
691/// Start the MCP server with comprehensive configuration
692///
693/// # Arguments
694/// * `db` - Database connection
695/// * `mcp_config` - MCP server configuration
696///
697/// # Errors
698/// Returns an error if the server fails to start
699pub 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
708/// Generic MCP server with config implementation that works with any I/O implementation
709pub 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    // Convert McpServerConfig to ThingsConfig for backward compatibility
716    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    // Read JSON-RPC requests line by line
726    loop {
727        // Read a line from input
728        let line = io.read_line().await.map_err(|e| {
729            things3_core::ThingsError::unknown(format!("Failed to read from input: {}", e))
730        })?;
731
732        // EOF reached
733        let Some(line) = line else {
734            break;
735        };
736
737        // Skip empty lines
738        if line.is_empty() {
739            continue;
740        }
741
742        // Parse JSON-RPC request
743        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        // Handle the request. See note in `start_mcp_server_generic` — handler
748        // errors are converted to JSON-RPC error responses instead of being
749        // propagated with `?`, which would terminate the loop (#148).
750        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        // Only write response if this is a request (not a notification)
761        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        // Notifications don't require a response, so we silently continue
775    }
776
777    Ok(())
778}
779
780/// Pick the default `MutationBackend` for a server invocation.
781///
782/// On macOS the safe default is `AppleScriptBackend`. `--unsafe-direct-db` /
783/// `THINGS_UNSAFE_DIRECT_DB=1` falls back to the deprecated `SqlxBackend`.
784/// On non-macOS the default is always `SqlxBackend` — there's no Things 3
785/// install to corrupt, and `AppleScriptBackend` is platform-gated.
786fn 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
805/// Returns `true` if Things 3 is currently running (macOS only).
806///
807/// Used as a precondition for `restore_database` — overwriting the live
808/// SQLite file under a running Things 3 process is the highest-corruption
809/// scenario CulturedCode warns about. On non-macOS we always return `false`
810/// because there is no Things 3 process to detect.
811fn 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    /// Create a new MCP server with a caller-provided mutation backend.
836    ///
837    /// Use this to inject `AppleScriptBackend` (issue #124) or a test double
838    /// without taking the default `SqlxBackend`. The `unsafe_direct_db` flag
839    /// defaults to `false`; callers gating `restore_database` should use
840    /// [`Self::new`] or set it after construction via the test-only
841    /// `set_unsafe_direct_db` helper.
842    #[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        // Use silent middleware config for MCP mode (no logging to stdout)
853        let mut middleware_config = MiddlewareConfig::default();
854        middleware_config.logging.enabled = false; // Disable logging to prevent stdout interference
855        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    /// Create a new MCP server with custom middleware configuration
871    #[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    /// Create a new MCP server with comprehensive configuration
900    #[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        // Convert McpServerConfig to MiddlewareConfig
914        // Always disable logging in MCP mode to prevent stdout interference with JSON-RPC
915        let middleware_config = MiddlewareConfig {
916            logging: middleware::LoggingConfig {
917                enabled: false, // Always disabled in MCP mode for JSON-RPC compatibility
918                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    /// Get the middleware chain for inspection or modification
982    #[must_use]
983    pub fn middleware_chain(&self) -> &MiddlewareChain {
984        &self.middleware_chain
985    }
986
987    /// The mutation backend's static identifier — `"applescript"` (safe
988    /// default on macOS) or `"sqlx"` (direct DB writes; deprecated). Used by
989    /// tests and operators to confirm which path is active.
990    #[must_use]
991    pub fn backend_kind(&self) -> &'static str {
992        self.mutations.kind()
993    }
994
995    /// Override the Things 3 process check used by `restore_database`.
996    ///
997    /// Tests use this to bypass the live `pgrep -x Things3` call. Production
998    /// code should never need it — the default predicate is correct.
999    pub fn set_process_check_for_test(&mut self, check: fn() -> bool) {
1000        self.process_check = check;
1001    }
1002
1003    /// List available MCP tools
1004    ///
1005    /// # Errors
1006    /// Returns an error if tool generation fails
1007    pub fn list_tools(&self) -> McpResult<ListToolsResult> {
1008        Ok(ListToolsResult {
1009            tools: Self::get_available_tools(),
1010        })
1011    }
1012
1013    /// Call a specific MCP tool
1014    ///
1015    /// # Errors
1016    /// Returns an error if tool execution fails or tool is not found
1017    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    /// Call a specific MCP tool with fallback error handling
1027    ///
1028    /// This method provides backward compatibility by converting `McpError` to `CallToolResult`
1029    /// for cases where the caller expects a `CallToolResult` even on error
1030    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    /// List available MCP resources
1038    ///
1039    /// # Errors
1040    /// Returns an error if resource generation fails
1041    pub fn list_resources(&self) -> McpResult<ListResourcesResult> {
1042        Ok(ListResourcesResult {
1043            resources: Self::get_available_resources(),
1044        })
1045    }
1046
1047    /// Read a specific MCP resource
1048    ///
1049    /// # Errors
1050    /// Returns an error if resource reading fails or resource is not found
1051    pub async fn read_resource(
1052        &self,
1053        request: ReadResourceRequest,
1054    ) -> McpResult<ReadResourceResult> {
1055        self.handle_resource_read(request).await
1056    }
1057
1058    /// Read a specific MCP resource with fallback error handling
1059    ///
1060    /// This method provides backward compatibility by converting `McpError` to `ReadResourceResult`
1061    /// for cases where the caller expects a `ReadResourceResult` even on error
1062    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    /// List available MCP prompts
1073    ///
1074    /// # Errors
1075    /// Returns an error if prompt generation fails
1076    pub fn list_prompts(&self) -> McpResult<ListPromptsResult> {
1077        Ok(ListPromptsResult {
1078            prompts: Self::get_available_prompts(),
1079        })
1080    }
1081
1082    /// Get a specific MCP prompt with arguments
1083    ///
1084    /// # Errors
1085    /// Returns an error if prompt retrieval fails or prompt is not found
1086    pub async fn get_prompt(&self, request: GetPromptRequest) -> McpResult<GetPromptResult> {
1087        self.handle_prompt_request(request).await
1088    }
1089
1090    /// Get a specific MCP prompt with fallback error handling
1091    ///
1092    /// This method provides backward compatibility by converting `McpError` to `GetPromptResult`
1093    /// for cases where the caller expects a `GetPromptResult` even on error
1094    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    /// Get available MCP tools
1102    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            // Tag Discovery Tools
1880            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            // Tag CRUD Operations
1944            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            // Tag Assignment Tools
2039            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            // Tag Analytics
2098            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                    // "partial_input" is accepted as a hidden backward-compat alias
2142                    // but is not advertised here. Use "prefix" for all new callers.
2143                    "required": ["prefix"]
2144                }),
2145            },
2146        ]
2147    }
2148
2149    /// Handle tool call
2150    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            // Tag discovery tools
2188            "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            // Tag CRUD
2193            "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            // Tag assignment
2198            "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            // Tag analytics
2202            "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    // ============================================================================
2214    // Bulk Operation Handlers
2215    // ============================================================================
2216
2217    // ========================================================================
2218    // TAG TOOL HANDLERS
2219    // ========================================================================
2220
2221    /// Get available MCP prompts
2222    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    /// Get available MCP resources
2356    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    /// Handle a JSON-RPC request and return a JSON-RPC response
2386    ///
2387    /// Returns `None` for notifications (messages without `id` field) - these don't require a response
2388    ///
2389    /// # Errors
2390    /// Returns an error if request parsing or handling fails
2391    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        // Check if this is a notification (no `id` field present)
2403        // In JSON-RPC, notifications don't have an `id` field, so get("id") returns None
2404        let is_notification = request.get("id").is_none();
2405
2406        // Handle notifications silently (they don't require a response)
2407        if is_notification {
2408            match method {
2409                "notifications/initialized" => {
2410                    // Silently acknowledge the initialized notification
2411                    return Ok(None);
2412                }
2413                _ => {
2414                    // Unknown notification - silently ignore
2415                    return Ok(None);
2416                }
2417            }
2418        }
2419
2420        // For requests (with `id` field), we need the id for the response
2421        let id = request["id"].clone();
2422
2423        let result = match method {
2424            "initialize" => {
2425                // Negotiate protocol version: respond with the highest version we support
2426                // that is <= the client's requested version. Claude Code 2.1+ uses
2427                // 2025-03-26 or newer; responding with 2024-11-05 causes it to silently
2428                // drop the server's tools from its deferred-tool catalog.
2429                let client_version = params
2430                    .get("protocolVersion")
2431                    .and_then(|v| v.as_str())
2432                    .unwrap_or("2024-11-05");
2433                // Supported versions (oldest → newest). When adding support for
2434                // a new spec version, add a branch here and update the
2435                // accepted_response_versions list in test_initialize_handshake_2025_11_25.
2436                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                // Use the fallback variant so tool-level failures (e.g. an
2477                // AppleScript backend error) come back as a structured
2478                // `{"isError": true, "content": [...]}` envelope inside the
2479                // JSON-RPC `result`, rather than propagating up as an `Err`
2480                // and dropping the MCP connection (#148).
2481                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                // Spec: result must be ListResourcesResult `{"resources":[...]}`, not a bare array.
2490                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                // Same envelope pattern as `tools/call` above (#148).
2502                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                // Spec: result must be ListPromptsResult `{"prompts":[...]}`, not a bare array.
2511                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                // Same envelope pattern as `tools/call` above (#148).
2530                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    /// Build a server with a fresh temp DB and the given `unsafe_direct_db` flag,
2578    /// routing through `ThingsMcpServer::new` so platform-aware backend selection runs.
2579    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}