diff --git a/CLAUDE.md b/CLAUDE.md index c20d22d..57a493c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Local hybrid search CLI for Obsidian vaults. Rust, MIT licensed. ## Architecture -Single binary with 13 modules behind a lib crate: +Single binary with 14 modules behind a lib crate: - `config.rs` — loads `~/.engraph/config.toml` and `vault.toml`, merges CLI args, provides `data_dir()` - `chunker.rs` — smart chunking with break-point scoring algorithm. Finds optimal split points considering headings, code fences, blank lines, and thematic breaks. `split_oversized_chunks()` handles token-aware secondary splitting with overlap @@ -14,13 +14,14 @@ Single binary with 13 modules behind a lib crate: - `fts.rs` — FTS5 full-text search support. Re-exports `FtsResult` from store. BM25-ranked keyword search - `fusion.rs` — Reciprocal Rank Fusion (RRF) engine. Merges semantic + FTS5 + graph results. Supports lane weighting, `--explain` output with per-lane detail - `context.rs` — context engine. Six functions: `read` (full note content + metadata), `list` (filtered note listing), `vault_map` (structure overview), `who` (person context bundle), `project` (project context bundle), `context_topic` (rich topic context with budget trimming). Pure functions taking `ContextParams` — no model loading except `context_topic` which reuses `search_internal` +- `serve.rs` — MCP stdio server via rmcp SDK. Exposes 7 read-only tools (search, read, list, vault_map, who, project, context). EngraphServer struct with Arc+Mutex wrapping for async handlers. Loads all resources at startup. - `graph.rs` — vault graph agent. Extracts wikilink targets, expands search results by following graph connections 1-2 hops. Relevance filtering via FTS5 term check and shared tags - `profile.rs` — vault profile detection. Auto-detects PARA/Folders/Flat structure, vault type (Obsidian/Logseq/Plain), wikilinks, frontmatter, tags. Writes/loads `vault.toml` - `store.rs` — SQLite persistence. Tables: `meta`, `files` (with docid), `chunks` (with vector BLOBs), `chunks_fts` (FTS5), `edges` (vault graph), `tombstones`. Handles incremental diffing via content hashes - `hnsw.rs` — thin wrapper around `hnsw_rs`. **Important:** `hnsw_rs` does not support inserting after `load_hnsw()`. The index is rebuilt from vectors stored in SQLite on every index run - `indexer.rs` — orchestrates vault walking (via `ignore` crate for `.gitignore` support), diffing, chunking, embedding (Rayon for parallel chunking, serial embedding since `Embedder` is not `Send`), serial writes to store + HNSW + FTS5, and vault graph edge building (wikilinks + people detection) -`main.rs` is a thin clap CLI. Subcommands: `index`, `search` (with `--explain`), `status`, `clear`, `init`, `configure`, `models`, `graph` (show/stats), `context` (read/list/vault-map/who/project/topic). +`main.rs` is a thin clap CLI (async via `#[tokio::main]`). Subcommands: `index`, `search` (with `--explain`), `status`, `clear`, `init`, `configure`, `models`, `graph` (show/stats), `context` (read/list/vault-map/who/project/topic), `serve` (MCP stdio server). ## Key patterns @@ -50,7 +51,7 @@ Single vault only. Re-indexing a different vault path triggers a confirmation pr ## Testing -- Unit tests in each module (`cargo test --lib`) — 144 tests, no network required +- Unit tests in each module (`cargo test --lib`) — 146 tests, no network required - 1 ignored smoke test (`test_embed_smoke`) — downloads ONNX model, verifies embedding - Integration tests (`cargo test --test integration -- --ignored`) — 8 tests, require model download diff --git a/Cargo.lock b/Cargo.lock index ee36be5..60f0716 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anndists" version = "0.1.4" @@ -134,6 +143,17 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -256,6 +276,20 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.6.0" @@ -425,8 +459,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -443,13 +487,37 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] @@ -488,7 +556,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn", @@ -546,6 +614,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -560,7 +634,7 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "engraph" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "clap", @@ -571,12 +645,14 @@ dependencies = [ "ndarray", "ort", "rayon", + "rmcp", "rusqlite", "serde", "serde_json", "sha2", "tempfile", "tokenizers", + "tokio", "toml", "tracing", "tracing-subscriber", @@ -721,6 +797,94 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -874,6 +1038,30 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -1481,6 +1669,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1667,6 +1861,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" version = "1.12.3" @@ -1710,6 +1924,41 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6b9d2f0efe2258b23767f1f9e0054cfbcac9c2d6f81a031214143096d7864f" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab9d95d7ed26ad8306352b0d5f05b593222b272790564589790d210aa15caa9e" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -1802,6 +2051,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1867,6 +2142,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -1921,6 +2207,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -2115,6 +2407,41 @@ dependencies = [ "unicode_categories", ] +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -2558,12 +2885,65 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index 46ad44a..90defef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "engraph" -version = "0.4.0" +version = "0.5.0" edition = "2024" description = "Local semantic search for Obsidian vaults" license = "MIT" @@ -24,6 +24,8 @@ ndarray = "0.17" hnsw_rs = "0.3" rayon = "1" ignore = "0.4" +rmcp = { version = "1.2", features = ["transport-io"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } [dev-dependencies] tempfile = "3" diff --git a/src/lib.rs b/src/lib.rs index c19dbe6..d48802a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,4 +11,5 @@ pub mod indexer; pub mod model; pub mod profile; pub mod search; +pub mod serve; pub mod store; diff --git a/src/main.rs b/src/main.rs index d5ee073..013e585 100644 --- a/src/main.rs +++ b/src/main.rs @@ -82,6 +82,9 @@ enum Command { action: ModelsAction, }, + /// Start MCP stdio server for AI agent access. + Serve, + /// Inspect vault graph connections. Graph { #[command(subcommand)] @@ -178,7 +181,8 @@ fn remove_dir_if_exists(path: &std::path::Path) -> Result { } } -fn main() -> Result<()> { +#[tokio::main] +async fn main() -> Result<()> { let cli = Cli::parse(); // Set up tracing. Default: suppress all logs (ort and hnsw_rs are very noisy). @@ -704,6 +708,14 @@ fn main() -> Result<()> { } } + Command::Serve => { + if !index_exists(&data_dir) { + eprintln!("No index found. Run 'engraph index ' first."); + std::process::exit(1); + } + engraph::serve::run_serve(&data_dir).await?; + } + Command::Models { action } => { let registry = model::ModelRegistry::default(); match action { diff --git a/src/search.rs b/src/search.rs index 2746869..1ed0b92 100644 --- a/src/search.rs +++ b/src/search.rs @@ -20,7 +20,7 @@ pub struct SearchResult { } /// Structured search result for internal use (no I/O). -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct InternalSearchResult { pub file_path: String, pub file_id: i64, diff --git a/src/serve.rs b/src/serve.rs new file mode 100644 index 0000000..97b57b7 --- /dev/null +++ b/src/serve.rs @@ -0,0 +1,276 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::Result; +use rmcp::handler::server::tool::ToolRouter; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{CallToolResult, Content, ServerCapabilities, ServerInfo}; +use rmcp::schemars; +use rmcp::schemars::JsonSchema; +use rmcp::{ErrorData as McpError, ServiceExt, tool, tool_handler, tool_router}; +use serde::Deserialize; +use tokio::sync::Mutex; + +use crate::config::Config; +use crate::context::{self, ContextParams}; +use crate::embedder::Embedder; +use crate::hnsw::HnswIndex; +use crate::profile::VaultProfile; +use crate::search; +use crate::store::Store; + +// --------------------------------------------------------------------------- +// Parameter structs +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SearchParams { + /// The search query. + pub query: String, + /// Number of results (default 10). + pub top_n: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ReadParams { + /// File path, basename, or #docid. + pub file: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListParams { + /// Filter to folder path prefix. + pub folder: Option, + /// Filter to notes with all listed tags. + pub tags: Option>, + /// Maximum results (default 20). + pub limit: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct WhoParams { + /// Person name (matches filename in People folder). + pub name: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ProjectParams { + /// Project name (matches filename). + pub name: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ContextToolParams { + /// Search query for the topic. + pub topic: String, + /// Character budget (default 32000). + pub budget: Option, +} + +// --------------------------------------------------------------------------- +// Server +// --------------------------------------------------------------------------- + +#[derive(Clone)] +pub struct EngraphServer { + store: Arc>, + embedder: Arc>, + hnsw_index: Arc, + vault_path: Arc, + profile: Arc>, + tool_router: ToolRouter, +} + +fn mcp_err(e: &anyhow::Error) -> McpError { + McpError::new( + rmcp::model::ErrorCode::INTERNAL_ERROR, + format!("{e:#}"), + None::, + ) +} + +fn to_json_result(value: &T) -> Result { + let json = serde_json::to_string_pretty(value).map_err(|e| { + McpError::new( + rmcp::model::ErrorCode::INTERNAL_ERROR, + e.to_string(), + None::, + ) + })?; + Ok(CallToolResult::success(vec![Content::text(json)])) +} + +#[tool_router] +impl EngraphServer { + #[tool( + name = "search", + description = "Semantic + keyword hybrid search across the vault. Returns ranked results with file paths, scores, headings, and snippets." + )] + async fn search(&self, params: Parameters) -> Result { + let top_n = params.0.top_n.unwrap_or(10); + let store = self.store.lock().await; + let mut embedder = self.embedder.lock().await; + let output = search::search_internal( + ¶ms.0.query, + top_n, + &store, + &mut embedder, + &self.hnsw_index, + ) + .map_err(|e| mcp_err(&e))?; + to_json_result(&output.results) + } + + #[tool( + name = "read", + description = "Read a note's full content with metadata, tags, and graph edges. Accepts file path, basename, or #docid." + )] + async fn read(&self, params: Parameters) -> Result { + let store = self.store.lock().await; + let ctx = ContextParams { + store: &store, + vault_path: &self.vault_path, + profile: self.profile.as_ref().as_ref(), + }; + let note = context::context_read(&ctx, ¶ms.0.file).map_err(|e| mcp_err(&e))?; + to_json_result(¬e) + } + + #[tool( + name = "list", + description = "List notes filtered by folder prefix and/or tags. Returns paths, docids, tags, and edge counts." + )] + async fn list(&self, params: Parameters) -> Result { + let store = self.store.lock().await; + let ctx = ContextParams { + store: &store, + vault_path: &self.vault_path, + profile: self.profile.as_ref().as_ref(), + }; + let tags = params.0.tags.unwrap_or_default(); + let limit = params.0.limit.unwrap_or(20); + let items = context::context_list(&ctx, params.0.folder.as_deref(), &tags, limit) + .map_err(|e| mcp_err(&e))?; + to_json_result(&items) + } + + #[tool( + name = "vault_map", + description = "Vault structure overview: folders, tags, file counts, recent files. Use to orient before deeper queries." + )] + async fn vault_map(&self) -> Result { + let store = self.store.lock().await; + let ctx = ContextParams { + store: &store, + vault_path: &self.vault_path, + profile: self.profile.as_ref().as_ref(), + }; + let map = context::vault_map(&ctx).map_err(|e| mcp_err(&e))?; + to_json_result(&map) + } + + #[tool( + name = "who", + description = "Person context bundle: their note, mentions across the vault, and graph connections." + )] + async fn who(&self, params: Parameters) -> Result { + let store = self.store.lock().await; + let ctx = ContextParams { + store: &store, + vault_path: &self.vault_path, + profile: self.profile.as_ref().as_ref(), + }; + let person = context::context_who(&ctx, ¶ms.0.name).map_err(|e| mcp_err(&e))?; + to_json_result(&person) + } + + #[tool( + name = "project", + description = "Project context bundle: project note, child notes, active tasks, team members, and recent daily mentions." + )] + async fn project(&self, params: Parameters) -> Result { + let store = self.store.lock().await; + let ctx = ContextParams { + store: &store, + vault_path: &self.vault_path, + profile: self.profile.as_ref().as_ref(), + }; + let proj = context::context_project(&ctx, ¶ms.0.name).map_err(|e| mcp_err(&e))?; + to_json_result(&proj) + } + + #[tool( + name = "context", + description = "Rich topic context with search-driven section selection and character budget trimming. Returns the most relevant note sections for a topic." + )] + async fn context( + &self, + params: Parameters, + ) -> Result { + let budget = params.0.budget.unwrap_or(32000); + let store = self.store.lock().await; + let mut embedder = self.embedder.lock().await; + let ctx = ContextParams { + store: &store, + vault_path: &self.vault_path, + profile: self.profile.as_ref().as_ref(), + }; + let bundle = context::context_topic_with_search( + &ctx, + ¶ms.0.topic, + budget, + &mut embedder, + &self.hnsw_index, + ) + .map_err(|e| mcp_err(&e))?; + to_json_result(&bundle) + } +} + +#[tool_handler] +impl rmcp::handler::server::ServerHandler for EngraphServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions( + "engraph: vault intelligence for Obsidian. \ + Use vault_map to orient, search to find, \ + read for full content, who/project for context bundles.", + ) + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +pub async fn run_serve(data_dir: &Path) -> Result<()> { + let db_path = data_dir.join("engraph.db"); + let models_dir = data_dir.join("models"); + let hnsw_dir = data_dir.join("hnsw"); + + let store = Store::open(&db_path)?; + let embedder = Embedder::new(&models_dir)?; + let hnsw_index = HnswIndex::load(&hnsw_dir)?; + + let vault_path_str = store.get_meta("vault_path")?.ok_or_else(|| { + anyhow::anyhow!("No vault path in index. Run 'engraph index ' first.") + })?; + let vault_path = PathBuf::from(&vault_path_str); + + let profile = Config::load_vault_profile().ok().flatten(); + + let server = EngraphServer { + store: Arc::new(Mutex::new(store)), + embedder: Arc::new(Mutex::new(embedder)), + hnsw_index: Arc::new(hnsw_index), + vault_path: Arc::new(vault_path), + profile: Arc::new(profile), + tool_router: EngraphServer::tool_router(), + }; + + eprintln!("engraph MCP server starting..."); + + let transport = rmcp::transport::io::stdio(); + let server_handle = server.serve(transport).await?; + server_handle.waiting().await?; + Ok(()) +}