1use 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#[derive(Debug, Deserialize, JsonSchema)]
28pub struct SearchParams {
29 #[schemars(description = "Text to search for in session messages")]
31 pub query: String,
32
33 #[schemars(description = "Maximum number of results (default: 10)")]
35 pub limit: Option<usize>,
36
37 #[schemars(description = "Filter by repository path prefix")]
39 pub repo: Option<String>,
40
41 #[schemars(description = "Filter by AI tool name (e.g., claude-code, aider)")]
43 pub tool: Option<String>,
44
45 #[schemars(description = "Filter to sessions after this date (ISO 8601 or 7d, 2w, 1m)")]
47 pub since: Option<String>,
48}
49
50#[derive(Debug, Deserialize, JsonSchema)]
52pub struct GetSessionParams {
53 #[schemars(description = "Session ID (full UUID or short prefix like abc123)")]
55 pub session_id: String,
56
57 #[schemars(description = "Include full message content (default: true)")]
59 pub include_messages: Option<bool>,
60}
61
62#[derive(Debug, Deserialize, JsonSchema)]
64pub struct ListSessionsParams {
65 #[schemars(description = "Maximum number of sessions (default: 10)")]
67 pub limit: Option<usize>,
68
69 #[schemars(description = "Filter by repository path prefix")]
71 pub repo: Option<String>,
72}
73
74#[derive(Debug, Deserialize, JsonSchema)]
76pub struct GetContextParams {
77 #[schemars(description = "Repository path (defaults to current directory)")]
79 pub repo: Option<String>,
80
81 #[schemars(description = "Show detailed info for the most recent session only")]
83 pub last: Option<bool>,
84}
85
86#[derive(Debug, Deserialize, JsonSchema)]
88pub struct GetLinkedSessionsParams {
89 #[schemars(description = "Git commit SHA (full or short prefix)")]
91 pub commit_sha: String,
92}
93
94#[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#[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#[derive(Debug, Serialize)]
120pub struct SearchResponse {
121 pub query: String,
122 pub total_matches: usize,
123 pub matches: Vec<SearchMatch>,
124}
125
126#[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#[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#[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#[derive(Debug, Serialize)]
155pub struct LinkedSessionsResponse {
156 pub commit_sha: String,
157 pub sessions: Vec<SessionInfo>,
158}
159
160#[derive(Debug, Clone)]
166pub struct LoreServer {
167 tool_router: ToolRouter<LoreServer>,
168}
169
170impl LoreServer {
171 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
185fn 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 #[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 #[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 #[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 #[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 #[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
316fn 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 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 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&date_str) {
342 return Ok(dt.with_timezone(&Utc));
343 }
344
345 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
356fn 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
369fn 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
379fn search_impl(params: SearchParams) -> anyhow::Result<SearchResponse> {
381 let db = Database::open_default()?;
382
383 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
432fn get_session_impl(params: GetSessionParams) -> anyhow::Result<SessionDetailsResponse> {
434 let db = Database::open_default()?;
435
436 let session_id = resolve_session_id(&db, ¶ms.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 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 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 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
471fn 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
481fn 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 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
512fn 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(¶ms.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
533fn resolve_session_id(db: &Database, id_prefix: &str) -> anyhow::Result<uuid::Uuid> {
535 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
542pub 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}