From e77ad309654c486a879083c1535457c7c103c6c2 Mon Sep 17 00:00:00 2001 From: sanggggg Date: Wed, 17 Dec 2025 17:57:50 +0900 Subject: [PATCH] feat: add MCP server for AI assistant integration Add retrochat-mcp crate that exposes chat session query and analytics capabilities via Model Context Protocol (MCP). This enables AI assistants like Claude and Cursor to query and analyze chat history. Features: - Read-only MCP server with stdio transport - 4 tools: list_sessions, get_session_detail, search_messages, get_session_analytics - Comprehensive error handling and validation (UUID, dates) - Pretty-printed JSON responses for AI consumption - Unit tests for all components (9 tests) - Documentation in CLAUDE.md with configuration examples Technical details: - Uses rmcp 0.11 with server, macros, and transport-io features - Shares database with CLI/TUI (read-only access) - Logs to stderr to avoid interfering with stdio transport - Supports RUST_LOG for log level control Usage: cargo mcp # Run MCP server cargo tmcp # Run tests --- .cargo/config.toml | 2 + CLAUDE.md | 108 ++++++- Cargo.lock | 89 +++++- Cargo.toml | 5 + crates/retrochat-mcp/Cargo.toml | 33 +++ crates/retrochat-mcp/src/error.rs | 71 +++++ crates/retrochat-mcp/src/lib.rs | 10 + crates/retrochat-mcp/src/main.rs | 57 ++++ crates/retrochat-mcp/src/server.rs | 444 +++++++++++++++++++++++++++++ 9 files changed, 813 insertions(+), 6 deletions(-) create mode 100644 crates/retrochat-mcp/Cargo.toml create mode 100644 crates/retrochat-mcp/src/error.rs create mode 100644 crates/retrochat-mcp/src/lib.rs create mode 100644 crates/retrochat-mcp/src/main.rs create mode 100644 crates/retrochat-mcp/src/server.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 1dbb5e0..314ed46 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -5,6 +5,7 @@ tc = "test -p retrochat-core --verbose" tt = "test -p retrochat-tui --verbose" tcli = "test -p retrochat-cli --verbose" tgui = "test -p retrochat-gui --verbose" +tmcp = "test -p retrochat-mcp --verbose" # Build aliases c = "check --workspace --verbose" @@ -21,6 +22,7 @@ core = "run -p retrochat-core" cli = "run -p retrochat-cli" tui = "run -p retrochat-cli" # TUI is embedded in CLI gui = "run -p retrochat-gui" +mcp = "run -p retrochat-mcp" # Clean commands clean-all = "clean --workspace" diff --git a/CLAUDE.md b/CLAUDE.md index 85fbee2..32376bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ ## Project Structure -The project uses a Cargo workspace with 4 separate packages: +The project uses a Cargo workspace with 5 separate packages: ``` crates/ @@ -37,10 +37,17 @@ crates/ │ │ └── commands/ # CLI command handlers │ └── tests/contract/ # CLI contract tests │ -└── retrochat-gui/ # Tauri desktop application - ├── src/ # Tauri Rust backend - ├── icons/ # App icons - └── tauri.conf.json # Tauri configuration +├── retrochat-gui/ # Tauri desktop application +│ ├── src/ # Tauri Rust backend +│ ├── icons/ # App icons +│ └── tauri.conf.json # Tauri configuration +│ +└── retrochat-mcp/ # MCP server + ├── src/ + │ ├── main.rs # MCP server entry point + │ ├── server.rs # Server handler implementation + │ └── tools/ # MCP tool implementations + └── tests/ # Unit and integration tests ui-react/ # React frontend for Tauri desktop app ├── src/ # React components and application code @@ -74,6 +81,7 @@ cargo clippy-strict # Run clippy with -D warnings (clippy --workspace -- -D war cargo cli # Run CLI (run -p retrochat-cli) cargo tui # Launch TUI (run -p retrochat-cli, same as cli) cargo gui # Run GUI (run -p retrochat-gui) +cargo mcp # Run MCP server (run -p retrochat-mcp) ``` #### Shell Scripts (in ./scripts) @@ -240,4 +248,94 @@ Rust: Follow standard rustfmt conventions, use constitutional TDD approach - **Shared Types**: Keep TypeScript types in sync with Rust types when communicating between frontend and backend - **Error Handling**: Handle Tauri command errors gracefully in the UI +### MCP Server (retrochat-mcp/) + +The project includes a Model Context Protocol (MCP) server that exposes RetroChat's query and analytics capabilities to AI assistants like Claude, Cursor, and others. + +#### What is the MCP Server? +The MCP server provides a read-only interface for AI assistants to: +- Query and filter chat sessions +- Search messages across all sessions +- Retrieve detailed session information including messages +- Access analytics data for sessions + +#### Running the MCP Server +```bash +# Using cargo alias +cargo mcp + +# Or directly +cargo run -p retrochat-mcp + +# With logging (logs go to stderr, won't interfere with stdio transport) +RUST_LOG=debug cargo mcp +``` + +#### Available Tools +The server exposes 4 MCP tools: + +1. **list_sessions**: Query and filter chat sessions + - Supports filtering by provider, project, date range, message count + - Pagination support + - Sortable by various fields + +2. **get_session_detail**: Get full session details including all messages + - Requires session UUID + - Returns complete message history + +3. **search_messages**: Full-text search across all messages + - Supports filtering by providers, projects, date range + - Pagination support + - Returns message snippets with context + +4. **get_session_analytics**: Get analytics for a specific session + - Requires session UUID + - Returns completed analytics or pending status + +#### AI Assistant Configuration + +**For Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`): +```json +{ + "mcpServers": { + "retrochat": { + "command": "/absolute/path/to/retrochat-mcp", + "args": [], + "env": { + "RUST_LOG": "info" + } + } + } +} +``` + +**For Cursor** (`.cursor/mcp.json` in project): +```json +{ + "mcpServers": { + "retrochat": { + "command": "cargo", + "args": ["run", "-p", "retrochat-mcp"], + "cwd": "/absolute/path/to/retrochat" + } + } +} +``` + +#### Development Notes +- The MCP server is read-only (no write operations) +- Uses stdio transport for communication +- Logs to stderr to avoid interfering with MCP protocol +- Shares the same database as CLI/TUI (uses default database path) +- All responses are pretty-printed JSON for easy AI consumption + +#### Testing +```bash +# Run MCP server tests +cargo tmcp + +# Or full test command +cargo test -p retrochat-mcp --verbose +``` + diff --git a/Cargo.lock b/Cargo.lock index 90217c0..3e3ab5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2819,6 +2819,15 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matches" version = "0.1.10" @@ -3588,6 +3597,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + [[package]] name = "pathdiff" version = "0.2.3" @@ -4467,6 +4482,24 @@ dependencies = [ "uuid", ] +[[package]] +name = "retrochat-mcp" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "retrochat-core", + "rmcp", + "schemars 1.1.0", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "retrochat-tui" version = "0.1.0" @@ -4554,6 +4587,41 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rmcp" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df440eaa43f8573491ed4a5899719b6d29099500774abba12214a095a4083ed" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars 1.1.0", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef03779cccab8337dd8617c53fce5c98ec21794febc397531555472ca28f8c3" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.106", +] + [[package]] name = "rsa" version = "0.9.8" @@ -4746,7 +4814,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "indexmap 1.9.3", - "schemars_derive", + "schemars_derive 0.8.22", "serde", "serde_json", "url", @@ -4771,10 +4839,13 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive 1.1.0", "serde", "serde_json", + "uuid", ] [[package]] @@ -4789,6 +4860,18 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "schemars_derive" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301858a4023d78debd2353c7426dc486001bddc91ae31a76fb1f55132f7e2633" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.106", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -6501,10 +6584,14 @@ version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] diff --git a/Cargo.toml b/Cargo.toml index d9c7844..440e381 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/retrochat-tui", "crates/retrochat-cli", "crates/retrochat-gui", + "crates/retrochat-mcp", ] resolver = "2" @@ -64,6 +65,10 @@ log = "0.4.28" atty = "0.2" rusqlite = { version = "0.30", features = ["bundled", "backup"] } +# MCP +rmcp = { version = "0.11", features = ["server", "macros", "transport-io"] } +schemars = { version = "1.0", features = ["chrono04", "uuid1"] } + [workspace.package] version = "0.1.0" edition = "2021" diff --git a/crates/retrochat-mcp/Cargo.toml b/crates/retrochat-mcp/Cargo.toml new file mode 100644 index 0000000..04e33c1 --- /dev/null +++ b/crates/retrochat-mcp/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "retrochat-mcp" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +description = "MCP server for RetroChat - exposes chat session query and analytics via Model Context Protocol" + +[[bin]] +name = "retrochat-mcp" +path = "src/main.rs" + +[dependencies] +retrochat-core = { path = "../retrochat-core" } + +# MCP +rmcp = { workspace = true } +schemars = { workspace = true } + +# Async & Core +tokio = { workspace = true } +anyhow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } + +# Logging +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } + +[dev-dependencies] +tempfile = "3.8" diff --git a/crates/retrochat-mcp/src/error.rs b/crates/retrochat-mcp/src/error.rs new file mode 100644 index 0000000..f899d1a --- /dev/null +++ b/crates/retrochat-mcp/src/error.rs @@ -0,0 +1,71 @@ +//! Error handling for MCP server +//! +//! Provides conversion utilities from retrochat errors to MCP protocol errors. + +use rmcp::ErrorData as McpError; + +/// Convert an anyhow error to an MCP internal error +pub fn to_mcp_error(err: anyhow::Error) -> McpError { + McpError::internal_error(err.to_string(), None) +} + +/// Create an MCP invalid params error +pub fn validation_error(msg: &str) -> McpError { + McpError::invalid_params(msg.to_string(), None) +} + +/// Create an MCP not found error +pub fn not_found_error(msg: &str) -> McpError { + McpError::internal_error(format!("Not found: {}", msg), None) +} + +#[cfg(test)] +mod tests { + use super::*; + use rmcp::model::ErrorCode; + + #[test] + fn test_anyhow_error_to_mcp_error() { + let err = anyhow::anyhow!("Database connection failed"); + let mcp_err = to_mcp_error(err); + + assert_eq!(mcp_err.code, ErrorCode::INTERNAL_ERROR); + assert!(mcp_err.message.contains("Database connection failed")); + } + + #[test] + fn test_validation_error() { + let err = validation_error("Invalid UUID format"); + + assert_eq!(err.code, ErrorCode::INVALID_PARAMS); + assert_eq!(err.message, "Invalid UUID format"); + } + + #[test] + fn test_not_found_error() { + let err = not_found_error("Session with ID abc123"); + + assert_eq!(err.code, ErrorCode::INTERNAL_ERROR); + assert!(err.message.contains("Not found")); + assert!(err.message.contains("Session with ID abc123")); + } + + #[test] + fn test_nested_error_conversion() { + // Test with a nested error chain + let inner_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); + let outer_err = anyhow::anyhow!(inner_err).context("Failed to read configuration"); + let mcp_err = to_mcp_error(outer_err); + + assert_eq!(mcp_err.code, ErrorCode::INTERNAL_ERROR); + assert!(mcp_err.message.contains("Failed to read configuration")); + } + + #[test] + fn test_error_message_formatting() { + let err = validation_error("Expected format: YYYY-MM-DD, got: invalid-date"); + + assert!(err.message.contains("YYYY-MM-DD")); + assert!(err.message.contains("invalid-date")); + } +} diff --git a/crates/retrochat-mcp/src/lib.rs b/crates/retrochat-mcp/src/lib.rs new file mode 100644 index 0000000..6b27a88 --- /dev/null +++ b/crates/retrochat-mcp/src/lib.rs @@ -0,0 +1,10 @@ +//! RetroChat MCP Server +//! +//! A Model Context Protocol server that exposes RetroChat's chat session +//! query and analytics capabilities to AI assistants. + +pub mod error; +pub mod server; + +// Re-exports for convenience +pub use server::*; diff --git a/crates/retrochat-mcp/src/main.rs b/crates/retrochat-mcp/src/main.rs new file mode 100644 index 0000000..5b28871 --- /dev/null +++ b/crates/retrochat-mcp/src/main.rs @@ -0,0 +1,57 @@ +//! RetroChat MCP Server +//! +//! A Model Context Protocol server that exposes RetroChat's chat session +//! query and analytics capabilities to AI assistants. + +use retrochat_mcp::RetroChatMcpServer; +use rmcp::transport::stdio; +use rmcp::ServiceExt; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize logging to stderr (won't interfere with stdio transport) + // Use RUST_LOG environment variable to control log level + tracing_subscriber::registry() + .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) + .with( + tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) // Log to stderr, not stdout + .with_target(false) + .with_thread_ids(false) + .with_file(false) + .with_line_number(false), + ) + .init(); + + tracing::info!( + "Starting RetroChat MCP Server v{}", + env!("CARGO_PKG_VERSION") + ); + + // Create the server + let server = RetroChatMcpServer::new().await.map_err(|e| { + tracing::error!("Failed to initialize server: {}", e); + e + })?; + + tracing::info!("Server initialized successfully"); + + // Start serving with stdio transport + let service = server.serve(stdio()).await.map_err(|e| { + tracing::error!("Failed to start server: {}", e); + e + })?; + + tracing::info!("MCP server running on stdio transport"); + + // Wait for the service to complete + service.waiting().await.map_err(|e| { + tracing::error!("Server error: {}", e); + e + })?; + + tracing::info!("Server shutting down gracefully"); + + Ok(()) +} diff --git a/crates/retrochat-mcp/src/server.rs b/crates/retrochat-mcp/src/server.rs new file mode 100644 index 0000000..4259b3d --- /dev/null +++ b/crates/retrochat-mcp/src/server.rs @@ -0,0 +1,444 @@ +//! MCP Server implementation for RetroChat + +use crate::error::{not_found_error, to_mcp_error, validation_error}; +use retrochat_core::database::DatabaseManager; +use retrochat_core::services::{ + DateRange, QueryService, SearchRequest, SessionDetailRequest, SessionFilters, + SessionsQueryRequest, +}; +use rmcp::handler::server::{router::tool::ToolRouter, wrapper::Parameters}; +use rmcp::model::{CallToolResult, Content, ServerCapabilities, ServerInfo}; +use rmcp::{tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use uuid::Uuid; + +/// RetroChat MCP Server +/// +/// Provides read-only access to chat session data and analytics +/// through the Model Context Protocol. +#[derive(Clone)] +pub struct RetroChatMcpServer { + pub(crate) db_manager: Arc, + pub(crate) tool_router: ToolRouter, +} + +impl RetroChatMcpServer { + /// Get the query service (creates fresh instance) + pub(crate) fn query_service(&self) -> QueryService { + QueryService::with_database(self.db_manager.clone()) + } + + /// Create a new MCP server with default database + pub async fn new() -> anyhow::Result { + let db_path = retrochat_core::database::config::get_default_db_path()?; + let db_manager = Arc::new(DatabaseManager::new(&db_path).await?); + + Ok(Self { + db_manager, + tool_router: Self::tool_router(), + }) + } + + /// Create a new MCP server with a specific database (for testing) + pub async fn with_database(db_manager: Arc) -> Self { + Self { + db_manager, + tool_router: Self::tool_router(), + } + } +} + +// Implement the ServerHandler trait +#[tool_handler(router = self.tool_router)] +impl ServerHandler for RetroChatMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + server_info: rmcp::model::Implementation { + name: "retrochat-mcp".into(), + version: env!("CARGO_PKG_VERSION").into(), + title: Some("RetroChat MCP Server".into()), + website_url: None, + icons: None, + }, + instructions: Some( + "RetroChat MCP Server - Query and analyze your AI chat history. \ + Use list_sessions to browse sessions, get_session_detail for full session info, \ + search_messages for full-text search, and get_session_analytics for analytics data." + .into(), + ), + capabilities: ServerCapabilities::builder().enable_tools().build(), + ..Default::default() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_server_initialization() { + let db_manager = Arc::new(DatabaseManager::open_in_memory().await.unwrap()); + let _server = RetroChatMcpServer::with_database(db_manager.clone()).await; + + // Verify shared database manager + assert!(Arc::strong_count(&db_manager) >= 2); // server + our reference + } + + #[tokio::test] + async fn test_server_info() { + let db_manager = Arc::new(DatabaseManager::open_in_memory().await.unwrap()); + let server = RetroChatMcpServer::with_database(db_manager).await; + let info = server.get_info(); + + assert_eq!(info.server_info.name, "retrochat-mcp"); + assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION")); + assert!(info.instructions.is_some()); + assert!(info.instructions.unwrap().contains("RetroChat MCP Server")); + assert!(info.capabilities.tools.is_some()); + } + + #[tokio::test] + async fn test_server_clone() { + let db_manager = Arc::new(DatabaseManager::open_in_memory().await.unwrap()); + let server = RetroChatMcpServer::with_database(db_manager.clone()).await; + let cloned = server.clone(); + + // Both should share the same database manager + assert!(Arc::ptr_eq(&server.db_manager, &cloned.db_manager)); + } + + #[tokio::test] + async fn test_server_capabilities() { + let db_manager = Arc::new(DatabaseManager::open_in_memory().await.unwrap()); + let server = RetroChatMcpServer::with_database(db_manager).await; + let info = server.get_info(); + + assert!(info.capabilities.tools.is_some()); + // Read-only server - no prompts, resources, or sampling + assert!(info.capabilities.prompts.is_none()); + assert!(info.capabilities.resources.is_none()); + } +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ListSessionsParams { + /// Filter by provider (e.g., "Claude Code", "Gemini CLI") + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + + /// Filter by project name + #[serde(skip_serializing_if = "Option::is_none")] + pub project: Option, + + /// Filter sessions from this date (ISO 8601 format) + #[serde(skip_serializing_if = "Option::is_none")] + pub start_date: Option, + + /// Filter sessions until this date (ISO 8601 format) + #[serde(skip_serializing_if = "Option::is_none")] + pub end_date: Option, + + /// Minimum message count + #[serde(skip_serializing_if = "Option::is_none")] + pub min_messages: Option, + + /// Maximum message count + #[serde(skip_serializing_if = "Option::is_none")] + pub max_messages: Option, + + /// Page number (default: 1) + #[serde(skip_serializing_if = "Option::is_none")] + pub page: Option, + + /// Items per page (default: 20) + #[serde(skip_serializing_if = "Option::is_none")] + pub page_size: Option, + + /// Sort field (default: "start_time") + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_by: Option, + + /// Sort order: "asc" or "desc" (default: "desc") + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_order: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct GetSessionDetailParams { + /// Session ID (UUID format) + pub session_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SearchMessagesParams { + /// Search query string + pub query: String, + + /// Filter by providers (e.g., ["Claude Code", "Gemini CLI"]) + #[serde(skip_serializing_if = "Option::is_none")] + pub providers: Option>, + + /// Filter by projects + #[serde(skip_serializing_if = "Option::is_none")] + pub projects: Option>, + + /// Search from this date (ISO 8601 format) + #[serde(skip_serializing_if = "Option::is_none")] + pub start_date: Option, + + /// Search until this date (ISO 8601 format) + #[serde(skip_serializing_if = "Option::is_none")] + pub end_date: Option, + + /// Page number (default: 1) + #[serde(skip_serializing_if = "Option::is_none")] + pub page: Option, + + /// Items per page (default: 20) + #[serde(skip_serializing_if = "Option::is_none")] + pub page_size: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct GetSessionAnalyticsParams { + /// Session ID (UUID format) + pub session_id: String, +} + +// ============================================================================ +// Tool Implementations +// ============================================================================ + +#[tool_router(router = tool_router)] +impl RetroChatMcpServer { + /// List chat sessions with optional filtering and pagination + #[tool( + description = "List chat sessions with optional filtering by provider, project, date range, message count, and pagination support" + )] + pub async fn list_sessions( + &self, + params: Parameters, + ) -> Result { + let params = params.0; + + // Validate dates + if let Some(ref start) = params.start_date { + chrono::DateTime::parse_from_rfc3339(start) + .map_err(|_| validation_error(&format!("Invalid start_date format: {}", start)))?; + } + if let Some(ref end) = params.end_date { + chrono::DateTime::parse_from_rfc3339(end) + .map_err(|_| validation_error(&format!("Invalid end_date format: {}", end)))?; + } + if let Some(ref order) = params.sort_order { + if order != "asc" && order != "desc" { + return Err(validation_error(&format!( + "Invalid sort_order: {}. Must be 'asc' or 'desc'", + order + ))); + } + } + + // Build request + let date_range = match (¶ms.start_date, ¶ms.end_date) { + (Some(start), Some(end)) => Some(DateRange { + start_date: start.clone(), + end_date: end.clone(), + }), + _ => None, + }; + + let filters = if params.provider.is_some() + || params.project.is_some() + || date_range.is_some() + || params.min_messages.is_some() + || params.max_messages.is_some() + { + Some(SessionFilters { + provider: params.provider, + project: params.project, + date_range, + min_messages: params.min_messages, + max_messages: params.max_messages, + }) + } else { + None + }; + + let request = SessionsQueryRequest { + page: params.page, + page_size: params.page_size, + sort_by: params.sort_by, + sort_order: params.sort_order, + filters, + }; + + // Query sessions + let response = self + .query_service() + .query_sessions(request) + .await + .map_err(to_mcp_error)?; + + // Return pretty-printed JSON + let json = serde_json::to_string_pretty(&response) + .map_err(|e| McpError::internal_error(e.to_string(), None))?; + + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + /// Get detailed information about a specific chat session including all messages + #[tool( + description = "Get detailed information about a specific chat session including all messages, tool usage, and metadata" + )] + pub async fn get_session_detail( + &self, + params: Parameters, + ) -> Result { + let params = params.0; + + // Validate UUID format + Uuid::parse_str(¶ms.session_id).map_err(|_| { + validation_error(&format!( + "Invalid session_id format: {}. Must be a valid UUID", + params.session_id + )) + })?; + + // Create request + let request = SessionDetailRequest { + session_id: params.session_id.clone(), + include_content: Some(true), + message_limit: None, + message_offset: None, + }; + + // Get session detail + let response = self + .query_service() + .get_session_detail(request) + .await + .map_err(|e| { + let err_msg = e.to_string(); + if err_msg.contains("not found") || err_msg.contains("Session not found") { + not_found_error(¶ms.session_id) + } else { + to_mcp_error(e) + } + })?; + + // Return pretty-printed JSON + let json = serde_json::to_string_pretty(&response) + .map_err(|e| McpError::internal_error(e.to_string(), None))?; + + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + /// Full-text search across all messages in chat sessions + #[tool( + description = "Search for messages across all chat sessions using full-text search. Supports filtering by providers, projects, and date ranges" + )] + pub async fn search_messages( + &self, + params: Parameters, + ) -> Result { + let params = params.0; + + // Validate query + if params.query.trim().is_empty() { + return Err(validation_error("Search query cannot be empty")); + } + + // Validate dates + if let Some(ref start) = params.start_date { + chrono::DateTime::parse_from_rfc3339(start) + .map_err(|_| validation_error(&format!("Invalid start_date format: {}", start)))?; + } + if let Some(ref end) = params.end_date { + chrono::DateTime::parse_from_rfc3339(end) + .map_err(|_| validation_error(&format!("Invalid end_date format: {}", end)))?; + } + + // Build request + let date_range = match (¶ms.start_date, ¶ms.end_date) { + (Some(start), Some(end)) => Some(DateRange { + start_date: start.clone(), + end_date: end.clone(), + }), + _ => None, + }; + + let request = SearchRequest { + query: params.query, + providers: params.providers, + projects: params.projects, + date_range, + search_type: None, + page: params.page, + page_size: params.page_size, + }; + + // Search messages + let response = self + .query_service() + .search_messages(request) + .await + .map_err(to_mcp_error)?; + + // Return pretty-printed JSON + let json = serde_json::to_string_pretty(&response) + .map_err(|e| McpError::internal_error(e.to_string(), None))?; + + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + /// Get analytics information for a specific chat session + #[tool( + description = "Get analytics information for a specific chat session, including completed analytics results and any pending/running analysis requests" + )] + pub async fn get_session_analytics( + &self, + params: Parameters, + ) -> Result { + let params = params.0; + + // Validate UUID format + Uuid::parse_str(¶ms.session_id).map_err(|_| { + validation_error(&format!( + "Invalid session_id format: {}. Must be a valid UUID", + params.session_id + )) + })?; + + // Get session analytics + let response = self + .query_service() + .get_session_analytics(¶ms.session_id) + .await + .map_err(|e| { + let err_msg = e.to_string(); + if err_msg.contains("not found") || err_msg.contains("Session not found") { + not_found_error(¶ms.session_id) + } else { + to_mcp_error(e) + } + })?; + + // Manually construct JSON since SessionAnalytics doesn't implement Serialize + let json = if let Some(analytics) = response { + let value = serde_json::json!({ + "latest_analytics": analytics.latest_analytics, + "latest_request": analytics.latest_request, + "active_request": analytics.active_request, + }); + serde_json::to_string_pretty(&value) + .map_err(|e| McpError::internal_error(e.to_string(), None))? + } else { + "null".to_string() + }; + + Ok(CallToolResult::success(vec![Content::text(json)])) + } +}