65 KiB
claw-rag-mcp Standalone MCP Server — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a standalone, PATH-installable MCP stdio server (claw-rag-mcp) that exposes RAG rag_query / rag_stats / rag_ingest / rag_ingest_status tools over a SQLite index shared with claw-rag-service.
Architecture: New independent Rust project at D:\tempo\claw-rag-mcp (own Cargo workspace, own git repo, not inside claw-code). It path-depends on the existing claw-rag-service lib for indexing/embedding/search logic. Transport is a self-written minimal stdio MCP server (LSP Content-Length framing + JSON-RPC initialize/tools/list/tools/call), zero MCP SDK dependency, honoring the repo-wide forbid(unsafe_code) lint. Ingest runs as an in-memory async job so rag_ingest returns immediately and rag_ingest_status polls progress.
Tech Stack: Rust 2021, tokio (macros/rt-multi-thread/io-std/io-util/sync/time), serde + serde_json, reqwest 0.12 (json, rustls-tls), claw-rag-service (path dep), rusqlite (transitive via claw-rag-service), tempfile (dev).
Global Constraints
- Project lives at
D:\tempo\claw-rag-mcp— not inside the claw-code repository. It is its own git repo (git initin Task 2). The only file changed inside claw-code isclaw-rag-service(Task 1). - Path dependency:
claw-rag-service = { path = "D:/tempo/claw-code/rust/crates/claw-rag-service" }. Use forward slashes. - Must compile with
cargo build --releaseandcargo test --release. On this machine every cargo invocation needs MSVC: run viacmd /c "C:\Users\Incredible\.config\opencode\CompilePreSet.bat && cargo ...". #![forbid(unsafe_code)]applies to every crate compiled (standalone crate sets its own[lints.rust] unsafe_code = "forbid").- MCP protocol subset only:
initialize,tools/list,tools/call. Protocol version2025-03-26. Capabilities{"tools": {}}. serverInfo name =claw-rag, version =env!("CARGO_PKG_VERSION"). - Tool names:
rag_query,rag_stats,rag_ingest,rag_ingest_status. No coupling with the originalretrieve_contextnaming or behavior. - Shared index: env
CLAW_RAG_DB(default.claw-rag/index.sqlite). Embedding env vars:CLAW_RAG_OPENAI_API_KEY/OPENAI_API_KEY,CLAW_RAG_EMBEDDING_BASE_URL(defaulthttps://api.openai.com/v1),CLAW_RAG_EMBEDDING_MODEL(defaulttext-embedding-3-small),CLAW_RAG_MOCK_PROVIDERS=1for deterministic mock vectors in tests. rag_query:top_kdefault 8, clamped 1..=32.- JSON-RPC error codes:
-32700parse,-32600invalid request,-32601method not found,-32602invalid params. - Tool-level failures →
isError: true+ text message. - Ingest jobs: in-memory registry, serialized through a global async mutex,
job_idis an incrementing integer string ("1","2", …). No persistence, no HTTP/SSE, no resources/prompts/auth.
Task 1: claw-rag-service — add progress reporting to ingest
Files:
- Modify:
D:\tempo\claw-code\rust\crates\claw-rag-service\src\ingest.rs:30-207 - Modify:
D:\tempo\claw-code\rust\crates\claw-rag-service\src\lib.rs:14
Interfaces:
-
Consumes: existing
IngestStats(files_indexed, chunks_total, embeddings_written). -
Produces:
pub struct IngestProgress { pub files_done: usize, pub files_total: usize, pub chunks_total: usize }andpub async fn run_ingest_with_progress<F>(workspaces: &[PathBuf], db_path: &Path, cfg: &EmbedConfig, client: &Client, progress: F) -> Result<IngestStats, String> where F: FnMut(IngestProgress).run_ingestis preserved as a delegating wrapper (zero caller changes). -
Step 1: Write the failing test
Append a #[cfg(test)] mod tests at the end of ingest.rs:
#[cfg(test)]
mod tests {
use super::*;
use reqwest::Client;
use tempfile::tempdir;
#[tokio::test]
async fn run_ingest_with_progress_reports_all_files() {
std::env::set_var("CLAW_RAG_MOCK_PROVIDERS", "1");
let dir = tempdir().unwrap();
let ws = dir.path().join("ws");
std::fs::create_dir_all(&ws).unwrap();
std::fs::write(ws.join("a.rs"), "alpha beta").unwrap();
std::fs::write(ws.join("b.rs"), "gamma delta").unwrap();
let db = dir.path().join("idx.sqlite");
let client = Client::new();
let cfg = EmbedConfig::mock_from_env().expect("mock embed config");
let mut seen = Vec::new();
let st = run_ingest_with_progress(&[ws.clone()], &db, &cfg, &client, |p| seen.push(p))
.await
.expect("ingest");
assert_eq!(st.files_indexed, 2);
let last = seen.last().expect("progress emitted");
assert_eq!(last.files_total, 2);
assert_eq!(last.files_done, 2);
assert!(last.chunks_total > 0);
std::env::remove_var("CLAW_RAG_MOCK_PROVIDERS");
}
}
- Step 2: Run test to verify it fails
Run: cmd /c "C:\Users\Incredible\.config\opencode\CompilePreSet.bat && cargo test --release -p claw-rag-service run_ingest_with_progress"
Expected: FAIL — cannot find function run_ingest_with_progress.
- Step 3: Implement
IngestProgressandrun_ingest_with_progress
In ingest.rs, after the IngestStats struct definition (line 35), add:
#[derive(Debug, Clone, Copy)]
pub struct IngestProgress {
pub files_done: usize,
pub files_total: usize,
pub chunks_total: usize,
}
Replace the entire run_ingest function (lines 92-207) with:
pub async fn run_ingest(
workspaces: &[PathBuf],
db_path: &Path,
cfg: &EmbedConfig,
client: &Client,
) -> Result<IngestStats, String> {
run_ingest_with_progress(workspaces, db_path, cfg, client, |_| {}).await
}
pub async fn run_ingest_with_progress<F>(
workspaces: &[PathBuf],
db_path: &Path,
cfg: &EmbedConfig,
client: &Client,
mut progress: F,
) -> Result<IngestStats, String>
where
F: FnMut(IngestProgress),
{
let conn = open_db(db_path)?;
let mut all_files: Vec<(String, PathBuf)> = Vec::new();
let mut seen_paths: Vec<String> = Vec::new();
for ws in workspaces {
let workspace = ws
.canonicalize()
.map_err(|e| format!("workspace: {}: {e}", ws.display()))?;
let ws_prefix = workspace.clone();
let repo_id = repo_id_for_workspace(&workspace);
for entry in WalkDir::new(&workspace)
.into_iter()
.filter_entry(|e| !should_skip_dir(e.path()))
{
let entry = entry.map_err(|e| e.to_string())?;
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
if !is_text_extension(path) {
continue;
}
let meta = entry.metadata().map_err(|e| e.to_string())?;
if meta.len() > DEFAULT_MAX_FILE_BYTES {
continue;
}
let rel = path
.strip_prefix(&ws_prefix)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
let key = format!("{repo_id}:{rel}");
seen_paths.push(key.clone());
all_files.push((key, path.to_path_buf()));
}
}
all_files.sort_by(|a, b| a.0.cmp(&b.0));
seen_paths.sort();
let mut stats = IngestStats {
files_indexed: all_files.len(),
..Default::default()
};
for (idx, (rel, file)) in all_files.iter().enumerate() {
progress(IngestProgress {
files_done: idx + 1,
files_total: all_files.len(),
chunks_total: stats.chunks_total,
});
let Ok(meta) = std::fs::metadata(file) else {
continue;
};
let size_bytes =
i64::try_from(meta.len()).map_err(|_| "file size too large".to_string())?;
let mtime_ms = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|d| i64::try_from(d.as_millis()).ok())
.unwrap_or(0);
let Ok(raw) = std::fs::read_to_string(file) else {
continue;
};
let content_hash = blake3::hash(raw.as_bytes()).to_hex().to_string();
if file_is_unchanged(&conn, rel, &content_hash, size_bytes, mtime_ms)? {
continue;
}
// Re-index this file: delete previous chunks (and embeddings) for path.
delete_file_and_chunks(&conn, rel)?;
let pieces = chunk_text(&raw, CHUNK_CHARS, CHUNK_OVERLAP);
if pieces.is_empty() {
continue;
}
let mut batch: Vec<(i32, String)> = Vec::new();
for (ord, piece) in pieces.into_iter().enumerate() {
stats.chunks_total += 1;
let ord_i32 =
i32::try_from(ord).map_err(|_| "file produced too many chunks".to_string())?;
batch.push((ord_i32, piece));
if batch.len() >= EMBED_BATCH {
flush_path_batch(&conn, rel, &mut batch, client, cfg, &mut stats).await?;
}
}
flush_path_batch(&conn, rel, &mut batch, client, cfg, &mut stats).await?;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| i64::try_from(d.as_millis()).unwrap_or(0))
.unwrap_or(0);
upsert_file_meta(&conn, rel, &content_hash, size_bytes, mtime_ms, now_ms)?;
}
// Delete entries for files that no longer exist.
// (We compare against file list from DB to avoid needing a SQL "NOT IN" temp table.)
let mut seen_set = std::collections::BTreeSet::new();
for p in &seen_paths {
seen_set.insert(p.as_str());
}
for p in list_all_files(&conn)? {
if !seen_set.contains(p.as_str()) {
delete_file_and_chunks(&conn, &p)?;
}
}
Ok(stats)
}
Note: the loop body now borrows rel/file (&rel, &file) because all_files is iterated by reference; flush_path_batch takes &str and &PathBuf params, so pass rel and file (auto-deref) as in the code above.
- Step 4: Export from lib.rs
In D:\tempo\claw-code\rust\crates\claw-rag-service\src\lib.rs, change line 14:
pub use ingest::{run_ingest, run_ingest_with_progress, IngestProgress, IngestStats};
- Step 5: Run the new test and the existing ingest roundtrip test
Run: cmd /c "C:\Users\Incredible\.config\opencode\CompilePreSet.bat && cargo test --release -p claw-rag-service"
Expected: PASS (all tests including run_ingest_with_progress_reports_all_files and existing ingest_and_query_roundtrip_mock).
- Step 6: Commit (in claw-code repo)
cd D:\tempo\claw-code
git add rust/crates/claw-rag-service/src/ingest.rs rust/crates/claw-rag-service/src/lib.rs
git commit -m "feat(rag-service): add run_ingest_with_progress with IngestProgress"
Task 2: Scaffold the standalone project
Files:
- Create:
D:\tempo\claw-rag-mcp\Cargo.toml - Create:
D:\tempo\claw-rag-mcp\.gitignore - Create:
D:\tempo\claw-rag-mcp\src\lib.rs - Create:
D:\tempo\claw-rag-mcp\src\main.rs - Create:
D:\tempo\claw-rag-mcp\src\framing.rs - Create:
D:\tempo\claw-rag-mcp\src\protocol.rs - Create:
D:\tempo\claw-rag-mcp\src\server.rs - Create:
D:\tempo\claw-rag-mcp\src\tools.rs
Interfaces:
-
Consumes:
claw-rag-servicelib from Task 1. -
Produces: compilable crate skeleton with module stubs. Later tasks fill each module.
-
Step 1: Create the directory and Cargo.toml
Create D:\tempo\claw-rag-mcp\Cargo.toml:
[package]
name = "claw-rag-mcp"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "Standalone MCP server exposing RAG query/stats/ingest over a shared SQLite index"
[workspace]
[dependencies]
claw-rag-service = { path = "D:/tempo/claw-code/rust/crates/claw-rag-service" }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "io-util", "sync", "time"] }
[dev-dependencies]
tempfile = "3"
[lints.rust]
unsafe_code = "forbid"
Create D:\tempo\claw-rag-mcp\.gitignore:
/target
- Step 2: Create module stubs
Create D:\tempo\claw-rag-mcp\src\framing.rs:
//! LSP `Content-Length` framing for MCP stdio transport.
Create D:\tempo\claw-rag-mcp\src\protocol.rs:
//! JSON-RPC 2.0 and MCP message types.
Create D:\tempo\claw-rag-mcp\src\server.rs:
//! Minimal stdio MCP server: dispatch over LSP-framed JSON-RPC.
Create D:\tempo\claw-rag-mcp\src\tools.rs:
//! RAG tool handlers and the async ingest job registry.
Create D:\tempo\claw-rag-mcp\src\lib.rs:
//! Standalone RAG MCP server (stdio).
#![forbid(unsafe_code)]
pub mod framing;
pub mod protocol;
pub mod server;
pub mod tools;
Create D:\tempo\claw-rag-mcp\src\main.rs:
use std::sync::Arc;
use claw_rag_mcp::tools::{build_server, AppState};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let state = Arc::new(AppState::from_env()?);
let server = build_server(state);
server.run(tokio::io::stdin(), tokio::io::stdout()).await?;
Ok(())
}
(These reference AppState, build_server, and McpServer::run which Tasks 4-6 implement; the crate will not compile until then — that is expected and resolved in Task 6.)
- Step 3: git init and verify the crate starts building
Run:
cd D:\tempo\claw-rag-mcp
git init
git add -A
git commit -m "chore: scaffold claw-rag-mcp crate"
cmd /c "C:\Users\Incredible\.config\opencode\CompilePreSet.bat && cargo build --release"
Expected: build fails with "cannot find function build_server" — confirming the path dependency on claw-rag-service resolves (its deps, incl. bundled rusqlite, compile successfully). If it fails earlier on claw-rag-service, the path in Cargo.toml is wrong.
- Step 4: Commit scaffold
git add -A
git commit -m "chore: verify claw-rag-service path dependency compiles"
(Only run after confirming the dependency resolved; if the crate compiles fully, fine — commit either way.)
Task 3: protocol module — JSON-RPC and MCP types + framing
Files:
- Modify:
D:\tempo\claw-rag-mcp\src\protocol.rs - Modify:
D:\tempo\claw-rag-mcp\src\framing.rs
Interfaces:
-
Consumes: serde, serde_json.
-
Produces:
JsonRpcId(untagged enum Null/Number/String),JsonRpcRequest<T>,JsonRpcResponse<T>,JsonRpcError,McpTool,McpInitializeResult,McpServerInfo,McpListToolsResult,McpToolCallParams,McpToolCallResult,McpToolCallContent,PROTOCOL_VERSION: &str = "2025-03-26". Plusframing::read_frame<R: AsyncBufRead + Unpin>(&mut R) -> io::Result<Option<Vec<u8>>>andframing::write_frame<W: AsyncWrite + Unpin>(&mut W, &[u8]) -> io::Result<()>. -
Step 1: Write the failing tests
Append to protocol.rs:
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn request_serialize_roundtrip() {
let req = JsonRpcRequest::<JsonValue> {
jsonrpc: "2.0".to_string(),
id: JsonRpcId::Number(7),
method: "tools/list".to_string(),
params: None,
};
let s = serde_json::to_string(&req).expect("serialize");
assert!(s.contains("\"id\":7"));
let back: JsonRpcRequest<JsonValue> = serde_json::from_str(&s).expect("deserialize");
assert_eq!(back.method, "tools/list");
assert_eq!(back.id, JsonRpcId::Number(7));
}
#[test]
fn tool_call_result_uses_standard_text_shape() {
let result = McpToolCallResult {
content: vec![McpToolCallContent::Text {
text: "hello".to_string(),
}],
structured_content: None,
is_error: Some(false),
meta: None,
};
let v = serde_json::to_value(&result).expect("serialize");
assert_eq!(v["content"][0]["type"], "text");
assert_eq!(v["content"][0]["text"], "hello");
assert_eq!(v["isError"], false);
}
#[test]
fn id_supports_string_and_null() {
let id = JsonRpcId::String("abc".to_string());
let v = serde_json::to_value(&id).expect("serialize");
assert_eq!(v, json!("abc"));
assert_eq!(
serde_json::from_value::<JsonRpcId>(json!(null)).expect("null id"),
JsonRpcId::Null
);
}
}
Append to framing.rs:
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{BufReader, Cursor};
#[tokio::test]
async fn frame_roundtrip() {
let body = b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}";
let mut buf = Vec::new();
write_frame(&mut buf, body).await.expect("write");
assert!(buf.starts_with(b"Content-Length: "));
let mut reader = BufReader::new(Cursor::new(&buf[..]));
let got = read_frame(&mut reader).await.expect("read").expect("frame present");
assert_eq!(got, body);
}
#[tokio::test]
async fn read_frame_eof_returns_none() {
let mut reader = BufReader::new(Cursor::new(&b""[..]));
assert!(read_frame(&mut reader).await.expect("read").is_none());
}
}
- Step 2: Run tests to verify they fail
Run: cmd /c "C:\Users\Incredible\.config\opencode\CompilePreSet.bat && cargo test --release -p claw-rag-mcp --lib protocol::tests framing::tests"
Expected: FAIL — types/functions not defined (compile error).
- Step 3: Implement protocol.rs
Replace the content of protocol.rs with:
//! JSON-RPC 2.0 and MCP message types.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
/// Protocol version advertised during `initialize`.
pub const PROTOCOL_VERSION: &str = "2025-03-26";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum JsonRpcId {
Null,
Number(u64),
String(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcRequest<T> {
pub jsonrpc: String,
pub id: JsonRpcId,
pub method: String,
pub params: Option<T>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcError {
pub code: i32,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<JsonValue>,
}
#[derive(Debug, Clone, Serialize)]
pub struct JsonRpcResponse<T> {
pub jsonrpc: String,
pub id: JsonRpcId,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<T>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<JsonRpcError>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpTool {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_schema: Option<JsonValue>,
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<JsonValue>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<JsonValue>,
}
#[derive(Debug, Clone, Serialize)]
pub struct McpInitializeResult {
pub protocol_version: String,
pub capabilities: JsonValue,
pub server_info: McpServerInfo,
}
#[derive(Debug, Clone, Serialize)]
pub struct McpServerInfo {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct McpListToolsResult {
pub tools: Vec<McpTool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct McpToolCallParams {
pub name: String,
#[serde(default)]
pub arguments: Option<JsonValue>,
}
#[derive(Debug, Clone, Serialize)]
pub struct McpToolCallResult {
pub content: Vec<McpToolCallContent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub structured_content: Option<JsonValue>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_error: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<JsonValue>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum McpToolCallContent {
Text { text: String },
}
#[allow(dead_code)]
fn _assert_send_sync(_: &dyn std::marker::Send) {}
/// Build a `BTreeMap` used as the tool-result text payload (compat helper).
#[allow(dead_code)]
pub fn text_content_map(text: String) -> BTreeMap<String, JsonValue> {
let mut map = BTreeMap::new();
map.insert("text".to_string(), JsonValue::String(text));
map
}
- Step 4: Implement framing.rs
Replace the content of framing.rs with:
//! LSP `Content-Length` framing for MCP stdio transport.
use std::io;
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWrite, AsyncWriteExt};
/// Read one framed JSON-RPC payload.
///
/// Returns `Ok(None)` on clean EOF before any header bytes have been read.
pub async fn read_frame<R: AsyncBufRead + Unpin>(
reader: &mut R,
) -> io::Result<Option<Vec<u8>>> {
let mut content_length: Option<usize> = None;
let mut first_header = true;
loop {
let mut line = String::new();
let bytes_read = reader.read_line(&mut line).await?;
if bytes_read == 0 {
if first_header {
return Ok(None);
}
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"MCP stdio stream closed while reading headers",
));
}
first_header = false;
if line == "\r\n" || line == "\n" {
break;
}
let header = line.trim_end_matches(['\r', '\n']);
if let Some((name, value)) = header.split_once(':') {
if name.trim().eq_ignore_ascii_case("Content-Length") {
let parsed = value
.trim()
.parse::<usize>()
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
content_length = Some(parsed);
}
}
}
let content_length = content_length.ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "missing Content-Length header")
})?;
let mut payload = vec![0_u8; content_length];
reader.read_exact(&mut payload).await?;
Ok(Some(payload))
}
/// Write a single LSP-framed payload.
pub async fn write_frame<W: AsyncWrite + Unpin>(
writer: &mut W,
body: &[u8],
) -> io::Result<()> {
let header = format!("Content-Length: {}\r\n\r\n", body.len());
writer.write_all(header.as_bytes()).await?;
writer.write_all(body).await?;
writer.flush().await
}
- Step 5: Run tests to verify they pass
Run: cmd /c "C:\Users\Incredible\.config\opencode\CompilePreSet.bat && cargo test --release -p claw-rag-mcp --lib protocol::tests framing::tests"
Expected: PASS (5 tests).
- Step 6: Commit
git add src/protocol.rs src/framing.rs
git commit -m "feat: JSON-RPC/MCP types and LSP framing"
Task 4: server module — dispatch + run loop
Files:
- Modify:
D:\tempo\claw-rag-mcp\src\server.rs
Interfaces:
-
Consumes:
protocolmodule from Task 3. -
Produces:
pub type ToolCallHandler = Box<dyn Fn(&str, &JsonValue) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> + Send + Sync>;pub struct McpServerSpec { server_name, server_version, tools: Vec<McpTool>, tool_handler: ToolCallHandler };pub struct McpServerwithnew(spec),async fn dispatch(&self, request: JsonRpcRequest<JsonValue>) -> JsonRpcResponse<JsonValue>, andasync fn run<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(&self, reader: R, writer: W) -> io::Result<()>. -
Step 1: Write the failing tests
Append to server.rs:
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::{McpTool, PROTOCOL_VERSION};
use serde_json::json;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
fn echo_server() -> McpServer {
let tool = McpTool {
name: "echo".to_string(),
description: Some("Echo".to_string()),
input_schema: Some(json!({"type": "object"})),
annotations: None,
meta: None,
};
let spec = McpServerSpec {
server_name: "claw-rag".to_string(),
server_version: "0.0.0".to_string(),
tools: vec![tool],
tool_handler: Box::new(|name, args| {
Box::pin(async move { Ok(format!("called {name} with {args}")) })
}),
};
McpServer::new(spec)
}
#[tokio::test]
async fn dispatch_initialize_returns_server_info() {
let request = JsonRpcRequest::<JsonValue> {
jsonrpc: "2.0".to_string(),
id: JsonRpcId::Number(1),
method: "initialize".to_string(),
params: None,
};
let response = echo_server().dispatch(request).await;
assert!(response.error.is_none());
let result = response.result.expect("result");
assert_eq!(result["protocolVersion"], PROTOCOL_VERSION);
assert_eq!(result["serverInfo"]["name"], "claw-rag");
}
#[tokio::test]
async fn dispatch_tools_list_returns_tools() {
let request = JsonRpcRequest::<JsonValue> {
jsonrpc: "2.0".to_string(),
id: JsonRpcId::Number(2),
method: "tools/list".to_string(),
params: None,
};
let response = echo_server().dispatch(request).await;
let result = response.result.expect("result");
assert_eq!(result["tools"][0]["name"], "echo");
}
#[tokio::test]
async fn dispatch_tools_call_wraps_handler_output() {
let request = JsonRpcRequest::<JsonValue> {
jsonrpc: "2.0".to_string(),
id: JsonRpcId::Number(3),
method: "tools/call".to_string(),
params: Some(json!({"name": "echo", "arguments": {"text": "hi"}})),
};
let response = echo_server().dispatch(request).await;
let result = response.result.expect("result");
assert_eq!(result["isError"], false);
assert_eq!(result["content"][0]["type"], "text");
assert!(result["content"][0]["text"].as_str().unwrap().starts_with("called echo"));
}
#[tokio::test]
async fn dispatch_tools_call_surfaces_handler_error() {
let tool = McpTool {
name: "broken".to_string(),
description: None,
input_schema: None,
annotations: None,
meta: None,
};
let spec = McpServerSpec {
server_name: "x".to_string(),
server_version: "0.0.0".to_string(),
tools: vec![tool],
tool_handler: Box::new(|_, _| Box::pin(async move { Err("boom".to_string()) })),
};
let server = McpServer::new(spec);
let request = JsonRpcRequest::<JsonValue> {
jsonrpc: "2.0".to_string(),
id: JsonRpcId::Number(4),
method: "tools/call".to_string(),
params: Some(json!({"name": "broken"})),
};
let response = server.dispatch(request).await;
let result = response.result.expect("result");
assert_eq!(result["isError"], true);
assert_eq!(result["content"][0]["text"], "boom");
}
#[tokio::test]
async fn dispatch_unknown_method_returns_error() {
let request = JsonRpcRequest::<JsonValue> {
jsonrpc: "2.0".to_string(),
id: JsonRpcId::Number(5),
method: "nonsense".to_string(),
params: None,
};
let response = echo_server().dispatch(request).await;
let error = response.error.expect("error");
assert_eq!(error.code, -32601);
}
#[tokio::test]
async fn run_roundtrip_over_duplex() {
let server = echo_server();
let (client, srv) = tokio::io::duplex(1 << 16);
let server_task = tokio::spawn(server.run(srv.clone(), srv));
let body = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#;
let header = format!("Content-Length: {}\r\n\r\n", body.len());
let mut c = client;
c.write_all(header.as_bytes()).await.expect("write header");
c.write_all(body.as_bytes()).await.expect("write body");
let mut line = String::new();
let mut reader = BufReader::new(&mut c);
reader.read_line(&mut line).await.expect("read len header");
let cl: usize = line.trim().split(':').nth(1).unwrap().trim().parse().expect("parse len");
line.clear();
reader.read_line(&mut line).await.expect("read blank");
let mut payload = vec![0_u8; cl];
reader.read_exact(&mut payload).await.expect("read body");
let v: JsonValue = serde_json::from_slice(&payload).expect("json");
assert_eq!(v["result"]["serverInfo"]["name"], "claw-rag");
drop(c);
let _ = server_task.await;
}
}
- Step 2: Run tests to verify they fail
Run: cmd /c "C:\Users\Incredible\.config\opencode\CompilePreSet.bat && cargo test --release -p claw-rag-mcp --lib server::tests"
Expected: FAIL — McpServer/McpServerSpec/ToolCallHandler undefined.
- Step 3: Implement server.rs
Replace the content of server.rs with:
//! Minimal stdio MCP server: dispatch over LSP-framed JSON-RPC.
use std::future::Future;
use std::io;
use std::pin::Pin;
use serde_json::{json, Value as JsonValue};
use tokio::io::{AsyncBufRead, AsyncRead, AsyncWrite, BufReader};
use crate::framing;
use crate::protocol::{
JsonRpcError, JsonRpcId, JsonRpcRequest, JsonRpcResponse, McpInitializeResult,
McpListToolsResult, McpServerInfo, McpTool, McpToolCallContent, McpToolCallParams,
McpToolCallResult, PROTOCOL_VERSION,
};
/// Synchronous-triggering, async-returning handler for `tools/call`.
///
/// `Ok(text)` yields a single text content block with `isError: false`;
/// `Err(message)` yields text with `isError: true`.
pub type ToolCallHandler =
Box<dyn Fn(&str, &JsonValue) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> + Send + Sync>;
pub struct McpServerSpec {
pub server_name: String,
pub server_version: String,
pub tools: Vec<McpTool>,
pub tool_handler: ToolCallHandler,
}
pub struct McpServer {
spec: McpServerSpec,
}
impl McpServer {
#[must_use]
pub fn new(spec: McpServerSpec) -> Self {
Self { spec }
}
/// Dispatch one JSON-RPC request, returning the response.
pub async fn dispatch(
&self,
request: JsonRpcRequest<JsonValue>,
) -> JsonRpcResponse<JsonValue> {
let id = request.id.clone();
match request.method.as_str() {
"initialize" => self.handle_initialize(id),
"tools/list" => self.handle_tools_list(id),
"tools/call" => self.handle_tools_call(id, request.params).await,
other => error_response(id, -32601, &format!("method not found: {other}")),
}
}
/// Read frames from `reader`, dispatch, write responses to `writer`.
pub async fn run<R, W>(&self, reader: R, writer: W) -> io::Result<()>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let mut reader = BufReader::new(reader);
let mut writer = writer;
loop {
let Some(payload) = framing::read_frame(&mut reader).await? else {
return Ok(());
};
let value: JsonValue = match serde_json::from_slice(&payload) {
Ok(value) => value,
Err(error) => {
let response = JsonRpcResponse::<JsonValue> {
jsonrpc: "2.0".to_string(),
id: JsonRpcId::Null,
result: None,
error: Some(JsonRpcError {
code: -32700,
message: format!("parse error: {error}"),
data: None,
}),
};
let body = serde_json::to_vec(&response)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
framing::write_frame(&mut writer, &body).await?;
continue;
}
};
if value.get("id").is_none() {
// Notification: no reply.
continue;
}
let request: JsonRpcRequest<JsonValue> = match serde_json::from_value(value) {
Ok(request) => request,
Err(error) => {
let response = JsonRpcResponse::<JsonValue> {
jsonrpc: "2.0".to_string(),
id: JsonRpcId::Null,
result: None,
error: Some(JsonRpcError {
code: -32600,
message: format!("invalid request: {error}"),
data: None,
}),
};
let body = serde_json::to_vec(&response)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
framing::write_frame(&mut writer, &body).await?;
continue;
}
};
let response = self.dispatch(request).await;
let body = serde_json::to_vec(&response)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
framing::write_frame(&mut writer, &body).await?;
}
}
fn handle_initialize(&self, id: JsonRpcId) -> JsonRpcResponse<JsonValue> {
let result = McpInitializeResult {
protocol_version: PROTOCOL_VERSION.to_string(),
capabilities: json!({ "tools": {} }),
server_info: McpServerInfo {
name: self.spec.server_name.clone(),
version: self.spec.server_version.clone(),
},
};
JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: serde_json::to_value(result).ok(),
error: None,
}
}
fn handle_tools_list(&self, id: JsonRpcId) -> JsonRpcResponse<JsonValue> {
let result = McpListToolsResult {
tools: self.spec.tools.clone(),
next_cursor: None,
};
JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: serde_json::to_value(result).ok(),
error: None,
}
}
async fn handle_tools_call(
&self,
id: JsonRpcId,
params: Option<JsonValue>,
) -> JsonRpcResponse<JsonValue> {
let Some(params) = params else {
return invalid_params_response(id, "missing params for tools/call");
};
let call: McpToolCallParams = match serde_json::from_value(params) {
Ok(value) => value,
Err(error) => {
return invalid_params_response(id, &format!("invalid tools/call params: {error}"));
}
};
let arguments = call.arguments.unwrap_or_else(|| json!({}));
let tool_result = (self.spec.tool_handler)(&call.name, &arguments).await;
let (text, is_error) = match tool_result {
Ok(text) => (text, false),
Err(message) => (message, true),
};
let call_result = McpToolCallResult {
content: vec![McpToolCallContent::Text { text }],
structured_content: None,
is_error: Some(is_error),
meta: None,
};
JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: serde_json::to_value(call_result).ok(),
error: None,
}
}
}
fn invalid_params_response(id: JsonRpcId, message: &str) -> JsonRpcResponse<JsonValue> {
error_response(id, -32602, message)
}
fn error_response(id: JsonRpcId, code: i32, message: &str) -> JsonRpcResponse<JsonValue> {
JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: None,
error: Some(JsonRpcError {
code,
message: message.to_string(),
data: None,
}),
}
}
#[allow(dead_code)]
fn _assert_async_read<B: AsyncBufRead>() {}
- Step 4: Run tests to verify they pass
Run: cmd /c "C:\Users\Incredible\.config\opencode\CompilePreSet.bat && cargo test --release -p claw-rag-mcp --lib server::tests"
Expected: PASS (6 tests).
- Step 5: Commit
git add src/server.rs
git commit -m "feat: minimal MCP stdio server dispatch and run loop"
Task 5: tools module — query, stats, and formatting
Files:
- Modify:
D:\tempo\claw-rag-mcp\src\tools.rs
Interfaces:
-
Consumes:
protocol::McpTool; claw-rag-serviceopen_db,chunk_count,query_index,QueryRequest,QueryResponse,RagHit,EmbedConfig. -
Produces:
pub struct AppState { pub db_path: PathBuf, pub client: reqwest::Client, pub cfg: EmbedConfig, pub jobs: Arc<Mutex<HashMap<String, JobState>>>, pub ingest_lock: Arc<tokio::sync::Mutex<()>>, pub next_job_id: AtomicU64 };impl AppState { pub fn from_env() -> Result<Self, String> };pub fn rag_tools() -> Vec<McpTool>;pub fn handle_tool(state: Arc<AppState>, name: &str, args: JsonValue) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>>;pub fn build_server(state: Arc<AppState>) -> McpServer;fn format_query_result(&QueryResponse) -> String;fn format_job_status(job_id: &str, Option<&JobStatus>) -> String. -
Step 1: Write the failing tests
Append to tools.rs:
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn format_query_result_renders_hits() {
let resp = QueryResponse {
hits: vec![RagHit {
path: "repo-ab:src/main.rs".to_string(),
snippet: "line one\nline two".to_string(),
score: Some(0.912_345_6),
}],
phase: "1-sqlite",
};
let s = format_query_result(&resp);
assert!(s.contains("phase: 1-sqlite"));
assert!(s.contains("score=0.9123"));
assert!(s.contains("repo-ab:src/main.rs"));
assert!(s.contains("line one"));
}
#[test]
fn format_query_result_empty_reports_no_hits() {
let resp = QueryResponse {
hits: Vec::new(),
phase: "1-sqlite-no-db",
};
let s = format_query_result(&resp);
assert!(s.contains("phase: 1-sqlite-no-db"));
assert!(s.contains("(no hits)"));
}
#[test]
fn format_job_status_covers_all_states() {
let running = format_job_status(
"1",
Some(&JobStatus::Running {
files_done: 3,
files_total: 10,
chunks_total: 12,
}),
);
assert!(running.contains("status: running"));
assert!(running.contains("3/10"));
assert!(running.contains("chunks_total: 12"));
let done = format_job_status(
"2",
Some(&JobStatus::Done {
files_indexed: 5,
chunks_total: 40,
embeddings_written: 40,
}),
);
assert!(done.contains("status: done"));
assert!(done.contains("files_indexed: 5"));
let failed = format_job_status("3", Some(&JobStatus::Failed("disk full".to_string())));
assert!(failed.contains("status: failed"));
assert!(failed.contains("disk full"));
let unknown = format_job_status("99", None);
assert!(unknown.contains("status: unknown"));
}
#[tokio::test]
async fn rag_tools_list_has_four_tools() {
let tools = rag_tools();
let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["rag_query", "rag_stats", "rag_ingest", "rag_ingest_status"]);
}
#[tokio::test]
async fn handle_tool_unknown_tool_errors() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(AppState {
db_path: dir.path().join("idx.sqlite"),
client: reqwest::Client::new(),
cfg: EmbedConfig {
api_key: "mock".into(),
base_url: "mock://".into(),
model: "mock-embedding".into(),
},
jobs: Arc::new(Mutex::new(HashMap::new())),
ingest_lock: Arc::new(tokio::sync::Mutex::new(())),
next_job_id: AtomicU64::new(0),
});
let out = handle_tool(state, "nope", json!({})).await;
assert!(out.is_err());
assert!(out.unwrap_err().contains("unknown tool"));
}
}
- Step 2: Run tests to verify they fail
Run: cmd /c "C:\Users\Incredible\.config\opencode\CompilePreSet.bat && cargo test --release -p claw-rag-mcp --lib tools::tests"
Expected: FAIL — types/functions undefined.
- Step 3: Implement tools.rs (query/stats/schemas; ingest stays stub for Task 6)
Replace the content of tools.rs with:
//! RAG tool handlers and the async ingest job registry.
use std::collections::HashMap;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use claw_rag_service::{
chunk_count, open_db, query_index, EmbedConfig, QueryRequest, QueryResponse, RagHit,
};
use serde_json::{json, Value as JsonValue};
use tokio::sync::Mutex as AsyncMutex;
use crate::protocol::McpTool;
use crate::server::{McpServer, McpServerSpec};
const DB_ENV: &str = "CLAW_RAG_DB";
const DEFAULT_DB: &str = ".claw-rag/index.sqlite";
const TOP_K_MAX: u32 = 32;
#[derive(Debug, Clone)]
pub enum JobStatus {
Running {
files_done: usize,
files_total: usize,
chunks_total: usize,
},
Done {
files_indexed: usize,
chunks_total: usize,
embeddings_written: usize,
},
Failed(String),
}
#[derive(Debug, Clone)]
pub struct JobState {
pub status: JobStatus,
}
pub struct AppState {
pub db_path: PathBuf,
pub client: reqwest::Client,
pub cfg: EmbedConfig,
pub jobs: Arc<Mutex<HashMap<String, JobState>>>,
pub ingest_lock: Arc<AsyncMutex<()>>,
pub next_job_id: AtomicU64,
}
impl AppState {
pub fn from_env() -> Result<Self, String> {
let cfg = if let Some(c) = EmbedConfig::mock_from_env() {
c
} else {
EmbedConfig::from_env()?
};
let db_path = std::env::var(DB_ENV).unwrap_or_else(|_| DEFAULT_DB.to_string());
Ok(Self {
db_path: PathBuf::from(db_path),
client: reqwest::Client::new(),
cfg,
jobs: Arc::new(Mutex::new(HashMap::new())),
ingest_lock: Arc::new(AsyncMutex::new(())),
next_job_id: AtomicU64::new(0),
})
}
}
pub fn rag_tools() -> Vec<McpTool> {
vec![
McpTool {
name: "rag_query".to_string(),
description: Some(
"Semantic search over the workspace RAG index. Returns ranked file paths and snippets with scores."
.to_string(),
),
input_schema: Some(json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Natural-language query" },
"top_k": { "type": "integer", "description": "Max hits (default 8, capped at 32)" }
},
"required": ["query"]
})),
annotations: None,
meta: None,
},
McpTool {
name: "rag_stats".to_string(),
description: Some(
"Report indexed chunk count and index phase (no embedding call).".to_string(),
),
input_schema: Some(json!({
"type": "object",
"properties": {}
})),
annotations: None,
meta: None,
},
McpTool {
name: "rag_ingest".to_string(),
description: Some(
"Index workspaces into the shared SQLite index asynchronously. Returns a job_id; poll rag_ingest_status for progress."
.to_string(),
),
input_schema: Some(json!({
"type": "object",
"properties": {
"workspaces": {
"type": "array",
"items": { "type": "string" },
"description": "Absolute workspace paths to index"
}
},
"required": ["workspaces"]
})),
annotations: None,
meta: None,
},
McpTool {
name: "rag_ingest_status".to_string(),
description: Some(
"Poll the status of an ingest job started by rag_ingest.".to_string(),
),
input_schema: Some(json!({
"type": "object",
"properties": {
"job_id": { "type": "string", "description": "Job id returned by rag_ingest" }
},
"required": ["job_id"]
})),
annotations: None,
meta: None,
},
]
}
pub fn build_server(state: Arc<AppState>) -> McpServer {
let spec = McpServerSpec {
server_name: "claw-rag".to_string(),
server_version: env!("CARGO_PKG_VERSION").to_string(),
tools: rag_tools(),
tool_handler: Box::new(move |name, args| handle_tool(state.clone(), name, args.clone())),
};
McpServer::new(spec)
}
pub fn handle_tool(
state: Arc<AppState>,
name: &str,
args: JsonValue,
) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> {
Box::pin(async move {
match name {
"rag_query" => rag_query(&state, &args).await,
"rag_stats" => rag_stats(&state, &args).await,
"rag_ingest" => rag_ingest(&state, &args),
"rag_ingest_status" => rag_ingest_status(&state, &args),
other => Err(format!("unknown tool: {other}")),
}
})
}
fn format_query_result(r: &QueryResponse) -> String {
let mut out = format!("phase: {}\n", r.phase);
if r.hits.is_empty() {
out.push_str("(no hits)\n");
return out;
}
for (i, h) in r.hits.iter().enumerate() {
let mut header = format!("{}. ", i + 1);
if let Some(s) = h.score {
header.push_str(&format!("score={s:.4} "));
}
header.push_str(&format!("path={}\n", h.path));
out.push_str(&header);
for line in h.snippet.lines().take(32) {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
if h.snippet.lines().count() > 32 {
out.push_str(" …\n");
}
out.push('\n');
}
out
}
fn format_job_status(job_id: &str, status: Option<&JobStatus>) -> String {
let Some(status) = status else {
return format!("status: unknown\njob_id: {job_id}");
};
match status {
JobStatus::Running {
files_done,
files_total,
chunks_total,
} => format!(
"status: running\nfiles_done: {files_done}/{files_total}\nchunks_total: {chunks_total}"
),
JobStatus::Done {
files_indexed,
chunks_total,
embeddings_written,
} => format!(
"status: done\nfiles_indexed: {files_indexed}\nchunks_total: {chunks_total}\nembeddings_written: {embeddings_written}"
),
JobStatus::Failed(e) => format!("status: failed\nerror: {e}"),
}
}
async fn rag_query(state: &AppState, args: &JsonValue) -> Result<String, String> {
let q = args
.get("query")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| "rag_query: missing or empty query".to_string())?;
let top_k = args
.get("top_k")
.and_then(JsonValue::as_u64)
.map(|n| n as u32)
.unwrap_or(8)
.clamp(1, TOP_K_MAX);
let req = QueryRequest {
query: q.to_string(),
top_k,
};
let resp = query_index(&state.db_path, &state.client, &state.cfg, &req)
.await
.map_err(|e| format!("rag_query: {e}"))?;
Ok(format_query_result(&resp))
}
async fn rag_stats(state: &AppState, _args: &JsonValue) -> Result<String, String> {
let db = state.db_path.clone();
if !db.is_file() {
return Ok("chunks: 0\nphase: 1-sqlite-no-db".to_string());
}
let n = tokio::task::spawn_blocking(move || {
let conn = open_db(&db).map_err(|e| e.to_string())?;
chunk_count(&conn).map_err(|e| e.to_string())
})
.await
.map_err(|e| format!("rag_stats: join: {e}"))?
.map_err(|e| format!("rag_stats: {e}"))?;
let phase = if n == 0 { "1-sqlite-empty" } else { "1-sqlite" };
Ok(format!("chunks: {n}\nphase: {phase}"))
}
fn rag_ingest(state: &Arc<AppState>, args: &JsonValue) -> Result<String, String> {
// Implemented in Task 6.
let _ = (state, args);
Err("rag_ingest: not yet implemented".to_string())
}
fn rag_ingest_status(state: &AppState, args: &JsonValue) -> Result<String, String> {
let jid = args
.get("job_id")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| "rag_ingest_status: missing job_id".to_string())?;
let jobs = state
.jobs
.lock()
.map_err(|_| "rag_ingest_status: registry poisoned".to_string())?;
let status = jobs.get(jid).map(|j| &j.status);
Ok(format_job_status(jid, status))
}
Note: rag_ingest is a stub here; Task 6 replaces it. rag_ingest_status already reads the (currently always-empty) registry, which is fine for these tests.
- Step 4: Run tests to verify they pass
Run: cmd /c "C:\Users\Incredible\.config\opencode\CompilePreSet.bat && cargo test --release -p claw-rag-mcp --lib tools::tests"
Expected: PASS (5 tests). The rag_ingest stub returns an error — no test calls it yet.
- Step 5: Commit
git add src/tools.rs
git commit -m "feat: rag_query, rag_stats tool handlers and formatting"
Task 6: tools module — async ingest job
Files:
- Modify:
D:\tempo\claw-rag-mcp\src\tools.rs
Interfaces:
-
Consumes:
AppState,JobStatus,JobState,handle_tooldispatch from Task 5; claw-rag-servicerun_ingest_with_progress,IngestProgressfrom Task 1. -
Produces: working
rag_ingest(spawns background task, returnsjob_id: N) and workingrag_ingest_status(progress). -
Step 1: Write the failing test
Append to the tests module in tools.rs:
#[tokio::test]
async fn ingest_job_lifecycle_with_mock_embeddings() {
std::env::set_var("CLAW_RAG_MOCK_PROVIDERS", "1");
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().join("ws");
std::fs::create_dir_all(&ws).unwrap();
std::fs::write(ws.join("note.md"), "hello RAG service mock content").unwrap();
let state = Arc::new(AppState {
db_path: dir.path().join("idx.sqlite"),
client: reqwest::Client::new(),
cfg: EmbedConfig::mock_from_env().expect("mock embed config"),
jobs: Arc::new(Mutex::new(HashMap::new())),
ingest_lock: Arc::new(AsyncMutex::new(())),
next_job_id: AtomicU64::new(0),
});
let out = handle_tool(
state.clone(),
"rag_ingest",
json!({"workspaces": [ws.to_string_lossy().to_string()]}),
)
.await
.expect("rag_ingest returns job_id");
assert!(out.starts_with("job_id: "), "unexpected: {out}");
let job_id = out.trim().strip_prefix("job_id: ").unwrap().to_string();
let mut done = false;
for _ in 0..100 {
let s = handle_tool(
state.clone(),
"rag_ingest_status",
json!({"job_id": job_id}),
)
.await
.expect("status call");
if s.contains("status: done") {
done = true;
assert!(s.contains("files_indexed: 1"), "stats: {s}");
break;
}
if s.contains("status: failed") {
panic!("ingest failed: {s}");
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
assert!(done, "ingest never finished");
let q = handle_tool(state.clone(), "rag_query", json!({"query": "RAG service"}))
.await
.expect("query");
assert!(q.contains("phase: 1-sqlite"), "query: {q}");
assert!(q.contains("note.md"), "query: {q}");
let st = handle_tool(state.clone(), "rag_stats", json!({}))
.await
.expect("stats");
assert!(st.contains("chunks: "), "stats: {st}");
let unk = handle_tool(
state.clone(),
"rag_ingest_status",
json!({"job_id": "999"}),
)
.await
.expect("unknown job");
assert!(unk.contains("status: unknown"));
std::env::remove_var("CLAW_RAG_MOCK_PROVIDERS");
}
- Step 2: Run test to verify it fails
Run: cmd /c "C:\Users\Incredible\.config\opencode\CompilePreSet.bat && cargo test --release -p claw-rag-mcp --lib tools::tests::ingest_job_lifecycle_with_mock_embeddings"
Expected: FAIL — rag_ingest returns "not yet implemented".
- Step 3: Implement rag_ingest
Replace the rag_ingest stub in tools.rs with:
fn rag_ingest(state: &Arc<AppState>, args: &JsonValue) -> Result<String, String> {
let ws = args
.get("workspaces")
.and_then(JsonValue::as_array)
.ok_or_else(|| "rag_ingest: missing workspaces array".to_string())?;
let mut paths: Vec<PathBuf> = Vec::new();
for p in ws {
let s = p
.as_str()
.ok_or_else(|| "rag_ingest: workspaces entries must be strings".to_string())?;
paths.push(PathBuf::from(s));
}
if paths.is_empty() {
return Err("rag_ingest: workspaces is empty".to_string());
}
let job_id = state
.next_job_id
.fetch_add(1, Ordering::SeqCst)
.to_string();
{
let mut jobs = state
.jobs
.lock()
.map_err(|_| "rag_ingest: registry poisoned".to_string())?;
jobs.insert(
job_id.clone(),
JobState {
status: JobStatus::Running {
files_done: 0,
files_total: 0,
chunks_total: 0,
},
},
);
}
let db = state.db_path.clone();
let cfg = state.cfg.clone();
let client = state.client.clone();
let jobs = state.jobs.clone();
let lock = state.ingest_lock.clone();
let jid = job_id.clone();
tokio::spawn(async move {
let _guard = lock.lock().await;
let result = run_ingest_with_progress(&paths, &db, &cfg, &client, |p| {
if let Ok(mut jobs) = jobs.lock() {
if let Some(j) = jobs.get_mut(&jid) {
j.status = JobStatus::Running {
files_done: p.files_done,
files_total: p.files_total,
chunks_total: p.chunks_total,
};
}
}
})
.await;
let status = match result {
Ok(s) => JobStatus::Done {
files_indexed: s.files_indexed,
chunks_total: s.chunks_total,
embeddings_written: s.embeddings_written,
},
Err(e) => JobStatus::Failed(e),
};
if let Ok(mut jobs) = jobs.lock() {
if let Some(j) = jobs.get_mut(&jid) {
j.status = status;
}
}
});
Ok(format!("job_id: {job_id}"))
}
Add the missing import at the top of tools.rs (change the claw-rag-service use line):
use claw_rag_service::{
chunk_count, open_db, query_index, run_ingest_with_progress, EmbedConfig, QueryRequest,
QueryResponse, RagHit,
};
- Step 4: Wire up main.rs and run all tests
Run: cmd /c "C:\Users\Incredible\.config\opencode\CompilePreSet.bat && cargo test --release"
Expected: PASS — all unit tests plus ingest_job_lifecycle_with_mock_embeddings. This is the first full crate build including main.rs (build_server/AppState::from_env/McpServer::run now all exist).
- Step 5: Commit
git add src/tools.rs src/main.rs
git commit -m "feat: async ingest jobs with progress tracking"
Task 7: full-flow integration test over the wire
Files:
- Create:
D:\tempo\claw-rag-mcp\tests\full_flow.rs
Interfaces:
-
Consumes:
build_server,AppState,framing(via server run over duplex), claw-rag-service mock embeddings. -
Produces: end-to-end proof that an external MCP host can
initialize→tools/list→rag_ingest→ poll →rag_query/rag_statsover the real wire protocol. -
Step 1: Write the failing test
Create D:\tempo\claw-rag-mcp\tests\full_flow.rs:
use std::sync::Arc;
use claw_rag_mcp::tools::{build_server, AppState};
use serde_json::{json, Value as JsonValue};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader, DuplexStream};
async fn request(client: &mut DuplexStream, payload: &JsonValue) -> JsonValue {
let body = serde_json::to_vec(payload).expect("serialize");
let header = format!("Content-Length: {}\r\n\r\n", body.len());
client.write_all(header.as_bytes()).await.expect("write header");
client.write_all(&body).await.expect("write body");
let mut line = String::new();
let mut reader = BufReader::new(&mut *client);
reader.read_line(&mut line).await.expect("read len");
let cl: usize = line.trim().split(':').nth(1).unwrap().trim().parse().expect("len");
line.clear();
reader.read_line(&mut line).await.expect("read blank");
let mut payload = vec![0_u8; cl];
reader.read_exact(&mut payload).await.expect("read body");
serde_json::from_slice(&payload).expect("json response")
}
#[tokio::test]
async fn end_to_end_mcp_session() {
std::env::set_var("CLAW_RAG_MOCK_PROVIDERS", "1");
let dir = tempfile::tempdir().expect("tempdir");
let ws = dir.path().join("ws");
std::fs::create_dir_all(&ws).expect("mkdir ws");
std::fs::write(ws.join("note.md"), "hello RAG service mock content").expect("write note");
let state = Arc::new(AppState {
db_path: dir.path().join("idx.sqlite"),
client: reqwest::Client::new(),
cfg: claw_rag_service::EmbedConfig::mock_from_env().expect("mock embed config"),
jobs: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
ingest_lock: Arc::new(tokio::sync::Mutex::new(())),
next_job_id: Default::default(),
});
let server = build_server(state);
let (client, srv) = tokio::io::duplex(1 << 20);
let server_task = tokio::spawn(server.run(srv.clone(), srv));
let mut c = client;
let init = request(&mut c, &json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}})).await;
assert_eq!(init["result"]["serverInfo"]["name"], "claw-rag");
assert_eq!(init["result"]["protocolVersion"], "2025-03-26");
let listed = request(&mut c, &json!({"jsonrpc":"2.0","id":2,"method":"tools/list"})).await;
let names: Vec<&str> = listed["result"]["tools"]
.as_array()
.expect("tools array")
.iter()
.filter_map(|t| t["name"].as_str())
.collect();
assert_eq!(names, vec!["rag_query", "rag_stats", "rag_ingest", "rag_ingest_status"]);
let ingest = request(
&mut c,
&json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"rag_ingest","arguments":{"workspaces":[ws.to_string_lossy().to_string()]}}}),
)
.await;
assert_eq!(ingest["result"]["isError"], false);
let job_id = ingest["result"]["content"][0]["text"]
.as_str()
.expect("job text")
.trim()
.strip_prefix("job_id: ")
.expect("job prefix")
.to_string();
let mut done = false;
for i in 0..100 {
let status = request(
&mut c,
&json!({"jsonrpc":"2.0","id":4+i,"method":"tools/call","params":{"name":"rag_ingest_status","arguments":{"job_id":job_id}}}),
)
.await;
let text = status["result"]["content"][0]["text"].as_str().expect("status text");
if text.contains("status: done") {
done = true;
break;
}
if text.contains("status: failed") {
panic!("ingest failed: {text}");
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
assert!(done, "ingest never finished over the wire");
let q = request(
&mut c,
&json!({"jsonrpc":"2.0","id":200,"method":"tools/call","params":{"name":"rag_query","arguments":{"query":"RAG service"}}}),
)
.await;
let qtext = q["result"]["content"][0]["text"].as_str().expect("query text");
assert!(qtext.contains("phase: 1-sqlite"), "query: {qtext}");
assert!(qtext.contains("note.md"), "query: {qtext}");
let stats = request(
&mut c,
&json!({"jsonrpc":"2.0","id":201,"method":"tools/call","params":{"name":"rag_stats","arguments":{}}}),
)
.await;
assert!(stats["result"]["content"][0]["text"]
.as_str()
.expect("stats text")
.contains("chunks: "));
drop(c);
let _ = server_task.await;
std::env::remove_var("CLAW_RAG_MOCK_PROVIDERS");
}
- Step 2: Run test to verify it fails (if tools weren't working) / passes
Run: cmd /c "C:\Users\Incredible\.config\opencode\CompilePreSet.bat && cargo test --release --test full_flow"
Expected: PASS.
- Step 3: Verify the release binary builds and print help
Run:
cmd /c "C:\Users\Incredible\.config\opencode\CompilePreSet.bat && cargo build --release"
cmd /c "C:\Users\Incredible\.config\opencode\CompilePreSet.bat && cargo clippy --release --all-targets -- -D warnings"
Expected: build succeeds; clippy clean with -D warnings.
- Step 4: Commit
git add tests/full_flow.rs
git commit -m "test: end-to-end MCP session over duplex stdio"
Task 8: README and final verification
Files:
- Create:
D:\tempo\claw-rag-mcp\README.md
Interfaces:
-
Consumes: everything above.
-
Produces: installation/usage documentation.
-
Step 1: Write README.md
Create D:\tempo\claw-rag-mcp\README.md:
# claw-rag-mcp
Standalone MCP (Model Context Protocol) server exposing RAG capabilities over a
SQLite index shared with `claw-rag-service`.
## Install
```bash
cd D:\tempo\claw-rag-mcp
cargo build --release
Copy target\release\claw-rag-mcp.exe to a directory on your PATH
(e.g. C:\Users\<you>\bin).
Configure an MCP host
Point any MCP client at the binary via command. Example for opencode:
{
"mcpServers": {
"claw-rag": {
"command": "claw-rag-mcp",
"env": {
"CLAW_RAG_DB": "D:/data/rag/.claw-rag/index.sqlite",
"OPENAI_API_KEY": "sk-..."
}
}
}
}
Environment variables
| Variable | Default | Purpose |
|---|---|---|
CLAW_RAG_DB |
.claw-rag/index.sqlite |
Shared SQLite index path |
CLAW_RAG_OPENAI_API_KEY / OPENAI_API_KEY |
— | Embedding API key (required) |
CLAW_RAG_EMBEDDING_BASE_URL |
https://api.openai.com/v1 |
OpenAI-compatible embeddings endpoint |
CLAW_RAG_EMBEDDING_MODEL |
text-embedding-3-small |
Embedding model |
CLAW_RAG_MOCK_PROVIDERS |
— | 1 = deterministic mock embeddings (testing) |
Tools
rag_query {query, top_k?}— semantic search over the index.top_kdefault 8, max 32.rag_stats {}— chunk count and index phase (1-sqlite-no-db/1-sqlite-empty/1-sqlite).rag_ingest {workspaces: [...]}— asynchronously index workspaces; returns ajob_id.rag_ingest_status {job_id}— poll ingest progress (running/done/failed/unknown).
Notes
- Ingest jobs live in process memory; restarting the server loses them. Re-run
rag_ingestafter a restart. - The index is shared with the
claw-rag-serviceHTTP server when both use the sameCLAW_RAG_DB. Only one process should ingest at a time (ingest jobs are serialized within this server).
- [ ] **Step 2: Final verification**
Run:
```bash
cmd /c "C:\Users\Incredible\.config\opencode\CompilePreSet.bat && cargo fmt -- --check"
cmd /c "C:\Users\Incredible\.config\opencode\CompilePreSet.bat && cargo clippy --release --all-targets -- -D warnings"
cmd /c "C:\Users\Incredible\.config\opencode\CompilePreSet.bat && cargo test --release"
Expected: fmt clean, clippy clean, all tests PASS.
- Step 3: Smoke-test the binary over real pipes
In PowerShell (uses a temp DB, mock embeddings):
$env:CLAW_RAG_MOCK_PROVIDERS = "1"
$db = Join-Path $env:TEMP "claw-rag-mcp-smoke.sqlite"
Remove-Item $db -ErrorAction SilentlyContinue
$env:CLAW_RAG_DB = $db
$body = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
$frame = "Content-Length: $($body.Length)`r`n`r`n$body"
$frame | & .\target\release\claw-rag-mcp.exe
Remove-Item Env:\CLAW_RAG_MOCK_PROVIDERS
Remove-Item Env:\CLAW_RAG_DB
Expected: prints a Content-Length-framed initialize response with "serverInfo":{"name":"claw-rag"} and exits when stdin closes.
- Step 4: Commit
git add README.md
git commit -m "docs: installation and usage for claw-rag-mcp"