1use 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#[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, 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#[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#[derive(Debug, Serialize)]
121pub struct SearchResponse {
122 pub query: String,
123 pub total_matches: usize,
124 pub matches: Vec<SearchMatch>,
125}
126
127#[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#[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#[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#[derive(Debug, Serialize)]
156pub struct LinkedSessionsResponse {
157 pub commit_sha: String,
158 pub sessions: Vec<SessionInfo>,
159}
160
161#[derive(Debug, Clone)]
167pub struct LoreServer {
168 tool_router: ToolRouter<LoreServer>,
169}
170
171impl LoreServer {
172 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
186fn 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 #[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 #[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 #[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 #[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 #[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
312fn 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 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 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&date_str) {
338 return Ok(dt.with_timezone(&Utc));
339 }
340
341 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
352fn 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
365fn 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
375fn search_impl(params: SearchParams) -> anyhow::Result<SearchResponse> {
377 let db = Database::open_default()?;
378
379 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
428fn get_session_impl(params: GetSessionParams) -> anyhow::Result<SessionDetailsResponse> {
430 let db = Database::open_default()?;
431
432 let session_id = resolve_session_id(&db, ¶ms.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 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 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 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
467fn 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
477fn 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 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
508fn 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(¶ms.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
529fn resolve_session_id(db: &Database, id_prefix: &str) -> anyhow::Result<uuid::Uuid> {
531 if let Ok(uuid) = uuid::Uuid::parse_str(id_prefix) {
533 return Ok(uuid);
534 }
535
536 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
552pub 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}