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