lore_cli/mcp/
server.rs

1//! MCP server implementation for Lore.
2//!
3//! Runs an MCP server on stdio transport, exposing Lore tools to
4//! AI coding assistants like Claude Code.
5
6use anyhow::Result;
7use rmcp::{
8    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
9    model::{
10        CallToolResult, Content, ErrorCode, ErrorData as McpError, Implementation, ProtocolVersion,
11        ServerCapabilities, ServerInfo,
12    },
13    tool, tool_handler, tool_router,
14    transport::stdio,
15    ServerHandler, ServiceExt,
16};
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use std::borrow::Cow;
20
21use crate::storage::models::{Message, SearchOptions, Session};
22use crate::storage::Database;
23
24// ============== Tool Parameter Types ==============
25
26/// Parameters for the lore_search tool.
27#[derive(Debug, Deserialize, JsonSchema)]
28pub struct SearchParams {
29    /// The search query text.
30    #[schemars(description = "Text to search for in session messages")]
31    pub query: String,
32
33    /// Maximum number of results to return.
34    #[schemars(description = "Maximum number of results (default: 10)")]
35    pub limit: Option<usize>,
36
37    /// Filter by repository path prefix.
38    #[schemars(description = "Filter by repository path prefix")]
39    pub repo: Option<String>,
40
41    /// Filter by AI tool name (e.g., claude-code, aider).
42    #[schemars(description = "Filter by AI tool name (e.g., claude-code, aider)")]
43    pub tool: Option<String>,
44
45    /// Filter to sessions after this date (ISO 8601 or relative like 7d, 2w, 1m).
46    #[schemars(description = "Filter to sessions after this date (ISO 8601 or 7d, 2w, 1m)")]
47    pub since: Option<String>,
48}
49
50/// Parameters for the lore_get_session tool.
51#[derive(Debug, Deserialize, JsonSchema)]
52pub struct GetSessionParams {
53    /// Session ID (full UUID or prefix).
54    #[schemars(description = "Session ID (full UUID or short prefix like abc123)")]
55    pub session_id: String,
56
57    /// Whether to include full message content.
58    #[schemars(description = "Include full message content (default: true)")]
59    pub include_messages: Option<bool>,
60}
61
62/// Parameters for the lore_list_sessions tool.
63#[derive(Debug, Deserialize, JsonSchema)]
64pub struct ListSessionsParams {
65    /// Maximum number of sessions to return.
66    #[schemars(description = "Maximum number of sessions (default: 10)")]
67    pub limit: Option<usize>,
68
69    /// Filter by repository path prefix.
70    #[schemars(description = "Filter by repository path prefix")]
71    pub repo: Option<String>,
72}
73
74/// Parameters for the lore_get_context tool.
75#[derive(Debug, Deserialize, JsonSchema)]
76pub struct GetContextParams {
77    /// Repository path to get context for.
78    #[schemars(description = "Repository path (defaults to current directory)")]
79    pub repo: Option<String>,
80
81    /// Whether to show detailed info for the most recent session only.
82    #[schemars(description = "Show detailed info for the most recent session only")]
83    pub last: Option<bool>,
84}
85
86/// Parameters for the lore_get_linked_sessions tool.
87#[derive(Debug, Deserialize, JsonSchema)]
88pub struct GetLinkedSessionsParams {
89    /// Git commit SHA (full or prefix).
90    #[schemars(description = "Git commit SHA (full or short prefix)")]
91    pub commit_sha: String,
92}
93
94// ============== Result Types ==============
95
96/// A session in search results.
97#[derive(Debug, Serialize)]
98pub struct SessionInfo {
99    pub id: String,
100    pub id_short: String,
101    pub tool: String,
102    pub started_at: String,
103    pub message_count: i32,
104    pub working_directory: String,
105    pub git_branch: Option<String>,
106}
107
108/// A search match result.
109#[derive(Debug, Serialize)]
110pub struct SearchMatch {
111    pub session: SessionInfo,
112    pub message_id: String,
113    pub role: String,
114    pub snippet: String,
115    pub timestamp: String,
116}
117
118/// Search results response.
119#[derive(Debug, Serialize)]
120pub struct SearchResponse {
121    pub query: String,
122    pub total_matches: usize,
123    pub matches: Vec<SearchMatch>,
124}
125
126/// A message for session transcript.
127#[derive(Debug, Serialize)]
128pub struct MessageInfo {
129    pub index: i32,
130    pub role: String,
131    pub content: String,
132    pub timestamp: String,
133}
134
135/// Full session details response.
136#[derive(Debug, Serialize)]
137pub struct SessionDetailsResponse {
138    pub session: SessionInfo,
139    pub linked_commits: Vec<String>,
140    pub messages: Option<Vec<MessageInfo>>,
141    pub summary: Option<String>,
142    pub tags: Vec<String>,
143}
144
145/// Context response for a repository.
146#[derive(Debug, Serialize)]
147pub struct ContextResponse {
148    pub working_directory: String,
149    pub sessions: Vec<SessionInfo>,
150    pub recent_messages: Option<Vec<MessageInfo>>,
151}
152
153/// Linked sessions response.
154#[derive(Debug, Serialize)]
155pub struct LinkedSessionsResponse {
156    pub commit_sha: String,
157    pub sessions: Vec<SessionInfo>,
158}
159
160// ============== Server Implementation ==============
161
162/// The Lore MCP server.
163///
164/// Provides tools for querying Lore session data via MCP.
165#[derive(Debug, Clone)]
166pub struct LoreServer {
167    tool_router: ToolRouter<LoreServer>,
168}
169
170impl LoreServer {
171    /// Creates a new LoreServer.
172    pub fn new() -> Self {
173        Self {
174            tool_router: Self::tool_router(),
175        }
176    }
177}
178
179impl Default for LoreServer {
180    fn default() -> Self {
181        Self::new()
182    }
183}
184
185/// Creates an McpError from an error message.
186fn mcp_error(message: &str) -> McpError {
187    McpError {
188        code: ErrorCode(-32603),
189        message: Cow::from(message.to_string()),
190        data: None,
191    }
192}
193
194#[tool_router]
195impl LoreServer {
196    /// Search Lore sessions by query text with optional filters.
197    ///
198    /// Searches message content using full-text search. Supports filtering
199    /// by repository, tool, and date range.
200    #[tool(description = "Search Lore session messages for text content")]
201    async fn lore_search(
202        &self,
203        params: Parameters<SearchParams>,
204    ) -> Result<CallToolResult, McpError> {
205        let params = params.0;
206        let result = search_impl(params);
207        match result {
208            Ok(response) => {
209                let json = serde_json::to_string_pretty(&response)
210                    .unwrap_or_else(|e| format!("Error serializing response: {e}"));
211                Ok(CallToolResult::success(vec![Content::text(json)]))
212            }
213            Err(e) => Err(mcp_error(&format!("Search failed: {e}"))),
214        }
215    }
216
217    /// Get full details of a Lore session by ID.
218    ///
219    /// Returns session metadata, linked commits, and optionally the full
220    /// message transcript.
221    #[tool(description = "Get full details of a Lore session by ID")]
222    async fn lore_get_session(
223        &self,
224        params: Parameters<GetSessionParams>,
225    ) -> Result<CallToolResult, McpError> {
226        let params = params.0;
227        let result = get_session_impl(params);
228        match result {
229            Ok(response) => {
230                let json = serde_json::to_string_pretty(&response)
231                    .unwrap_or_else(|e| format!("Error serializing response: {e}"));
232                Ok(CallToolResult::success(vec![Content::text(json)]))
233            }
234            Err(e) => Err(mcp_error(&format!("Get session failed: {e}"))),
235        }
236    }
237
238    /// List recent Lore sessions.
239    ///
240    /// Returns a list of recent sessions, optionally filtered by repository.
241    #[tool(description = "List recent Lore sessions")]
242    async fn lore_list_sessions(
243        &self,
244        params: Parameters<ListSessionsParams>,
245    ) -> Result<CallToolResult, McpError> {
246        let params = params.0;
247        let result = list_sessions_impl(params);
248        match result {
249            Ok(sessions) => {
250                let json = serde_json::to_string_pretty(&sessions)
251                    .unwrap_or_else(|e| format!("Error serializing response: {e}"));
252                Ok(CallToolResult::success(vec![Content::text(json)]))
253            }
254            Err(e) => Err(mcp_error(&format!("List sessions failed: {e}"))),
255        }
256    }
257
258    /// Get recent session context for a repository.
259    ///
260    /// Provides a summary of recent sessions for quick orientation.
261    #[tool(description = "Get recent session context for a repository")]
262    async fn lore_get_context(
263        &self,
264        params: Parameters<GetContextParams>,
265    ) -> Result<CallToolResult, McpError> {
266        let params = params.0;
267        let result = get_context_impl(params);
268        match result {
269            Ok(response) => {
270                let json = serde_json::to_string_pretty(&response)
271                    .unwrap_or_else(|e| format!("Error serializing response: {e}"));
272                Ok(CallToolResult::success(vec![Content::text(json)]))
273            }
274            Err(e) => Err(mcp_error(&format!("Get context failed: {e}"))),
275        }
276    }
277
278    /// Get sessions linked to a git commit.
279    ///
280    /// Returns all sessions that have been linked to the specified commit.
281    #[tool(description = "Get Lore sessions linked to a git commit")]
282    async fn lore_get_linked_sessions(
283        &self,
284        params: Parameters<GetLinkedSessionsParams>,
285    ) -> Result<CallToolResult, McpError> {
286        let params = params.0;
287        let result = get_linked_sessions_impl(params);
288        match result {
289            Ok(response) => {
290                let json = serde_json::to_string_pretty(&response)
291                    .unwrap_or_else(|e| format!("Error serializing response: {e}"));
292                Ok(CallToolResult::success(vec![Content::text(json)]))
293            }
294            Err(e) => Err(mcp_error(&format!("Get linked sessions failed: {e}"))),
295        }
296    }
297}
298
299#[tool_handler]
300impl ServerHandler for LoreServer {
301    fn get_info(&self) -> ServerInfo {
302        ServerInfo {
303            protocol_version: ProtocolVersion::V_2024_11_05,
304            capabilities: ServerCapabilities::builder().enable_tools().build(),
305            server_info: Implementation::from_build_env(),
306            instructions: Some(
307                "Lore is a reasoning history system for code. It captures AI coding sessions \
308                 and links them to git commits. Use these tools to search session history, \
309                 view session transcripts, and find sessions linked to commits."
310                    .to_string(),
311            ),
312        }
313    }
314}
315
316// ============== Implementation Functions ==============
317
318/// Parses a date string (ISO 8601 or relative like 7d, 2w, 1m) into a DateTime.
319fn parse_date(date_str: &str) -> anyhow::Result<chrono::DateTime<chrono::Utc>> {
320    use chrono::{Duration, Utc};
321
322    let date_str = date_str.trim().to_lowercase();
323
324    // Try relative format first (e.g., "7d", "2w", "1m")
325    if date_str.ends_with('d') {
326        let days: i64 = date_str[..date_str.len() - 1].parse()?;
327        return Ok(Utc::now() - Duration::days(days));
328    }
329
330    if date_str.ends_with('w') {
331        let weeks: i64 = date_str[..date_str.len() - 1].parse()?;
332        return Ok(Utc::now() - Duration::weeks(weeks));
333    }
334
335    if date_str.ends_with('m') {
336        let months: i64 = date_str[..date_str.len() - 1].parse()?;
337        return Ok(Utc::now() - Duration::days(months * 30));
338    }
339
340    // Try ISO 8601 format
341    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&date_str) {
342        return Ok(dt.with_timezone(&Utc));
343    }
344
345    // Try date-only format
346    if let Ok(date) = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") {
347        let datetime = date
348            .and_hms_opt(0, 0, 0)
349            .ok_or_else(|| anyhow::anyhow!("Invalid date"))?;
350        return Ok(datetime.and_utc());
351    }
352
353    anyhow::bail!("Invalid date format: {date_str}")
354}
355
356/// Converts a Session to SessionInfo.
357fn session_to_info(session: &Session) -> SessionInfo {
358    SessionInfo {
359        id: session.id.to_string(),
360        id_short: session.id.to_string()[..8].to_string(),
361        tool: session.tool.clone(),
362        started_at: session.started_at.to_rfc3339(),
363        message_count: session.message_count,
364        working_directory: session.working_directory.clone(),
365        git_branch: session.git_branch.clone(),
366    }
367}
368
369/// Converts a Message to MessageInfo.
370fn message_to_info(message: &Message) -> MessageInfo {
371    MessageInfo {
372        index: message.index,
373        role: message.role.to_string(),
374        content: message.content.text(),
375        timestamp: message.timestamp.to_rfc3339(),
376    }
377}
378
379/// Implementation of the search tool.
380fn search_impl(params: SearchParams) -> anyhow::Result<SearchResponse> {
381    let db = Database::open_default()?;
382
383    // Build search index if needed
384    if db.search_index_needs_rebuild()? {
385        db.rebuild_search_index()?;
386    }
387
388    let since = params.since.as_ref().map(|s| parse_date(s)).transpose()?;
389
390    let options = SearchOptions {
391        query: params.query.clone(),
392        limit: params.limit.unwrap_or(10),
393        repo: params.repo,
394        tool: params.tool,
395        since,
396        ..Default::default()
397    };
398
399    let results = db.search_with_options(&options)?;
400
401    let matches: Vec<SearchMatch> = results
402        .into_iter()
403        .map(|r| SearchMatch {
404            session: SessionInfo {
405                id: r.session_id.to_string(),
406                id_short: r.session_id.to_string()[..8].to_string(),
407                tool: r.tool,
408                started_at: r
409                    .session_started_at
410                    .map(|dt| dt.to_rfc3339())
411                    .unwrap_or_default(),
412                message_count: r.session_message_count,
413                working_directory: r.working_directory,
414                git_branch: r.git_branch,
415            },
416            message_id: r.message_id.to_string(),
417            role: r.role.to_string(),
418            snippet: r.snippet,
419            timestamp: r.timestamp.to_rfc3339(),
420        })
421        .collect();
422
423    let total = matches.len();
424
425    Ok(SearchResponse {
426        query: params.query,
427        total_matches: total,
428        matches,
429    })
430}
431
432/// Implementation of the get_session tool.
433fn get_session_impl(params: GetSessionParams) -> anyhow::Result<SessionDetailsResponse> {
434    let db = Database::open_default()?;
435
436    // Try to find session by ID prefix
437    let session_id = resolve_session_id(&db, &params.session_id)?;
438    let session = db
439        .get_session(&session_id)?
440        .ok_or_else(|| anyhow::anyhow!("Session not found: {}", params.session_id))?;
441
442    // Get linked commits
443    let links = db.get_links_by_session(&session_id)?;
444    let linked_commits: Vec<String> = links.iter().filter_map(|l| l.commit_sha.clone()).collect();
445
446    // Get messages if requested
447    let messages = if params.include_messages.unwrap_or(true) {
448        let msgs = db.get_messages(&session_id)?;
449        Some(msgs.iter().map(message_to_info).collect())
450    } else {
451        None
452    };
453
454    // Get summary and tags
455    let summary = db.get_summary(&session_id)?.map(|s| s.content);
456    let tags: Vec<String> = db
457        .get_tags(&session_id)?
458        .into_iter()
459        .map(|t| t.label)
460        .collect();
461
462    Ok(SessionDetailsResponse {
463        session: session_to_info(&session),
464        linked_commits,
465        messages,
466        summary,
467        tags,
468    })
469}
470
471/// Implementation of the list_sessions tool.
472fn list_sessions_impl(params: ListSessionsParams) -> anyhow::Result<Vec<SessionInfo>> {
473    let db = Database::open_default()?;
474
475    let limit = params.limit.unwrap_or(10);
476    let sessions = db.list_sessions(limit, params.repo.as_deref())?;
477
478    Ok(sessions.iter().map(session_to_info).collect())
479}
480
481/// Implementation of the get_context tool.
482fn get_context_impl(params: GetContextParams) -> anyhow::Result<ContextResponse> {
483    let db = Database::open_default()?;
484
485    let working_dir = params.repo.unwrap_or_else(|| {
486        std::env::current_dir()
487            .map(|p| p.to_string_lossy().to_string())
488            .unwrap_or_default()
489    });
490
491    let limit = if params.last.unwrap_or(false) { 1 } else { 5 };
492    let sessions = db.list_sessions(limit, Some(&working_dir))?;
493
494    let session_infos: Vec<SessionInfo> = sessions.iter().map(session_to_info).collect();
495
496    // Get recent messages for --last mode
497    let recent_messages = if params.last.unwrap_or(false) && !sessions.is_empty() {
498        let messages = db.get_messages(&sessions[0].id)?;
499        let start = messages.len().saturating_sub(3);
500        Some(messages[start..].iter().map(message_to_info).collect())
501    } else {
502        None
503    };
504
505    Ok(ContextResponse {
506        working_directory: working_dir,
507        sessions: session_infos,
508        recent_messages,
509    })
510}
511
512/// Implementation of the get_linked_sessions tool.
513fn get_linked_sessions_impl(
514    params: GetLinkedSessionsParams,
515) -> anyhow::Result<LinkedSessionsResponse> {
516    let db = Database::open_default()?;
517
518    let links = db.get_links_by_commit(&params.commit_sha)?;
519
520    let mut sessions = Vec::new();
521    for link in links {
522        if let Some(session) = db.get_session(&link.session_id)? {
523            sessions.push(session_to_info(&session));
524        }
525    }
526
527    Ok(LinkedSessionsResponse {
528        commit_sha: params.commit_sha,
529        sessions,
530    })
531}
532
533/// Resolves a session ID prefix to a full UUID.
534fn resolve_session_id(db: &Database, id_prefix: &str) -> anyhow::Result<uuid::Uuid> {
535    // Use the efficient database method that searches all sessions
536    match db.find_session_by_id_prefix(id_prefix)? {
537        Some(session) => Ok(session.id),
538        None => anyhow::bail!("No session found with ID prefix: {id_prefix}"),
539    }
540}
541
542/// Runs the MCP server on stdio transport.
543///
544/// This is a blocking call that processes MCP requests until the client
545/// disconnects or an error occurs.
546pub async fn run_server() -> Result<()> {
547    let service = LoreServer::new().serve(stdio()).await?;
548    service.waiting().await?;
549    Ok(())
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    #[test]
557    fn test_parse_date_days() {
558        let result = parse_date("7d").expect("Should parse 7d");
559        let expected = chrono::Utc::now() - chrono::Duration::days(7);
560        assert!((result - expected).num_seconds().abs() < 2);
561    }
562
563    #[test]
564    fn test_parse_date_weeks() {
565        let result = parse_date("2w").expect("Should parse 2w");
566        let expected = chrono::Utc::now() - chrono::Duration::weeks(2);
567        assert!((result - expected).num_seconds().abs() < 2);
568    }
569
570    #[test]
571    fn test_parse_date_months() {
572        let result = parse_date("1m").expect("Should parse 1m");
573        let expected = chrono::Utc::now() - chrono::Duration::days(30);
574        assert!((result - expected).num_seconds().abs() < 2);
575    }
576
577    #[test]
578    fn test_parse_date_iso() {
579        let result = parse_date("2024-01-15").expect("Should parse date");
580        assert_eq!(result.format("%Y-%m-%d").to_string(), "2024-01-15");
581    }
582
583    #[test]
584    fn test_parse_date_invalid() {
585        assert!(parse_date("invalid").is_err());
586        assert!(parse_date("abc123").is_err());
587    }
588
589    #[test]
590    fn test_session_to_info() {
591        use chrono::Utc;
592        use uuid::Uuid;
593
594        let session = Session {
595            id: Uuid::new_v4(),
596            tool: "claude-code".to_string(),
597            tool_version: Some("2.0.0".to_string()),
598            started_at: Utc::now(),
599            ended_at: None,
600            model: Some("claude-3-opus".to_string()),
601            working_directory: "/home/user/project".to_string(),
602            git_branch: Some("main".to_string()),
603            source_path: None,
604            message_count: 10,
605            machine_id: None,
606        };
607
608        let info = session_to_info(&session);
609        assert_eq!(info.tool, "claude-code");
610        assert_eq!(info.message_count, 10);
611        assert_eq!(info.working_directory, "/home/user/project");
612        assert_eq!(info.git_branch, Some("main".to_string()));
613        assert_eq!(info.id_short.len(), 8);
614    }
615
616    #[test]
617    fn test_message_to_info() {
618        use crate::storage::models::{MessageContent, MessageRole};
619        use chrono::Utc;
620        use uuid::Uuid;
621
622        let message = Message {
623            id: Uuid::new_v4(),
624            session_id: Uuid::new_v4(),
625            parent_id: None,
626            index: 0,
627            timestamp: Utc::now(),
628            role: MessageRole::User,
629            content: MessageContent::Text("Hello, world!".to_string()),
630            model: None,
631            git_branch: None,
632            cwd: None,
633        };
634
635        let info = message_to_info(&message);
636        assert_eq!(info.index, 0);
637        assert_eq!(info.role, "user");
638        assert_eq!(info.content, "Hello, world!");
639    }
640}