Skip to main content

things3_cli/mcp/
mod.rs

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