1use anyhow::Result;
7use rmcp::{
8 handler::server::wrapper::Parameters,
9 model::{
10 CallToolResult, Content, ErrorCode, ErrorData as McpError, 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::capture::memory::{resolve_project_path, MemoryMirror, CLAUDE_CODE_TOOL};
22use crate::storage::models::{Memory, Message, SearchOptions, Session};
23use crate::storage::Database;
24
25#[derive(Debug, Deserialize, JsonSchema)]
29pub struct SearchParams {
30 #[schemars(description = "Text to search for in session messages")]
32 pub query: String,
33
34 #[schemars(description = "Maximum number of results (default: 10)")]
36 pub limit: Option<usize>,
37
38 #[schemars(description = "Filter by repository path prefix")]
40 pub repo: Option<String>,
41
42 #[schemars(description = "Filter by AI tool name (e.g., claude-code, aider)")]
44 pub tool: Option<String>,
45
46 #[schemars(description = "Filter to sessions after this date (ISO 8601 or 7d, 2w, 1m)")]
48 pub since: Option<String>,
49}
50
51#[derive(Debug, Deserialize, JsonSchema)]
53pub struct GetSessionParams {
54 #[schemars(description = "Session ID (full UUID or short prefix like abc123)")]
56 pub session_id: String,
57
58 #[schemars(description = "Include full message content (default: true)")]
60 pub include_messages: Option<bool>,
61}
62
63#[derive(Debug, Deserialize, JsonSchema)]
65pub struct ListSessionsParams {
66 #[schemars(description = "Maximum number of sessions (default: 10)")]
68 pub limit: Option<usize>,
69
70 #[schemars(description = "Filter by repository path prefix")]
72 pub repo: Option<String>,
73}
74
75#[derive(Debug, Deserialize, JsonSchema)]
77pub struct GetContextParams {
78 #[schemars(description = "Repository path (defaults to current directory)")]
80 pub repo: Option<String>,
81
82 #[schemars(description = "Show detailed info for the most recent session only")]
84 pub last: Option<bool>,
85}
86
87#[derive(Debug, Deserialize, JsonSchema)]
89pub struct GetLinkedSessionsParams {
90 #[schemars(description = "Git commit SHA (full or short prefix)")]
92 pub commit_sha: String,
93}
94
95#[derive(Debug, Deserialize, JsonSchema)]
97pub struct GetMemoriesParams {
98 #[schemars(
100 description = "Repository path (defaults to the current directory's git top-level)"
101 )]
102 pub project_path: Option<String>,
103}
104
105#[derive(Debug, Deserialize, JsonSchema)]
107pub struct SearchMemoriesParams {
108 #[schemars(description = "Text to search for in the project's mirrored memories")]
110 pub query: String,
111
112 #[schemars(
114 description = "Repository path (defaults to the current directory's git top-level)"
115 )]
116 pub project_path: Option<String>,
117
118 #[schemars(description = "Maximum number of results (default: 20)")]
120 pub limit: Option<usize>,
121}
122
123#[derive(Debug, Serialize)]
127pub struct SessionInfo {
128 pub id: String,
129 pub id_short: String,
130 pub tool: String,
131 pub started_at: String,
132 pub message_count: i32,
133 pub working_directory: String,
134 pub git_branch: Option<String>,
135}
136
137#[derive(Debug, Serialize)]
139pub struct SearchMatch {
140 pub session: SessionInfo,
141 pub message_id: String,
142 pub role: String,
143 pub snippet: String,
144 pub timestamp: String,
145}
146
147#[derive(Debug, Serialize)]
149pub struct SearchResponse {
150 pub query: String,
151 pub total_matches: usize,
152 pub matches: Vec<SearchMatch>,
153}
154
155#[derive(Debug, Serialize)]
157pub struct MessageInfo {
158 pub index: i32,
159 pub role: String,
160 pub content: String,
161 pub timestamp: String,
162}
163
164#[derive(Debug, Serialize)]
166pub struct SessionDetailsResponse {
167 pub session: SessionInfo,
168 pub linked_commits: Vec<String>,
169 pub messages: Option<Vec<MessageInfo>>,
170 pub summary: Option<String>,
171 pub tags: Vec<String>,
172}
173
174#[derive(Debug, Serialize)]
176pub struct ContextResponse {
177 pub working_directory: String,
178 pub sessions: Vec<SessionInfo>,
179 pub recent_messages: Option<Vec<MessageInfo>>,
180}
181
182#[derive(Debug, Serialize)]
184pub struct LinkedSessionsResponse {
185 pub commit_sha: String,
186 pub sessions: Vec<SessionInfo>,
187}
188
189#[derive(Debug, Serialize)]
191pub struct MemoryInfo {
192 pub name: String,
193 pub description: Option<String>,
194 pub memory_type: Option<String>,
195 pub content: String,
196 pub file_path: String,
197 pub updated_at: String,
198}
199
200#[derive(Debug, Serialize)]
202pub struct MemoriesResponse {
203 pub project_path: String,
204 pub source_tool: String,
205 pub total: usize,
206 pub memories: Vec<MemoryInfo>,
207}
208
209#[derive(Debug, Serialize)]
211pub struct SearchMemoriesResponse {
212 pub query: String,
213 pub project_path: String,
214 pub source_tool: String,
215 pub total: usize,
216 pub memories: Vec<MemoryInfo>,
217}
218
219#[derive(Debug, Clone)]
229pub struct LoreServer;
230
231impl LoreServer {
232 pub fn new() -> Self {
234 Self
235 }
236}
237
238impl Default for LoreServer {
239 fn default() -> Self {
240 Self::new()
241 }
242}
243
244fn mcp_error(message: &str) -> McpError {
246 McpError {
247 code: ErrorCode(-32603),
248 message: Cow::from(message.to_string()),
249 data: None,
250 }
251}
252
253#[tool_router]
254impl LoreServer {
255 #[tool(description = "Search Lore session messages for text content")]
260 async fn lore_search(
261 &self,
262 params: Parameters<SearchParams>,
263 ) -> Result<CallToolResult, McpError> {
264 let params = params.0;
265 let result = search_impl(params);
266 match result {
267 Ok(response) => {
268 let json = serde_json::to_string_pretty(&response)
269 .unwrap_or_else(|e| format!("Error serializing response: {e}"));
270 Ok(CallToolResult::success(vec![Content::text(json)]))
271 }
272 Err(e) => Err(mcp_error(&format!("Search failed: {e}"))),
273 }
274 }
275
276 #[tool(description = "Get full details of a Lore session by ID")]
281 async fn lore_get_session(
282 &self,
283 params: Parameters<GetSessionParams>,
284 ) -> Result<CallToolResult, McpError> {
285 let params = params.0;
286 let result = get_session_impl(params);
287 match result {
288 Ok(response) => {
289 let json = serde_json::to_string_pretty(&response)
290 .unwrap_or_else(|e| format!("Error serializing response: {e}"));
291 Ok(CallToolResult::success(vec![Content::text(json)]))
292 }
293 Err(e) => Err(mcp_error(&format!("Get session failed: {e}"))),
294 }
295 }
296
297 #[tool(description = "List recent Lore sessions")]
301 async fn lore_list_sessions(
302 &self,
303 params: Parameters<ListSessionsParams>,
304 ) -> Result<CallToolResult, McpError> {
305 let params = params.0;
306 let result = list_sessions_impl(params);
307 match result {
308 Ok(sessions) => {
309 let json = serde_json::to_string_pretty(&sessions)
310 .unwrap_or_else(|e| format!("Error serializing response: {e}"));
311 Ok(CallToolResult::success(vec![Content::text(json)]))
312 }
313 Err(e) => Err(mcp_error(&format!("List sessions failed: {e}"))),
314 }
315 }
316
317 #[tool(description = "Get recent session context for a repository")]
321 async fn lore_get_context(
322 &self,
323 params: Parameters<GetContextParams>,
324 ) -> Result<CallToolResult, McpError> {
325 let params = params.0;
326 let result = get_context_impl(params);
327 match result {
328 Ok(response) => {
329 let json = serde_json::to_string_pretty(&response)
330 .unwrap_or_else(|e| format!("Error serializing response: {e}"));
331 Ok(CallToolResult::success(vec![Content::text(json)]))
332 }
333 Err(e) => Err(mcp_error(&format!("Get context failed: {e}"))),
334 }
335 }
336
337 #[tool(description = "Get Lore sessions linked to a git commit")]
341 async fn lore_get_linked_sessions(
342 &self,
343 params: Parameters<GetLinkedSessionsParams>,
344 ) -> Result<CallToolResult, McpError> {
345 let params = params.0;
346 let result = get_linked_sessions_impl(params);
347 match result {
348 Ok(response) => {
349 let json = serde_json::to_string_pretty(&response)
350 .unwrap_or_else(|e| format!("Error serializing response: {e}"));
351 Ok(CallToolResult::success(vec![Content::text(json)]))
352 }
353 Err(e) => Err(mcp_error(&format!("Get linked sessions failed: {e}"))),
354 }
355 }
356
357 #[tool(description = "Get a project's memories mirrored from a coding tool's memory store")]
363 async fn lore_get_memories(
364 &self,
365 params: Parameters<GetMemoriesParams>,
366 ) -> Result<CallToolResult, McpError> {
367 let params = params.0;
368 let result = get_memories_impl(params);
369 match result {
370 Ok(response) => {
371 let json = serde_json::to_string_pretty(&response)
372 .unwrap_or_else(|e| format!("Error serializing response: {e}"));
373 Ok(CallToolResult::success(vec![Content::text(json)]))
374 }
375 Err(e) => Err(mcp_error(&format!("Get memories failed: {e}"))),
376 }
377 }
378
379 #[tool(description = "Full-text search a project's mirrored memories")]
384 async fn lore_search_memories(
385 &self,
386 params: Parameters<SearchMemoriesParams>,
387 ) -> Result<CallToolResult, McpError> {
388 let params = params.0;
389 let result = search_memories_impl(params);
390 match result {
391 Ok(response) => {
392 let json = serde_json::to_string_pretty(&response)
393 .unwrap_or_else(|e| format!("Error serializing response: {e}"));
394 Ok(CallToolResult::success(vec![Content::text(json)]))
395 }
396 Err(e) => Err(mcp_error(&format!("Search memories failed: {e}"))),
397 }
398 }
399}
400
401#[tool_handler]
402impl ServerHandler for LoreServer {
403 fn get_info(&self) -> ServerInfo {
404 ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
407 .with_protocol_version(ProtocolVersion::V_2024_11_05)
408 .with_instructions(
409 "Lore is a reasoning history system for code. It captures AI coding sessions \
410 and links them to git commits. Use these tools to search session history, \
411 view session transcripts, and find sessions linked to commits.",
412 )
413 }
414}
415
416fn parse_date(date_str: &str) -> anyhow::Result<chrono::DateTime<chrono::Utc>> {
420 use chrono::{Duration, Utc};
421
422 let date_str = date_str.trim().to_lowercase();
423
424 if date_str.ends_with('d') {
426 let days: i64 = date_str[..date_str.len() - 1].parse()?;
427 return Ok(Utc::now() - Duration::days(days));
428 }
429
430 if date_str.ends_with('w') {
431 let weeks: i64 = date_str[..date_str.len() - 1].parse()?;
432 return Ok(Utc::now() - Duration::weeks(weeks));
433 }
434
435 if date_str.ends_with('m') {
436 let months: i64 = date_str[..date_str.len() - 1].parse()?;
437 return Ok(Utc::now() - Duration::days(months * 30));
438 }
439
440 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&date_str) {
442 return Ok(dt.with_timezone(&Utc));
443 }
444
445 if let Ok(date) = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") {
447 let datetime = date
448 .and_hms_opt(0, 0, 0)
449 .ok_or_else(|| anyhow::anyhow!("Invalid date"))?;
450 return Ok(datetime.and_utc());
451 }
452
453 anyhow::bail!("Invalid date format: {date_str}")
454}
455
456fn session_to_info(session: &Session) -> SessionInfo {
458 SessionInfo {
459 id: session.id.to_string(),
460 id_short: session.id.to_string()[..8].to_string(),
461 tool: session.tool.clone(),
462 started_at: session.started_at.to_rfc3339(),
463 message_count: session.message_count,
464 working_directory: session.working_directory.clone(),
465 git_branch: session.git_branch.clone(),
466 }
467}
468
469fn message_to_info(message: &Message) -> MessageInfo {
471 MessageInfo {
472 index: message.index,
473 role: message.role.to_string(),
474 content: message.content.text(),
475 timestamp: message.timestamp.to_rfc3339(),
476 }
477}
478
479fn search_impl(params: SearchParams) -> anyhow::Result<SearchResponse> {
481 let db = Database::open_default()?;
482
483 if db.search_index_needs_rebuild()? {
485 db.rebuild_search_index()?;
486 }
487
488 let since = params.since.as_ref().map(|s| parse_date(s)).transpose()?;
489
490 let options = SearchOptions {
491 query: params.query.clone(),
492 limit: params.limit.unwrap_or(10),
493 repo: params.repo,
494 tool: params.tool,
495 since,
496 ..Default::default()
497 };
498
499 let results = db.search_with_options(&options)?;
500
501 let matches: Vec<SearchMatch> = results
502 .into_iter()
503 .map(|r| SearchMatch {
504 session: SessionInfo {
505 id: r.session_id.to_string(),
506 id_short: r.session_id.to_string()[..8].to_string(),
507 tool: r.tool,
508 started_at: r
509 .session_started_at
510 .map(|dt| dt.to_rfc3339())
511 .unwrap_or_default(),
512 message_count: r.session_message_count,
513 working_directory: r.working_directory,
514 git_branch: r.git_branch,
515 },
516 message_id: r.message_id.to_string(),
517 role: r.role.to_string(),
518 snippet: r.snippet,
519 timestamp: r.timestamp.to_rfc3339(),
520 })
521 .collect();
522
523 let total = matches.len();
524
525 Ok(SearchResponse {
526 query: params.query,
527 total_matches: total,
528 matches,
529 })
530}
531
532fn get_session_impl(params: GetSessionParams) -> anyhow::Result<SessionDetailsResponse> {
534 let db = Database::open_default()?;
535
536 let session_id = resolve_session_id(&db, ¶ms.session_id)?;
538 let session = db
539 .get_session(&session_id)?
540 .ok_or_else(|| anyhow::anyhow!("Session not found: {}", params.session_id))?;
541
542 let links = db.get_links_by_session(&session_id)?;
544 let linked_commits: Vec<String> = links.iter().filter_map(|l| l.commit_sha.clone()).collect();
545
546 let messages = if params.include_messages.unwrap_or(true) {
548 let msgs = db.get_messages(&session_id)?;
549 Some(msgs.iter().map(message_to_info).collect())
550 } else {
551 None
552 };
553
554 let summary = db.get_summary(&session_id)?.map(|s| s.content);
556 let tags: Vec<String> = db
557 .get_tags(&session_id)?
558 .into_iter()
559 .map(|t| t.label)
560 .collect();
561
562 Ok(SessionDetailsResponse {
563 session: session_to_info(&session),
564 linked_commits,
565 messages,
566 summary,
567 tags,
568 })
569}
570
571fn list_sessions_impl(params: ListSessionsParams) -> anyhow::Result<Vec<SessionInfo>> {
573 let db = Database::open_default()?;
574
575 let limit = params.limit.unwrap_or(10);
576 let sessions = db.list_sessions(limit, params.repo.as_deref())?;
577
578 Ok(sessions.iter().map(session_to_info).collect())
579}
580
581fn get_context_impl(params: GetContextParams) -> anyhow::Result<ContextResponse> {
583 let db = Database::open_default()?;
584
585 let working_dir = params.repo.unwrap_or_else(|| {
586 std::env::current_dir()
587 .map(|p| p.to_string_lossy().to_string())
588 .unwrap_or_default()
589 });
590
591 let limit = if params.last.unwrap_or(false) { 1 } else { 5 };
592 let sessions = db.list_sessions(limit, Some(&working_dir))?;
593
594 let session_infos: Vec<SessionInfo> = sessions.iter().map(session_to_info).collect();
595
596 let recent_messages = if params.last.unwrap_or(false) && !sessions.is_empty() {
598 let messages = db.get_messages(&sessions[0].id)?;
599 let start = messages.len().saturating_sub(3);
600 Some(messages[start..].iter().map(message_to_info).collect())
601 } else {
602 None
603 };
604
605 Ok(ContextResponse {
606 working_directory: working_dir,
607 sessions: session_infos,
608 recent_messages,
609 })
610}
611
612fn get_linked_sessions_impl(
614 params: GetLinkedSessionsParams,
615) -> anyhow::Result<LinkedSessionsResponse> {
616 let db = Database::open_default()?;
617
618 let links = db.get_links_by_commit(¶ms.commit_sha)?;
619
620 let mut sessions = Vec::new();
621 for link in links {
622 if let Some(session) = db.get_session(&link.session_id)? {
623 sessions.push(session_to_info(&session));
624 }
625 }
626
627 Ok(LinkedSessionsResponse {
628 commit_sha: params.commit_sha,
629 sessions,
630 })
631}
632
633fn memory_to_info(memory: &Memory) -> MemoryInfo {
635 MemoryInfo {
636 name: memory.name.clone(),
637 description: memory.description.clone(),
638 memory_type: memory.memory_type.clone(),
639 content: memory.content.clone(),
640 file_path: memory.file_path.clone(),
641 updated_at: memory.updated_at.to_rfc3339(),
642 }
643}
644
645fn get_memories_impl(params: GetMemoriesParams) -> anyhow::Result<MemoriesResponse> {
650 let db = Database::open_default()?;
651 let project = resolve_project_path(params.project_path.as_deref())?;
652
653 let mirror = MemoryMirror::claude();
655 mirror.refresh(&db, &project)?;
656
657 let project_key = project.to_string_lossy().to_string();
658 let memories = db.get_memories(&project_key, CLAUDE_CODE_TOOL)?;
659 let infos: Vec<MemoryInfo> = memories.iter().map(memory_to_info).collect();
660
661 Ok(MemoriesResponse {
662 project_path: project_key,
663 source_tool: CLAUDE_CODE_TOOL.to_string(),
664 total: infos.len(),
665 memories: infos,
666 })
667}
668
669fn search_memories_impl(params: SearchMemoriesParams) -> anyhow::Result<SearchMemoriesResponse> {
671 let db = Database::open_default()?;
672 let project = resolve_project_path(params.project_path.as_deref())?;
673
674 let mirror = MemoryMirror::claude();
676 mirror.refresh(&db, &project)?;
677
678 let project_key = project.to_string_lossy().to_string();
679 let limit = params.limit.unwrap_or(20);
680 let memories = db.search_memories(&project_key, CLAUDE_CODE_TOOL, ¶ms.query, limit)?;
681 let infos: Vec<MemoryInfo> = memories.iter().map(memory_to_info).collect();
682
683 Ok(SearchMemoriesResponse {
684 query: params.query,
685 project_path: project_key,
686 source_tool: CLAUDE_CODE_TOOL.to_string(),
687 total: infos.len(),
688 memories: infos,
689 })
690}
691
692fn resolve_session_id(db: &Database, id_prefix: &str) -> anyhow::Result<uuid::Uuid> {
694 match db.find_session_by_id_prefix(id_prefix)? {
696 Some(session) => Ok(session.id),
697 None => anyhow::bail!("No session found with ID prefix: {id_prefix}"),
698 }
699}
700
701pub async fn run_server() -> Result<()> {
706 let service = LoreServer::new().serve(stdio()).await?;
707 service.waiting().await?;
708 Ok(())
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714
715 #[test]
716 fn test_parse_date_days() {
717 let result = parse_date("7d").expect("Should parse 7d");
718 let expected = chrono::Utc::now() - chrono::Duration::days(7);
719 assert!((result - expected).num_seconds().abs() < 2);
720 }
721
722 #[test]
723 fn test_parse_date_weeks() {
724 let result = parse_date("2w").expect("Should parse 2w");
725 let expected = chrono::Utc::now() - chrono::Duration::weeks(2);
726 assert!((result - expected).num_seconds().abs() < 2);
727 }
728
729 #[test]
730 fn test_parse_date_months() {
731 let result = parse_date("1m").expect("Should parse 1m");
732 let expected = chrono::Utc::now() - chrono::Duration::days(30);
733 assert!((result - expected).num_seconds().abs() < 2);
734 }
735
736 #[test]
737 fn test_parse_date_iso() {
738 let result = parse_date("2024-01-15").expect("Should parse date");
739 assert_eq!(result.format("%Y-%m-%d").to_string(), "2024-01-15");
740 }
741
742 #[test]
743 fn test_parse_date_invalid() {
744 assert!(parse_date("invalid").is_err());
745 assert!(parse_date("abc123").is_err());
746 }
747
748 #[test]
749 fn test_session_to_info() {
750 use chrono::Utc;
751 use uuid::Uuid;
752
753 let session = Session {
754 id: Uuid::new_v4(),
755 tool: "claude-code".to_string(),
756 tool_version: Some("2.0.0".to_string()),
757 started_at: Utc::now(),
758 ended_at: None,
759 model: Some("claude-3-opus".to_string()),
760 working_directory: "/home/user/project".to_string(),
761 git_branch: Some("main".to_string()),
762 source_path: None,
763 message_count: 10,
764 machine_id: None,
765 };
766
767 let info = session_to_info(&session);
768 assert_eq!(info.tool, "claude-code");
769 assert_eq!(info.message_count, 10);
770 assert_eq!(info.working_directory, "/home/user/project");
771 assert_eq!(info.git_branch, Some("main".to_string()));
772 assert_eq!(info.id_short.len(), 8);
773 }
774
775 #[test]
776 fn test_message_to_info() {
777 use crate::storage::models::{MessageContent, MessageRole};
778 use chrono::Utc;
779 use uuid::Uuid;
780
781 let message = Message {
782 id: Uuid::new_v4(),
783 session_id: Uuid::new_v4(),
784 parent_id: None,
785 index: 0,
786 timestamp: Utc::now(),
787 role: MessageRole::User,
788 content: MessageContent::Text("Hello, world!".to_string()),
789 model: None,
790 git_branch: None,
791 cwd: None,
792 };
793
794 let info = message_to_info(&message);
795 assert_eq!(info.index, 0);
796 assert_eq!(info.role, "user");
797 assert_eq!(info.content, "Hello, world!");
798 }
799
800 #[test]
801 fn test_memory_to_info() {
802 use chrono::Utc;
803 use uuid::Uuid;
804
805 let memory = Memory {
806 id: Uuid::new_v4(),
807 project_path: "/home/user/project".to_string(),
808 source_tool: "claude-code".to_string(),
809 name: "API base URL".to_string(),
810 description: Some("Where the API lives".to_string()),
811 memory_type: Some("reference".to_string()),
812 content: "The API base URL is https://example.com.".to_string(),
813 file_path: "/home/user/.claude/projects/slug/memory/fact-1.md".to_string(),
814 updated_at: Utc::now(),
815 };
816
817 let info = memory_to_info(&memory);
818 assert_eq!(info.name, "API base URL");
819 assert_eq!(info.description.as_deref(), Some("Where the API lives"));
820 assert_eq!(info.memory_type.as_deref(), Some("reference"));
821 assert!(info.content.contains("https://example.com"));
822 assert!(info.file_path.ends_with("fact-1.md"));
823 }
824}