|
| 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 | + ¶ms.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, ¶ms.0.file).map_err(|e| mcp_err(&e))?; |
| 136 | + to_json_result(¬e) |
| 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, ¶ms.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, ¶ms.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 | + ¶ms.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