Skip to content

Commit 67c1133

Browse files
devwhodevsclaude
andcommitted
docs: add engraph v2.2b design spec — MCP server
MCP stdio server exposing 7 read-only vault tools via rmcp SDK. Arc+Mutex wrapping for Clone requirement, tokio::sync::Mutex for async handlers, transport-io for stdio. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 424469e commit 67c1133

1 file changed

Lines changed: 345 additions & 0 deletions

File tree

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
# engraph v2.2b — MCP Server
2+
3+
**Date:** 2026-03-24
4+
**Milestone:** v0.5
5+
**Depends on:** v0.4.0 (context engine)
6+
7+
---
8+
9+
## Goal
10+
11+
Add `engraph serve` command that runs an MCP stdio server, exposing the context engine as tools that Claude Code and other MCP clients can call directly.
12+
13+
## What's in scope
14+
15+
1. `engraph serve` command — starts MCP stdio server
16+
2. 7 read-only MCP tools mapping to existing context engine functions
17+
3. Dependencies: `rmcp` (official Rust MCP SDK), `tokio`, `schemars`
18+
19+
## What's NOT in scope
20+
21+
- HTTP daemon / REST API → v0.8
22+
- Agent authentication / permissions → v0.8
23+
- Write tools (create, append, update_metadata) → v0.6 (write pipeline)
24+
- SSE / streamable HTTP transport → v0.8
25+
26+
---
27+
28+
## 1. MCP Tools
29+
30+
| Tool | Parameters | Maps to | Returns |
31+
|------|-----------|---------|---------|
32+
| `search` | `query: String, top_n?: usize` | `search_internal` | JSON array of results with score, path, heading, snippet, docid |
33+
| `read` | `file: String` | `context_read` | JSON NoteContent (full content, metadata, graph edges) |
34+
| `list` | `folder?: String, tags?: Vec<String>, limit?: usize` | `context_list` | JSON array of NoteListItem |
35+
| `vault_map` | (none) | `vault_map` | JSON VaultMap (folders, tags, recent files) |
36+
| `who` | `name: String` | `context_who` | JSON PersonContext (note, mentions, links) |
37+
| `project` | `name: String` | `context_project` | JSON ProjectContext (note, children, tasks, team) |
38+
| `context` | `topic: String, budget?: usize` | `context_topic_with_search` | JSON ContextBundle (sections, budget info) |
39+
40+
All tools return JSON via `CallToolResult::success(vec![Content::text(json_string)])`.
41+
42+
Tool parameters use `schemars::JsonSchema` derive for automatic schema generation in MCP tool listing.
43+
44+
---
45+
46+
## 2. Architecture
47+
48+
```
49+
Claude Code / MCP Client
50+
│ stdio (stdin/stdout)
51+
52+
engraph serve
53+
54+
┌─────▼─────┐
55+
│ MCP Server │ (rmcp ServerHandler)
56+
│ serve.rs │
57+
└─────┬──────┘
58+
│ calls directly (sync)
59+
┌─────▼──────────────────────┐
60+
│ context.rs │ search.rs │
61+
│ store.rs │ graph.rs │
62+
│ embedder.rs │ hnsw.rs │
63+
└────────────────────────────┘
64+
```
65+
66+
### Server Struct
67+
68+
```rust
69+
use std::sync::Arc;
70+
use tokio::sync::Mutex;
71+
72+
#[derive(Clone)]
73+
pub struct EngraphServer {
74+
store: Arc<Mutex<Store>>, // Connection is !Sync, needs Mutex
75+
embedder: Arc<Mutex<Embedder>>, // &mut self methods, needs Mutex
76+
hnsw_index: Arc<HnswIndex>, // Hnsw is Send+Sync, Arc alone is fine
77+
vault_path: Arc<PathBuf>,
78+
profile: Arc<Option<VaultProfile>>,
79+
}
80+
```
81+
82+
**Why `Arc` + `Clone`:** rmcp's `#[tool_router]` macro requires the server struct to implement `Clone`. Since `Store` wraps `rusqlite::Connection` (which is `!Sync`) and `Embedder` needs `&mut self`, both are wrapped in `Arc<tokio::sync::Mutex<T>>`.
83+
84+
**Why `tokio::sync::Mutex`:** Allows `.lock().await` in async tool handlers. Since MCP stdio processes one request at a time, contention is never an issue — but tokio's Mutex integrates cleanly with async handlers.
85+
86+
**Helper method** for constructing `ContextParams` per tool call:
87+
88+
```rust
89+
impl EngraphServer {
90+
async fn with_context<F, T>(&self, f: F) -> Result<T, McpError>
91+
where
92+
F: FnOnce(&context::ContextParams<'_>) -> anyhow::Result<T>,
93+
{
94+
let store = self.store.lock().await;
95+
let params = context::ContextParams {
96+
store: &store,
97+
vault_path: &self.vault_path,
98+
profile: self.profile.as_ref().as_ref(),
99+
};
100+
f(&params).map_err(|e| McpError::internal(e.to_string()))
101+
}
102+
}
103+
```
104+
105+
### Async/Sync Bridge
106+
107+
The existing codebase is synchronous. The MCP SDK is async (tokio). The bridge:
108+
109+
1. `engraph serve` creates a tokio runtime via `#[tokio::main]` on main
110+
2. MCP tool handlers are `async fn` (required by rmcp)
111+
3. Inside each handler, lock the store/embedder Mutex, call sync functions, release
112+
4. Operations are fast (<100ms typically) — SQLite queries, disk reads, and occasional ONNX inference (CPU-bound, 20-80ms)
113+
114+
**Conscious trade-off:** No `spawn_blocking` is used. ONNX inference in the `search` and `context` tools blocks the tokio worker thread for up to 80ms. For a single-client stdio server with no concurrent async work, this is harmless. If the server ever moves to multi-client HTTP, `spawn_blocking` should be added for embedding calls.
115+
116+
### Startup Flow
117+
118+
```
119+
engraph serve
120+
→ load Store from ~/.engraph/engraph.db
121+
→ load Embedder from ~/.engraph/models/
122+
→ load HnswIndex from ~/.engraph/hnsw/
123+
→ load VaultProfile from ~/.engraph/vault.toml (optional)
124+
→ get vault_path from store.get_meta("vault_path")
125+
→ create EngraphServer struct
126+
→ create stdio transport: (tokio::io::stdin(), tokio::io::stdout())
127+
→ server.serve(transport).await
128+
```
129+
130+
### Error Handling
131+
132+
Tool errors return `McpError` with descriptive messages. The server never panics — all errors are caught and returned as MCP error responses.
133+
134+
If the index doesn't exist at startup, print error to stderr and exit (same as other commands).
135+
136+
---
137+
138+
## 3. Tool Implementations
139+
140+
Each tool is a method on `EngraphServer` with `#[tool]` attribute:
141+
142+
### `search`
143+
144+
```rust
145+
#[tool(description = "Hybrid search across the vault using semantic, keyword, and graph lanes")]
146+
async fn search(&self, Parameters(params): Parameters<SearchParams>) -> Result<CallToolResult, McpError> {
147+
let top_n = params.top_n.unwrap_or(5);
148+
let store = self.store.lock().await;
149+
let mut embedder = self.embedder.lock().await;
150+
let output = search_internal(&params.query, top_n, &store, &mut embedder, &self.hnsw_index)
151+
.map_err(|e| McpError::internal(e.to_string()))?;
152+
let json = serde_json::to_string_pretty(&output.results)
153+
.map_err(|e| McpError::internal(e.to_string()))?;
154+
Ok(CallToolResult::success(vec![Content::text(json)]))
155+
}
156+
```
157+
158+
### `read`
159+
160+
```rust
161+
#[tool(description = "Read a note's full content with metadata and graph connections")]
162+
async fn read(&self, Parameters(params): Parameters<ReadParams>) -> Result<CallToolResult, McpError> {
163+
self.with_context(|ctx| {
164+
let note = context_read(ctx, &params.file)?;
165+
Ok(serde_json::to_string_pretty(&note)?)
166+
}).await
167+
.map(|json| CallToolResult::success(vec![Content::text(json)]))
168+
}
169+
```
170+
171+
### Pattern for all tools
172+
173+
Each tool:
174+
1. Extract parameters (with defaults for optional fields)
175+
2. Build `ContextParams` via `self.context_params()`
176+
3. Call the corresponding context/search function
177+
4. Serialize result to JSON
178+
5. Return `CallToolResult::success`
179+
180+
### Parameter Structs
181+
182+
```rust
183+
use schemars::JsonSchema;
184+
use serde::Deserialize;
185+
186+
#[derive(Debug, Deserialize, JsonSchema)]
187+
pub struct SearchParams {
188+
/// The search query
189+
pub query: String,
190+
/// Number of results (default 5)
191+
pub top_n: Option<usize>,
192+
}
193+
194+
#[derive(Debug, Deserialize, JsonSchema)]
195+
pub struct ReadParams {
196+
/// File path, basename, or #docid
197+
pub file: String,
198+
}
199+
200+
#[derive(Debug, Deserialize, JsonSchema)]
201+
pub struct ListParams {
202+
/// Filter to folder path prefix
203+
pub folder: Option<String>,
204+
/// Filter to notes with all listed tags
205+
pub tags: Option<Vec<String>>,
206+
/// Maximum results (default 20)
207+
pub limit: Option<usize>,
208+
}
209+
210+
#[derive(Debug, Deserialize, JsonSchema)]
211+
pub struct WhoParams {
212+
/// Person name
213+
pub name: String,
214+
}
215+
216+
#[derive(Debug, Deserialize, JsonSchema)]
217+
pub struct ProjectParams {
218+
/// Project name
219+
pub name: String,
220+
}
221+
222+
#[derive(Debug, Deserialize, JsonSchema)]
223+
pub struct ContextToolParams {
224+
/// Topic to search for
225+
pub topic: String,
226+
/// Character budget (default 32000, ~8000 tokens)
227+
pub budget: Option<usize>,
228+
}
229+
```
230+
231+
Note: `ContextToolParams` (MCP tool input) is distinct from `context::ContextParams` (the shared context struct). Different names, different purposes — no collision.
232+
233+
---
234+
235+
## 4. CLI
236+
237+
```rust
238+
/// Start MCP stdio server.
239+
Serve,
240+
```
241+
242+
Handler:
243+
```rust
244+
Command::Serve => {
245+
if !index_exists(&data_dir) {
246+
eprintln!("No index found. Run 'engraph index <path>' first.");
247+
std::process::exit(1);
248+
}
249+
serve::run_serve(&data_dir).await?;
250+
}
251+
```
252+
253+
Since `engraph serve` needs async, the `main()` function needs to handle this. Two options:
254+
- Make `main` itself `#[tokio::main]` (simplest, adds ~1ms overhead to non-serve commands)
255+
- Use `tokio::runtime::Runtime::new()` only in the Serve handler (avoids runtime for other commands)
256+
257+
**Recommendation:** Use `#[tokio::main]` on main. The overhead is negligible and avoids complexity. All existing sync commands work fine inside a tokio runtime — they just don't use it.
258+
259+
---
260+
261+
## 5. Dependencies
262+
263+
Add to `Cargo.toml`:
264+
265+
```toml
266+
rmcp = { version = "1.2", features = ["transport-io"] } # default includes server + macros
267+
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
268+
schemars = "0.8"
269+
```
270+
271+
Note: `rmcp` default features include `server` (which pulls in `transport-async-rw` + `schemars`) and `macros`. The only extra feature needed is `transport-io` for stdio (`tokio/io-std`).
272+
273+
---
274+
275+
## 6. Testing
276+
277+
Unit tests for the MCP server are limited since the tool handlers are thin wrappers around context/search functions (already tested with 146 tests).
278+
279+
**Integration test approach:**
280+
- Test `EngraphServer` construction with in-memory store
281+
- Test each tool method directly (they're just async fns)
282+
- Don't test MCP protocol encoding (that's rmcp's responsibility)
283+
284+
**Manual testing:**
285+
```bash
286+
# Start the server
287+
engraph serve
288+
289+
# In another terminal, send JSON-RPC via stdin (or use Claude Code)
290+
echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | engraph serve
291+
```
292+
293+
**Claude Code testing:**
294+
Add to settings, then use tools in a conversation.
295+
296+
---
297+
298+
## 7. File Structure
299+
300+
### New Files
301+
- `src/serve.rs` — MCP server: EngraphServer struct, tool handlers, parameter structs, run_serve
302+
303+
### Modified Files
304+
- `src/main.rs` — add `Serve` command, make main async with `#[tokio::main]`
305+
- `src/search.rs` — add `Serialize` derive to `InternalSearchResult` (currently only has `Debug, Clone`)
306+
- `Cargo.toml` — add rmcp, tokio, schemars
307+
- `src/lib.rs` — add `pub mod serve;`
308+
309+
---
310+
311+
## 8. Claude Code Integration
312+
313+
After release, configure in `~/.claude/settings.json`:
314+
315+
```json
316+
{
317+
"mcpServers": {
318+
"engraph": {
319+
"command": "engraph",
320+
"args": ["serve"]
321+
}
322+
}
323+
}
324+
```
325+
326+
Then Claude Code can call:
327+
- `engraph_search({ query: "delivery date" })`
328+
- `engraph_read({ file: "#bf84f0" })`
329+
- `engraph_who({ name: "John Nelson" })`
330+
- etc.
331+
332+
---
333+
334+
## 9. Revised Roadmap
335+
336+
| Semver | Milestone | What |
337+
|--------|-----------|------|
338+
| v0.1 | Initial | Shipped 2026-03-19 |
339+
| v0.2 | v2.0 Foundation | Shipped 2026-03-24 |
340+
| v0.3 | v2.1 Graph | Shipped 2026-03-24 |
341+
| v0.4 | v2.2a Context Engine | Shipped 2026-03-24 |
342+
| **v0.5** | **v2.2b MCP Server** | **MCP stdio, 7 read-only tools** |
343+
| v0.6 | v2.3 Write Pipeline | Auto-filing, tag resolution, link discovery |
344+
| v0.7 | v2.4 Intelligence | Research orchestrator, temporal agent |
345+
| v0.8 | v2.5 Polish | HTTP/REST, auth, vault health, multi-vault |

0 commit comments

Comments
 (0)