Skip to content

Commit 43a548e

Browse files
fix(mcp): eliminate connection timeout and JSON-RPC notification compliance
Fix three bugs preventing MCP server from completing the initialize handshake within the client's timeout window: 1. CRITICAL (serve.rs): move run_connect_time_catchup to a spawn_blocking background task so the MCP message loop enters immediately. The previous synchronous block_on call prevented the server from reading stdin until catch-up indexing finished, causing the client's initialize request to time out. 2. HIGH (schema.rs): make JsonRpcRequest.id optional with #[serde(default)]. JSON-RPC 2.0 notifications omit the id field entirely, so deserialization failed for compliant clients sending notifications/initialized. 3. MEDIUM (mod.rs): dispatch now returns Option<Value> — None for notifications (id=null) per JSON-RPC 2.0 §4.1. The message loop skips writing a response when dispatch returns None.
1 parent 8f9ede8 commit 43a548e

8 files changed

Lines changed: 184 additions & 50 deletions

File tree

AGENTS.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# AGENTS.md
2+
3+
## Build & Run
4+
5+
```bash
6+
cargo build
7+
cargo run -- <args>
8+
cargo run -- serve --mcp # start MCP server
9+
cargo run -- init # initialize a project
10+
cargo run -- index # index codebase
11+
cargo run -- search "query" # semantic search
12+
```
13+
14+
## Testing
15+
16+
```bash
17+
cargo test --all-targets # run all tests
18+
cargo test --lib # unit tests only
19+
cargo test --test '*' # integration tests only
20+
cargo test <test_name> # single test
21+
```
22+
23+
## Lint & Format
24+
25+
```bash
26+
cargo fmt # auto-format
27+
cargo fmt --check # check formatting (CI)
28+
cargo clippy --all-targets -- -D warnings # lint (CI)
29+
```
30+
31+
All three (test, clippy, fmt --check) MUST pass before committing. CI enforces this.
32+
33+
## Architecture
34+
35+
```
36+
src/
37+
├── main.rs # entry point — clap dispatch
38+
├── lib.rs # module root + re-exports
39+
├── error.rs # VectorCodeError (thiserror)
40+
├── types.rs # Chunk, SearchResult, IndexMeta
41+
├── cli/ # one file per subcommand (init, index, search, serve, status, install, uninstall, upgrade)
42+
├── config/ # TOML config schema + loader (.vectorcode/config.toml)
43+
├── embedder/ # Embedder trait + providers (ONNX, Gemini, Ollama, OpenAI)
44+
├── engine/ # core orchestration: chunker (tree-sitter) → embedder → store
45+
├── mcp/ # MCP server: JSON-RPC 2.0 over stdio, tool handlers
46+
├── store/ # SQLite + sqlite-vec: db, chunks, files, meta tables
47+
└── watcher/ # file watcher (notify crate, debounced, gitignore-aware)
48+
```
49+
50+
## Key Conventions
51+
52+
- **Rust edition**: 2021, MSRV 1.75
53+
- **Error handling**: `VectorCodeError` (thiserror) for library code, `anyhow::Result` in `main.rs` and CLI handlers
54+
- **Async**: tokio runtime, `async-trait` for trait objects
55+
- **CLI**: clap with derive macros — one module per subcommand under `src/cli/`
56+
- **MCP protocol**: JSON-RPC 2.0 over stdio — schema types in `src/mcp/schema.rs`, handler dispatch in `src/mcp/server.rs`
57+
- **Embedding providers**: implement the `Embedder` trait from `src/embedder/mod.rs`
58+
- **Database**: rusqlite with bundled sqlite + sqlite-vec extension
59+
- **Tree-sitter**: one grammar per language, used by the chunker in `src/engine/`
60+
- **Tests**: unit tests inline (`#[cfg(test)] mod tests`), integration tests in `tests/`
61+
- **No unwrap/expect** in library code — propagate errors with `?`
62+
63+
## Adding a New Embedding Provider
64+
65+
1. Create `src/embedder/<provider>.rs`
66+
2. Implement the `Embedder` trait (async `embed` + `embed_batch`)
67+
3. Register in the provider factory in `src/embedder/mod.rs`
68+
4. Add config schema to `src/config/schema.rs`
69+
5. Add tests
70+
71+
## Adding a New Tree-sitter Language
72+
73+
1. Add the grammar crate to `Cargo.toml`
74+
2. Register in the chunker's language dispatch in `src/engine/`
75+
3. Add the extension mapping to `src/types.rs`
76+
77+
## Environment Variables
78+
79+
| Variable | Purpose |
80+
|---|---|
81+
| `GEMINI_API_KEY` | Gemini embedding API key |
82+
| `OPENAI_API_KEY` | OpenAI embedding API key |
83+
| `VECTORCODE_PROVIDER` | Override provider |
84+
| `VECTORCODE_NO_WATCH` | Set `1` to disable file watcher |
85+
| `RUST_LOG` | tracing filter (e.g. `debug`, `vectorcode=trace`) |
86+
87+
## Project Data
88+
89+
- Config: `.vectorcode/config.toml`
90+
- Index DB: `.vectorcode/index.db`
91+
- Both are gitignored — never commit them

src/cli/install.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -437,8 +437,7 @@ mod tests {
437437
let dir = tempfile::tempdir().unwrap();
438438
let config_path = dir.path().join("opencode.json");
439439

440-
let result =
441-
install_for_agent(&AgentTarget::Opencode, &config_path, dir.path()).unwrap();
440+
let result = install_for_agent(&AgentTarget::Opencode, &config_path, dir.path()).unwrap();
442441
assert!(result, "Should return true when config is created");
443442

444443
assert!(config_path.exists(), "Config file should be created");
@@ -470,13 +469,11 @@ mod tests {
470469
let config_path = dir.path().join("config.json");
471470

472471
// First install
473-
let result1 =
474-
install_for_agent(&AgentTarget::Cursor, &config_path, dir.path()).unwrap();
472+
let result1 = install_for_agent(&AgentTarget::Cursor, &config_path, dir.path()).unwrap();
475473
assert!(result1, "First install should modify config");
476474

477475
// Second install — should detect already configured
478-
let result2 =
479-
install_for_agent(&AgentTarget::Cursor, &config_path, dir.path()).unwrap();
476+
let result2 = install_for_agent(&AgentTarget::Cursor, &config_path, dir.path()).unwrap();
480477
assert!(!result2, "Second install should be idempotent (no changes)");
481478
}
482479

@@ -526,7 +523,10 @@ mod tests {
526523
assert!(entry["command"].is_string(), "Should have command field");
527524
assert!(entry["args"].is_array(), "Should have args array");
528525
let args = entry["args"].as_array().unwrap();
529-
assert!(args.len() >= 4, "Should have serve, --mcp, --project-path, and path");
526+
assert!(
527+
args.len() >= 4,
528+
"Should have serve, --mcp, --project-path, and path"
529+
);
530530
assert!(entry["env"].is_object(), "Should have env object");
531531
}
532532

@@ -578,7 +578,7 @@ mod tests {
578578
// Guard: if !force && skill_path.exists() { skip write }
579579
// force=false, exists=true → should NOT write
580580
let _force = false; // guard condition: only write if force || !exists
581-
// Verify existing content preserved
581+
// Verify existing content preserved
582582
let content = std::fs::read_to_string(&skill_path).unwrap();
583583
assert_eq!(
584584
content, "existing content",

src/cli/mod.rs

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ pub fn resolve_project_path(cli_path: Option<&PathBuf>) -> PathBuf {
9595
}
9696

9797
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
98-
98+
9999
// Walk up the tree looking for .vectorcode
100100
let mut current = cwd.as_path();
101101
loop {
@@ -107,7 +107,7 @@ pub fn resolve_project_path(cli_path: Option<&PathBuf>) -> PathBuf {
107107
None => break, // Reached root without finding it
108108
}
109109
}
110-
110+
111111
// Fallback to cwd if not found anywhere (commands will handle the "not initialized" error)
112112
cwd
113113
}
@@ -282,33 +282,33 @@ mod tests {
282282
let temp_path = std::fs::canonicalize(temp.path()).unwrap();
283283
let prev_cwd = std::env::current_dir().unwrap();
284284
std::env::set_current_dir(&temp_path).unwrap();
285-
285+
286286
let resolved = resolve_project_path(None);
287287
assert_eq!(resolved, temp_path);
288-
288+
289289
std::env::set_current_dir(prev_cwd).unwrap();
290290
}
291291

292292
#[test]
293293
fn resolve_project_path_finds_parent_dir() {
294294
let temp = tempfile::tempdir().unwrap();
295295
let project_root = std::fs::canonicalize(temp.path()).unwrap();
296-
296+
297297
// Create .vectorcode in root
298298
std::fs::create_dir(project_root.join(".vectorcode")).unwrap();
299-
299+
300300
// Create a deep subdirectory
301301
let deep_dir = project_root.join("src").join("cli").join("nested");
302302
std::fs::create_dir_all(&deep_dir).unwrap();
303-
303+
304304
// Change cwd to deep directory
305305
let prev_cwd = std::env::current_dir().unwrap();
306306
std::env::set_current_dir(&deep_dir).unwrap();
307-
307+
308308
// Resolve without explicit path should find the root
309309
let resolved = resolve_project_path(None);
310310
assert_eq!(resolved, project_root);
311-
311+
312312
// Restore cwd
313313
std::env::set_current_dir(prev_cwd).unwrap();
314314
}
@@ -348,9 +348,8 @@ mod tests {
348348
// Deterministic: uses an empty temp dir — model not cached, so
349349
// ModelManager returns an error before reaching ONNX Runtime init.
350350
let empty_dir = tempfile::tempdir().unwrap();
351-
let result = crate::embedder::onnx::OnnxEmbedder::from_model_dir(
352-
empty_dir.path().to_path_buf(),
353-
);
351+
let result =
352+
crate::embedder::onnx::OnnxEmbedder::from_model_dir(empty_dir.path().to_path_buf());
354353
assert!(result.is_err());
355354
let err_msg = format!("{}", result.err().unwrap());
356355
assert!(
@@ -365,9 +364,8 @@ mod tests {
365364
// Deterministic: empty model dir → error, verify it's the right
366365
// error variant (EmbedderError, not a panic or unexpected type).
367366
let empty_dir = tempfile::tempdir().unwrap();
368-
let result = crate::embedder::onnx::OnnxEmbedder::from_model_dir(
369-
empty_dir.path().to_path_buf(),
370-
);
367+
let result =
368+
crate::embedder::onnx::OnnxEmbedder::from_model_dir(empty_dir.path().to_path_buf());
371369
assert!(result.is_err());
372370
let err_msg = format!("{}", result.err().unwrap());
373371
assert!(

src/cli/serve.rs

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,6 @@ pub async fn execute(args: &ServeArgs, project_path: &std::path::Path) -> Result
7272
info!(" Watch: {watch}");
7373
info!(" Debounce: {}ms", args.debounce);
7474

75-
// Connect-time catch-up (spec §14.3) — reconcile files before serving
76-
if watch {
77-
run_connect_time_catchup(&db, embedder.clone(), &cfg, project_path)?;
78-
}
79-
8075
// Create file watcher if enabled
8176
let watcher = if watch {
8277
let watcher_config = &cfg.watcher;
@@ -129,6 +124,28 @@ pub async fn execute(args: &ServeArgs, project_path: &std::path::Path) -> Result
129124

130125
let mut server = McpServer::new(state);
131126

127+
// Spawn connect-time catch-up on a blocking thread (spec §14.3).
128+
// Runs on the blocking pool so the MCP message loop enters immediately
129+
// and the client's initialize request is answered without timeout.
130+
// Uses spawn_blocking because Indexer (rusqlite internals) is !Send.
131+
if watch {
132+
let catchup_db_path = db_path.clone();
133+
let catchup_embedder = embedder.clone();
134+
let catchup_cfg = cfg.clone();
135+
let catchup_project = project_path.to_path_buf();
136+
137+
tokio::task::spawn_blocking(move || {
138+
if let Err(e) = run_connect_time_catchup(
139+
&catchup_db_path,
140+
catchup_embedder,
141+
&catchup_cfg,
142+
&catchup_project,
143+
) {
144+
tracing::warn!("Connect-time catch-up failed: {e}");
145+
}
146+
});
147+
}
148+
132149
// Set up Ctrl+C handler
133150
let _ctrl_c = tokio::spawn(async {
134151
let _ = tokio::signal::ctrl_c().await;
@@ -146,14 +163,20 @@ pub async fn execute(args: &ServeArgs, project_path: &std::path::Path) -> Result
146163
///
147164
/// Compares file mtimes on disk against the `files` table and runs incremental
148165
/// sync on any files that changed while no MCP server was running.
166+
///
167+
/// Called from `spawn_blocking` so the MCP message loop starts immediately
168+
/// and the client's `initialize` request is answered without timeout.
149169
fn run_connect_time_catchup(
150-
db: &Database,
170+
db_path: &std::path::Path,
151171
embedder: Arc<dyn crate::embedder::Embedder>,
152172
cfg: &config::schema::Config,
153173
project_path: &std::path::Path,
154174
) -> Result<()> {
155175
info!("Running connect-time catch-up sync...");
156176

177+
// Open DB to list tracked files
178+
let db = Database::open(db_path).map_err(|e| anyhow::anyhow!("{e}"))?;
179+
157180
let tracked_files = files::list_all_files(db.conn())?;
158181
let mut changed_paths = Vec::new();
159182

@@ -190,13 +213,12 @@ fn run_connect_time_catchup(
190213
changed_paths.len()
191214
);
192215

193-
// Open a new DB handle for the indexer (the original is borrowed)
194-
let db_path = project_path.join(".vectorcode").join("index.db");
195-
let sync_db = Database::open(&db_path).map_err(|e| anyhow::anyhow!("{e}"))?;
196-
216+
// Open a fresh DB handle for the indexer (SQLite handles concurrent access)
217+
let sync_db = Database::open(db_path).map_err(|e| anyhow::anyhow!("{e}"))?;
197218
let indexer = Indexer::new(sync_db, embedder, cfg.indexing.clone());
198219

199-
// Use a blocking runtime context for the sync
220+
// block_on is safe here because we are on a spawn_blocking thread,
221+
// not the main async runtime.
200222
let rt = tokio::runtime::Handle::current();
201223
let report = rt.block_on(indexer.index_files(&changed_paths, project_path))?;
202224

@@ -376,7 +398,10 @@ mod tests {
376398
debounce: 2000,
377399
};
378400
let result = rt.block_on(execute(&serve_args, project_path));
379-
assert!(result.is_err(), "Should fail with Gemini provider missing key");
401+
assert!(
402+
result.is_err(),
403+
"Should fail with Gemini provider missing key"
404+
);
380405
let err = result.unwrap_err().to_string();
381406
assert!(
382407
err.contains("API key") || err.contains("GEMINI_API_KEY"),

src/cli/uninstall.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,8 +238,12 @@ mod tests {
238238
let config_path = dir.path().join("config.json");
239239

240240
// Install
241-
let installed =
242-
crate::cli::install::install_for_agent(&AgentTarget::Opencode, &config_path, dir.path()).unwrap();
241+
let installed = crate::cli::install::install_for_agent(
242+
&AgentTarget::Opencode,
243+
&config_path,
244+
dir.path(),
245+
)
246+
.unwrap();
243247
assert!(installed);
244248

245249
// Verify entry exists

src/embedder/model_manager.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ const MODEL_FILENAME: &str = "model.onnx";
2525
/// HuggingFace path for the quantized ONNX model, selected by platform.
2626
/// Falls back to the unquantized `onnx/model.onnx` (~90MB) if no quantized variant matches.
2727
const ONNX_MODEL_PATH: &str = {
28-
if cfg!(all(target_arch = "aarch64", any(target_os = "macos", target_os = "linux"))) {
28+
if cfg!(all(
29+
target_arch = "aarch64",
30+
any(target_os = "macos", target_os = "linux")
31+
)) {
2932
"onnx/model_qint8_arm64.onnx" // ~23MB, optimized for ARM64
3033
} else if cfg!(all(
3134
target_arch = "x86_64",

0 commit comments

Comments
 (0)