Skip to content

Commit 5d4bfdd

Browse files
MarkShawn2020claude
andcommitted
feat(mcp): rmcp-based stdio MCP server + imported-data adapter
Phase 3. crates/lovcode-mcp: - LovcodeMcp ServerHandler with two tools — search_conversations (full-text + filters) and list_sources (adapter inventory). - serve_stdio() opens the index and runs the rmcp service on stdin/stdout. - CLI `lovcode mcp` now delegates here (no longer stubbed). - JSON-RPC smoke test verified: initialize → tools/list → tools/call search_conversations returns hits over stdio. crates/lovcode-core: - New `imported` adapter family — reads canonical Conversation JSON-lines from ~/.lovcode/imports/<source>/*.jsonl. Used to onboard data sources we don't have a first-class adapter for: ChatGPT, Gemini, Claude Desktop. The escape hatch keeps the core minimal while enabling any external converter / skill to feed Lovcode. - SourceAdapter gains `parse_many(path) -> Vec<Conversation>` with a default that delegates to `parse`. ImportedAdapter overrides it so one jsonl file fans out to N conversations. - builtin_adapters() now registers chatgpt / gemini / claude-desktop alongside claude-code / codex. End-to-end smoke: dropped a sample chatgpt jsonl, ran `lovcode index` (930 conversations, 3.3s), then `lovcode search "倒排索引" --source chatgpt` returned the expected hit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 1610349 commit 5d4bfdd

9 files changed

Lines changed: 348 additions & 21 deletions

File tree

Cargo.lock

Lines changed: 88 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ resolver = "2"
33
members = [
44
"crates/lovcode-core",
55
"crates/lovcode-cli",
6+
"crates/lovcode-mcp",
67
"src-tauri",
78
]
89

crates/lovcode-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ path = "src/main.rs"
1212

1313
[dependencies]
1414
lovcode-core = { path = "../lovcode-core" }
15+
lovcode-mcp = { path = "../lovcode-mcp" }
1516

1617
clap = { version = "4", features = ["derive"] }
1718
serde = { workspace = true }

crates/lovcode-cli/src/cmd_mcp.rs

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
1-
//! Stdio MCP server. Phase 3 wires this up to `rmcp`; for now we stub it
2-
//! so the subcommand exists and prints an actionable message.
1+
//! Stdio MCP server bridge. Delegates to `lovcode-mcp`.
32
43
use anyhow::Result;
54
use std::path::Path;
65

7-
pub fn run(_index_dir: &Path) -> Result<()> {
8-
eprintln!(
9-
"lovcode mcp: not implemented yet (lands in phase 3). \
10-
The crate `lovcode-mcp` will host the rmcp server; \
11-
this subcommand will then spawn it on stdio."
12-
);
13-
std::process::exit(2);
6+
pub fn run(index_dir: &Path) -> Result<()> {
7+
let rt = tokio::runtime::Runtime::new()?;
8+
rt.block_on(lovcode_mcp::serve_stdio(index_dir.to_path_buf()))
149
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
//! Generic "imported" adapter.
2+
//!
3+
//! Reads canonical `Conversation` JSON lines from `~/.lovcode/imports/<source>/*.jsonl`,
4+
//! one full conversation per line. This is the escape hatch for any data
5+
//! source we don't have a first-class adapter for yet: ChatGPT exports,
6+
//! Gemini exports, Claude.ai web dumps, etc.
7+
//!
8+
//! A small converter binary or skill writes the canonical JSON; the adapter
9+
//! itself stays trivial.
10+
11+
use super::SourceAdapter;
12+
use crate::types::Conversation;
13+
use anyhow::{Context, Result};
14+
use std::fs::File;
15+
use std::io::{BufRead, BufReader};
16+
use std::path::{Path, PathBuf};
17+
use walkdir::WalkDir;
18+
19+
pub struct ImportedAdapter {
20+
pub id: &'static str,
21+
pub name: &'static str,
22+
pub subdir: &'static str,
23+
}
24+
25+
impl ImportedAdapter {
26+
pub const fn chatgpt() -> Self {
27+
Self { id: "chatgpt", name: "ChatGPT (imported)", subdir: "chatgpt" }
28+
}
29+
pub const fn gemini() -> Self {
30+
Self { id: "gemini", name: "Gemini (imported)", subdir: "gemini" }
31+
}
32+
pub const fn claude_desktop() -> Self {
33+
Self { id: "claude-desktop", name: "Claude Desktop (imported)", subdir: "claude-desktop" }
34+
}
35+
36+
fn root(&self) -> PathBuf {
37+
dirs::home_dir()
38+
.unwrap_or_else(|| PathBuf::from("."))
39+
.join(".lovcode")
40+
.join("imports")
41+
.join(self.subdir)
42+
}
43+
}
44+
45+
impl SourceAdapter for ImportedAdapter {
46+
fn id(&self) -> &'static str { self.id }
47+
fn name(&self) -> &'static str { self.name }
48+
49+
fn discover(&self) -> Result<Vec<PathBuf>> {
50+
let root = self.root();
51+
if !root.exists() {
52+
return Ok(Vec::new());
53+
}
54+
Ok(WalkDir::new(&root)
55+
.into_iter()
56+
.filter_map(|e| e.ok())
57+
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("jsonl"))
58+
.map(|e| e.into_path())
59+
.collect())
60+
}
61+
62+
fn parse(&self, _path: &Path) -> Result<Conversation> {
63+
anyhow::bail!("ImportedAdapter is multi-conversation; use parse_many")
64+
}
65+
66+
fn parse_many(&self, path: &Path) -> Result<Vec<Conversation>> {
67+
let mut out = read_imported_file(path)?;
68+
// Stamp the source id onto each conversation so downstream filtering
69+
// and per-source counts stay accurate regardless of what the writer
70+
// put in the canonical JSON.
71+
for c in &mut out {
72+
c.source = self.id.to_string();
73+
}
74+
Ok(out)
75+
}
76+
77+
fn watch_roots(&self) -> Vec<PathBuf> {
78+
vec![self.root()]
79+
}
80+
}
81+
82+
/// Each line in the file is one canonical `Conversation`. The watcher path
83+
/// is whole-file; we need the per-line stream too. Public so the indexer
84+
/// can stream them out.
85+
pub fn read_imported_file(path: &Path) -> Result<Vec<Conversation>> {
86+
let file = File::open(path).with_context(|| format!("open {}", path.display()))?;
87+
let reader = BufReader::new(file);
88+
let mut out = Vec::new();
89+
for line in reader.lines().map_while(Result::ok) {
90+
if line.trim().is_empty() { continue; }
91+
match serde_json::from_str::<Conversation>(&line) {
92+
Ok(c) => out.push(c),
93+
Err(e) => tracing::warn!(error = %e, path = %path.display(), "skipped malformed import line"),
94+
}
95+
}
96+
Ok(out)
97+
}

crates/lovcode-core/src/adapter/mod.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::path::{Path, PathBuf};
55

66
pub mod claude_code;
77
pub mod codex;
8+
pub mod imported;
89

910
pub trait SourceAdapter: Send + Sync {
1011
/// Stable id, e.g. `"claude-code"`.
@@ -16,9 +17,17 @@ pub trait SourceAdapter: Send + Sync {
1617
/// Enumerate conversation files on disk for this adapter.
1718
fn discover(&self) -> anyhow::Result<Vec<PathBuf>>;
1819

19-
/// Parse one conversation file into the canonical `Conversation` shape.
20+
/// Parse one conversation file. One-per-file adapters return one
21+
/// Conversation; multi-per-file adapters should override `parse_many`
22+
/// and may leave `parse` as `unreachable!()`.
2023
fn parse(&self, path: &Path) -> anyhow::Result<Conversation>;
2124

25+
/// Optional batch parser: one file → N conversations. Default delegates
26+
/// to `parse`.
27+
fn parse_many(&self, path: &Path) -> anyhow::Result<Vec<Conversation>> {
28+
Ok(vec![self.parse(path)?])
29+
}
30+
2231
/// Roots the watcher should observe for live updates.
2332
fn watch_roots(&self) -> Vec<PathBuf>;
2433
}
@@ -28,5 +37,8 @@ pub fn builtin_adapters() -> Vec<Box<dyn SourceAdapter>> {
2837
vec![
2938
Box::new(claude_code::ClaudeCodeAdapter),
3039
Box::new(codex::CodexAdapter),
40+
Box::new(imported::ImportedAdapter::chatgpt()),
41+
Box::new(imported::ImportedAdapter::gemini()),
42+
Box::new(imported::ImportedAdapter::claude_desktop()),
3143
]
3244
}

crates/lovcode-core/src/watcher.rs

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,17 @@ pub fn index_all(idx: &LovcodeIndex, adapters: &[Box<dyn SourceAdapter>]) -> Res
2626
}
2727
};
2828
for path in paths {
29-
match adapter.parse(&path) {
30-
Ok(c) if !c.messages.is_empty() => {
31-
if let Err(e) = add_conversation(&mut writer, schema, &c) {
32-
tracing::warn!(path = %path.display(), error = %e, "index failed");
33-
} else {
34-
count += 1;
29+
match adapter.parse_many(&path) {
30+
Ok(conversations) => {
31+
for c in conversations {
32+
if c.messages.is_empty() { continue; }
33+
if let Err(e) = add_conversation(&mut writer, schema, &c) {
34+
tracing::warn!(path = %path.display(), error = %e, "index failed");
35+
} else {
36+
count += 1;
37+
}
3538
}
3639
}
37-
Ok(_) => {} // empty conversation, skip
3840
Err(e) => {
3941
tracing::warn!(path = %path.display(), error = %e, "parse failed");
4042
}
@@ -103,11 +105,11 @@ fn flush(idx: &LovcodeIndex, pending: &mut Vec<(PathBuf, &dyn SourceAdapter)>) -
103105

104106
for (path, adapter) in pending.drain(..) {
105107
if !seen.insert(path.clone()) { continue; }
106-
match adapter.parse(&path) {
107-
Ok(c) if !c.messages.is_empty() => {
108+
if let Ok(conversations) = adapter.parse_many(&path) {
109+
for c in conversations {
110+
if c.messages.is_empty() { continue; }
108111
let _ = add_conversation(&mut writer, schema, &c);
109112
}
110-
_ => {}
111113
}
112114
}
113115
writer.commit()?;

crates/lovcode-mcp/Cargo.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "lovcode-mcp"
3+
version.workspace = true
4+
edition.workspace = true
5+
license.workspace = true
6+
repository.workspace = true
7+
description = "MCP server library for Lovcode — exposes conversation search as MCP tools."
8+
9+
[dependencies]
10+
lovcode-core = { path = "../lovcode-core" }
11+
12+
rmcp = { version = "1", features = ["server", "macros", "transport-io", "schemars"] }
13+
schemars = "0.8"
14+
serde = { workspace = true }
15+
serde_json = { workspace = true }
16+
tokio = { workspace = true }
17+
anyhow = { workspace = true }
18+
tracing = { workspace = true }

0 commit comments

Comments
 (0)