Skip to content

Commit d0450c6

Browse files
devwhodevsclaude
andcommitted
feat: MCP server — 7 vault intelligence tools via stdio
EngraphServer with search, read, list, vault_map, who, project, context tools. Arc+Mutex wrapping for rmcp Clone requirement. tokio::sync::Mutex for Store and Embedder. All tools return JSON. Server loads all resources at startup, serves until EOF. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 83f01f3 commit d0450c6

1 file changed

Lines changed: 276 additions & 0 deletions

File tree

src/serve.rs

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
use std::path::{Path, PathBuf};
2+
use std::sync::Arc;
3+
4+
use anyhow::Result;
5+
use rmcp::handler::server::tool::ToolRouter;
6+
use rmcp::handler::server::wrapper::Parameters;
7+
use rmcp::model::{CallToolResult, Content, ServerCapabilities, ServerInfo};
8+
use rmcp::schemars;
9+
use rmcp::schemars::JsonSchema;
10+
use rmcp::{ErrorData as McpError, ServiceExt, tool, tool_handler, tool_router};
11+
use serde::Deserialize;
12+
use tokio::sync::Mutex;
13+
14+
use crate::config::Config;
15+
use crate::context::{self, ContextParams};
16+
use crate::embedder::Embedder;
17+
use crate::hnsw::HnswIndex;
18+
use crate::profile::VaultProfile;
19+
use crate::search;
20+
use crate::store::Store;
21+
22+
// ---------------------------------------------------------------------------
23+
// Parameter structs
24+
// ---------------------------------------------------------------------------
25+
26+
#[derive(Debug, Deserialize, JsonSchema)]
27+
pub struct SearchParams {
28+
/// The search query.
29+
pub query: String,
30+
/// Number of results (default 10).
31+
pub top_n: Option<usize>,
32+
}
33+
34+
#[derive(Debug, Deserialize, JsonSchema)]
35+
pub struct ReadParams {
36+
/// File path, basename, or #docid.
37+
pub file: String,
38+
}
39+
40+
#[derive(Debug, Deserialize, JsonSchema)]
41+
pub struct ListParams {
42+
/// Filter to folder path prefix.
43+
pub folder: Option<String>,
44+
/// Filter to notes with all listed tags.
45+
pub tags: Option<Vec<String>>,
46+
/// Maximum results (default 20).
47+
pub limit: Option<usize>,
48+
}
49+
50+
#[derive(Debug, Deserialize, JsonSchema)]
51+
pub struct WhoParams {
52+
/// Person name (matches filename in People folder).
53+
pub name: String,
54+
}
55+
56+
#[derive(Debug, Deserialize, JsonSchema)]
57+
pub struct ProjectParams {
58+
/// Project name (matches filename).
59+
pub name: String,
60+
}
61+
62+
#[derive(Debug, Deserialize, JsonSchema)]
63+
pub struct ContextToolParams {
64+
/// Search query for the topic.
65+
pub topic: String,
66+
/// Character budget (default 32000).
67+
pub budget: Option<usize>,
68+
}
69+
70+
// ---------------------------------------------------------------------------
71+
// Server
72+
// ---------------------------------------------------------------------------
73+
74+
#[derive(Clone)]
75+
pub struct EngraphServer {
76+
store: Arc<Mutex<Store>>,
77+
embedder: Arc<Mutex<Embedder>>,
78+
hnsw_index: Arc<HnswIndex>,
79+
vault_path: Arc<PathBuf>,
80+
profile: Arc<Option<VaultProfile>>,
81+
tool_router: ToolRouter<Self>,
82+
}
83+
84+
fn mcp_err(e: &anyhow::Error) -> McpError {
85+
McpError::new(
86+
rmcp::model::ErrorCode::INTERNAL_ERROR,
87+
format!("{e:#}"),
88+
None::<serde_json::Value>,
89+
)
90+
}
91+
92+
fn to_json_result<T: serde::Serialize>(value: &T) -> Result<CallToolResult, McpError> {
93+
let json = serde_json::to_string_pretty(value).map_err(|e| {
94+
McpError::new(
95+
rmcp::model::ErrorCode::INTERNAL_ERROR,
96+
e.to_string(),
97+
None::<serde_json::Value>,
98+
)
99+
})?;
100+
Ok(CallToolResult::success(vec![Content::text(json)]))
101+
}
102+
103+
#[tool_router]
104+
impl EngraphServer {
105+
#[tool(
106+
name = "search",
107+
description = "Semantic + keyword hybrid search across the vault. Returns ranked results with file paths, scores, headings, and snippets."
108+
)]
109+
async fn search(&self, params: Parameters<SearchParams>) -> Result<CallToolResult, McpError> {
110+
let top_n = params.0.top_n.unwrap_or(10);
111+
let store = self.store.lock().await;
112+
let mut embedder = self.embedder.lock().await;
113+
let output = search::search_internal(
114+
&params.0.query,
115+
top_n,
116+
&store,
117+
&mut embedder,
118+
&self.hnsw_index,
119+
)
120+
.map_err(|e| mcp_err(&e))?;
121+
to_json_result(&output.results)
122+
}
123+
124+
#[tool(
125+
name = "read",
126+
description = "Read a note's full content with metadata, tags, and graph edges. Accepts file path, basename, or #docid."
127+
)]
128+
async fn read(&self, params: Parameters<ReadParams>) -> Result<CallToolResult, McpError> {
129+
let store = self.store.lock().await;
130+
let ctx = ContextParams {
131+
store: &store,
132+
vault_path: &self.vault_path,
133+
profile: self.profile.as_ref().as_ref(),
134+
};
135+
let note = context::context_read(&ctx, &params.0.file).map_err(|e| mcp_err(&e))?;
136+
to_json_result(&note)
137+
}
138+
139+
#[tool(
140+
name = "list",
141+
description = "List notes filtered by folder prefix and/or tags. Returns paths, docids, tags, and edge counts."
142+
)]
143+
async fn list(&self, params: Parameters<ListParams>) -> Result<CallToolResult, McpError> {
144+
let store = self.store.lock().await;
145+
let ctx = ContextParams {
146+
store: &store,
147+
vault_path: &self.vault_path,
148+
profile: self.profile.as_ref().as_ref(),
149+
};
150+
let tags = params.0.tags.unwrap_or_default();
151+
let limit = params.0.limit.unwrap_or(20);
152+
let items = context::context_list(&ctx, params.0.folder.as_deref(), &tags, limit)
153+
.map_err(|e| mcp_err(&e))?;
154+
to_json_result(&items)
155+
}
156+
157+
#[tool(
158+
name = "vault_map",
159+
description = "Vault structure overview: folders, tags, file counts, recent files. Use to orient before deeper queries."
160+
)]
161+
async fn vault_map(&self) -> Result<CallToolResult, McpError> {
162+
let store = self.store.lock().await;
163+
let ctx = ContextParams {
164+
store: &store,
165+
vault_path: &self.vault_path,
166+
profile: self.profile.as_ref().as_ref(),
167+
};
168+
let map = context::vault_map(&ctx).map_err(|e| mcp_err(&e))?;
169+
to_json_result(&map)
170+
}
171+
172+
#[tool(
173+
name = "who",
174+
description = "Person context bundle: their note, mentions across the vault, and graph connections."
175+
)]
176+
async fn who(&self, params: Parameters<WhoParams>) -> Result<CallToolResult, McpError> {
177+
let store = self.store.lock().await;
178+
let ctx = ContextParams {
179+
store: &store,
180+
vault_path: &self.vault_path,
181+
profile: self.profile.as_ref().as_ref(),
182+
};
183+
let person = context::context_who(&ctx, &params.0.name).map_err(|e| mcp_err(&e))?;
184+
to_json_result(&person)
185+
}
186+
187+
#[tool(
188+
name = "project",
189+
description = "Project context bundle: project note, child notes, active tasks, team members, and recent daily mentions."
190+
)]
191+
async fn project(&self, params: Parameters<ProjectParams>) -> Result<CallToolResult, McpError> {
192+
let store = self.store.lock().await;
193+
let ctx = ContextParams {
194+
store: &store,
195+
vault_path: &self.vault_path,
196+
profile: self.profile.as_ref().as_ref(),
197+
};
198+
let proj = context::context_project(&ctx, &params.0.name).map_err(|e| mcp_err(&e))?;
199+
to_json_result(&proj)
200+
}
201+
202+
#[tool(
203+
name = "context",
204+
description = "Rich topic context with search-driven section selection and character budget trimming. Returns the most relevant note sections for a topic."
205+
)]
206+
async fn context(
207+
&self,
208+
params: Parameters<ContextToolParams>,
209+
) -> Result<CallToolResult, McpError> {
210+
let budget = params.0.budget.unwrap_or(32000);
211+
let store = self.store.lock().await;
212+
let mut embedder = self.embedder.lock().await;
213+
let ctx = ContextParams {
214+
store: &store,
215+
vault_path: &self.vault_path,
216+
profile: self.profile.as_ref().as_ref(),
217+
};
218+
let bundle = context::context_topic_with_search(
219+
&ctx,
220+
&params.0.topic,
221+
budget,
222+
&mut embedder,
223+
&self.hnsw_index,
224+
)
225+
.map_err(|e| mcp_err(&e))?;
226+
to_json_result(&bundle)
227+
}
228+
}
229+
230+
#[tool_handler]
231+
impl rmcp::handler::server::ServerHandler for EngraphServer {
232+
fn get_info(&self) -> ServerInfo {
233+
ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions(
234+
"engraph: vault intelligence for Obsidian. \
235+
Use vault_map to orient, search to find, \
236+
read for full content, who/project for context bundles.",
237+
)
238+
}
239+
}
240+
241+
// ---------------------------------------------------------------------------
242+
// Entry point
243+
// ---------------------------------------------------------------------------
244+
245+
pub async fn run_serve(data_dir: &Path) -> Result<()> {
246+
let db_path = data_dir.join("engraph.db");
247+
let models_dir = data_dir.join("models");
248+
let hnsw_dir = data_dir.join("hnsw");
249+
250+
let store = Store::open(&db_path)?;
251+
let embedder = Embedder::new(&models_dir)?;
252+
let hnsw_index = HnswIndex::load(&hnsw_dir)?;
253+
254+
let vault_path_str = store.get_meta("vault_path")?.ok_or_else(|| {
255+
anyhow::anyhow!("No vault path in index. Run 'engraph index <path>' first.")
256+
})?;
257+
let vault_path = PathBuf::from(&vault_path_str);
258+
259+
let profile = Config::load_vault_profile().ok().flatten();
260+
261+
let server = EngraphServer {
262+
store: Arc::new(Mutex::new(store)),
263+
embedder: Arc::new(Mutex::new(embedder)),
264+
hnsw_index: Arc::new(hnsw_index),
265+
vault_path: Arc::new(vault_path),
266+
profile: Arc::new(profile),
267+
tool_router: EngraphServer::tool_router(),
268+
};
269+
270+
eprintln!("engraph MCP server starting...");
271+
272+
let transport = rmcp::transport::io::stdio();
273+
let server_handle = server.serve(transport).await?;
274+
server_handle.waiting().await?;
275+
Ok(())
276+
}

0 commit comments

Comments
 (0)