360 lines
13 KiB
Rust
360 lines
13 KiB
Rust
//! Genuine executable MCP boundary: handshake, discovery, source scope and lifecycle.
|
|
use serde_json::{json, Value};
|
|
use std::{
|
|
io::{BufRead, BufReader, Write},
|
|
process::{Child, ChildStdin, Command, Stdio},
|
|
sync::mpsc,
|
|
time::Duration,
|
|
};
|
|
struct Server {
|
|
child: Child,
|
|
input: ChildStdin,
|
|
output: mpsc::Receiver<String>,
|
|
}
|
|
impl Drop for Server {
|
|
fn drop(&mut self) {
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
}
|
|
}
|
|
impl Server {
|
|
fn start(db: &std::path::Path) -> Self {
|
|
Self::start_with_assets(db, None)
|
|
}
|
|
fn start_with_assets(
|
|
db: &std::path::Path,
|
|
assets: Option<(&std::ffi::OsStr, &std::ffi::OsStr)>,
|
|
) -> Self {
|
|
let mut command = Command::new(env!("CARGO_BIN_EXE_searchi"));
|
|
command.args(["mcp", "--db"]).arg(db);
|
|
if let Some((model, worker)) = assets {
|
|
command
|
|
.arg("--model-dir")
|
|
.arg(model)
|
|
.arg("--embedding-worker")
|
|
.arg(worker);
|
|
}
|
|
let mut child = command
|
|
.env("SEARCHI_FORCE_CPU", "1")
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.unwrap();
|
|
let input = child.stdin.take().unwrap();
|
|
let stdout = child.stdout.take().unwrap();
|
|
let (tx, output) = mpsc::channel();
|
|
std::thread::spawn(move || {
|
|
for line in BufReader::new(stdout).lines() {
|
|
if tx.send(line.unwrap()).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
Self {
|
|
child,
|
|
input,
|
|
output,
|
|
}
|
|
}
|
|
fn send(&mut self, value: Value) {
|
|
writeln!(self.input, "{value}").unwrap();
|
|
self.input.flush().unwrap();
|
|
}
|
|
fn request(&mut self, id: u32, method: &str, params: Value) -> Value {
|
|
self.send(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}));
|
|
loop {
|
|
let line = self
|
|
.output
|
|
.recv_timeout(Duration::from_secs(15))
|
|
.expect("MCP response deadline");
|
|
let value: Value =
|
|
serde_json::from_str(&line).expect("stdout contains non-JSONRPC logging");
|
|
assert_eq!(value["jsonrpc"], "2.0");
|
|
if value["id"] == id {
|
|
return value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#[test]
|
|
fn actual_mcp_handshake_search_and_guarded_sources() {
|
|
let fixture = tempfile::tempdir().unwrap();
|
|
let folder = fixture.path().join("documents");
|
|
std::fs::create_dir(&folder).unwrap();
|
|
let file = folder.join("contract.txt");
|
|
std::fs::write(
|
|
&file,
|
|
"Termination clause: sixty days written notice to end the supplier partnership.",
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
folder.join("retry.rs"),
|
|
"pub fn refreshAccessToken() { /* retry requests */ }",
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
folder.join("symbol-note.txt"),
|
|
"refreshAccessToken retry requests",
|
|
)
|
|
.unwrap();
|
|
let db = fixture.path().join("index.sqlite");
|
|
let indexed = Command::new(env!("CARGO_BIN_EXE_searchi"))
|
|
.args(["index", "--db"])
|
|
.arg(&db)
|
|
.arg("--root")
|
|
.arg(&folder)
|
|
.output()
|
|
.unwrap();
|
|
assert!(indexed.status.success());
|
|
let mut server = Server::start(&db);
|
|
let hello=server.request(1,"initialize",json!({"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"searchi-test","version":"1"}}));
|
|
assert_eq!(hello["result"]["serverInfo"]["name"], "searchi");
|
|
server.send(json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
|
|
let tools = server.request(2, "tools/list", json!({}));
|
|
let names: Vec<&str> = tools["result"]["tools"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|tool| {
|
|
assert_eq!(tool["annotations"]["readOnlyHint"], true);
|
|
tool["name"].as_str().unwrap()
|
|
})
|
|
.collect();
|
|
assert_eq!(names.len(), 3);
|
|
assert!(
|
|
names.contains(&"search") && names.contains(&"stats") && names.contains(&"read_source")
|
|
);
|
|
let found = server.request(
|
|
3,
|
|
"tools/call",
|
|
json!({"name":"search","arguments":{"query":"termination","limit":3,"extension":"txt"}}),
|
|
);
|
|
let data = &found["result"]["structuredContent"];
|
|
assert_eq!(data["mode"], "lexical_only");
|
|
assert_eq!(data["main_result_count"], 1);
|
|
assert_eq!(data["related_result_count"], 0);
|
|
assert_eq!(data["match_assessment"], "main_matches");
|
|
assert_eq!(data["results"][0]["evidence"], "main_match");
|
|
assert_eq!(data["results"][0]["filename"], "contract.txt");
|
|
assert!(data["results"][0]["snippet"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("sixty"));
|
|
let handle = data["results"][0]["source_handle"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_owned();
|
|
let source = server.request(
|
|
4,
|
|
"tools/call",
|
|
json!({"name":"read_source","arguments":{"handle":handle}}),
|
|
);
|
|
assert!(source["result"]["structuredContent"]["text"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("sixty"));
|
|
let invented = server.request(
|
|
5,
|
|
"tools/call",
|
|
json!({"name":"read_source","arguments":{"handle":"invented"}}),
|
|
);
|
|
assert_eq!(invented["result"]["isError"], true);
|
|
let invalid = server.request(
|
|
6,
|
|
"tools/call",
|
|
json!({"name":"search","arguments":{"query":"termination","limit":101}}),
|
|
);
|
|
assert_eq!(invalid["result"]["isError"], true);
|
|
for (offset, folder) in [r"\\host\share", r"\\?\UNC\host\share", "//host/share"]
|
|
.iter()
|
|
.enumerate()
|
|
{
|
|
let rejected = server.request(
|
|
20 + offset as u32,
|
|
"tools/call",
|
|
json!({"name":"search","arguments":{"query":"termination","folder":folder}}),
|
|
);
|
|
assert_eq!(rejected["result"]["isError"], true);
|
|
assert!(rejected["result"]["structuredContent"]["error"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("local path"));
|
|
}
|
|
let code = server.request(
|
|
40,
|
|
"tools/call",
|
|
json!({"name":"search","arguments":{"query":"refreshAccessToken","code_only":true}}),
|
|
);
|
|
let code_results = code["result"]["structuredContent"]["results"]
|
|
.as_array()
|
|
.unwrap();
|
|
assert_eq!(code_results.len(), 1);
|
|
assert_eq!(code_results[0]["filename"], "retry.rs");
|
|
assert_eq!(code_results[0]["source"]["kind"], "line");
|
|
assert!(code_results[0]["snippet"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("refreshAccessToken"));
|
|
let mutation = server.request(7, "tools/call", json!({"name":"index","arguments":{}}));
|
|
assert!(mutation["error"].is_object());
|
|
std::fs::write(
|
|
&file,
|
|
"The source changed and its previous clause is stale.",
|
|
)
|
|
.unwrap();
|
|
let stale = server.request(
|
|
8,
|
|
"tools/call",
|
|
json!({"name":"read_source","arguments":{"handle":handle}}),
|
|
);
|
|
assert_eq!(stale["result"]["isError"], true);
|
|
let reindexed = Command::new(env!("CARGO_BIN_EXE_searchi"))
|
|
.args(["index", "--db"])
|
|
.arg(&db)
|
|
.arg("--root")
|
|
.arg(&folder)
|
|
.output()
|
|
.unwrap();
|
|
assert!(reindexed.status.success());
|
|
let replaced = server.request(
|
|
30,
|
|
"tools/call",
|
|
json!({"name":"read_source","arguments":{"handle":handle}}),
|
|
);
|
|
assert_eq!(replaced["result"]["isError"], true);
|
|
assert!(replaced["result"]["structuredContent"]["error"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("version changed"));
|
|
std::fs::remove_file(&file).unwrap();
|
|
let deleted = server.request(
|
|
9,
|
|
"tools/call",
|
|
json!({"name":"read_source","arguments":{"handle":handle}}),
|
|
);
|
|
assert_eq!(deleted["result"]["isError"], true);
|
|
let stats = server.request(10, "tools/call", json!({"name":"stats","arguments":{}}));
|
|
assert_eq!(stats["result"]["structuredContent"]["mode"], "lexical_only");
|
|
}
|
|
#[test]
|
|
fn missing_database_never_creates_file_or_protocol_noise() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let db = temp.path().join("missing.sqlite");
|
|
let output = Command::new(env!("CARGO_BIN_EXE_searchi"))
|
|
.args(["mcp", "--db"])
|
|
.arg(&db)
|
|
.output()
|
|
.unwrap();
|
|
assert!(!output.status.success());
|
|
assert!(output.stdout.is_empty());
|
|
assert!(!db.exists());
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "Requires verified Google model and current CPU-capable native worker"]
|
|
fn actual_google_cli_and_mcp_use_query_role_with_cited_results() {
|
|
let models = std::env::var_os("SEARCHI_GEMMA_MODEL_DIR").expect("Set models");
|
|
let worker = std::env::var_os("SEARCHI_GEMMA_WORKER").expect("Set worker");
|
|
let fixture = tempfile::tempdir().unwrap();
|
|
let folder = fixture.path().join("documents");
|
|
std::fs::create_dir(&folder).unwrap();
|
|
std::fs::write(
|
|
folder.join("supplier.txt"),
|
|
"The supplier agreement may be terminated with sixty days written notice.",
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
folder.join("garden.txt"),
|
|
"Vegetable beds and rainwater collection in the garden.",
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
folder.join("retry.rs"),
|
|
"pub fn retry_backoff(attempt: u32) -> u64 {\n 100 * 2_u64.pow(attempt)\n}\n",
|
|
)
|
|
.unwrap();
|
|
let db = fixture.path().join("index.sqlite");
|
|
searchi_core::SearchIndex::open(&db)
|
|
.unwrap()
|
|
.index_folder(&folder)
|
|
.unwrap();
|
|
let indexer = searchi_service::HybridSearch::open(&db, &models, &worker, 1).unwrap();
|
|
assert_eq!(indexer.worker_info().unwrap().role, "indexing");
|
|
drop(indexer);
|
|
let embedded = Command::new(env!("CARGO_BIN_EXE_searchi"))
|
|
.args(["embed", "--db"])
|
|
.arg(&db)
|
|
.arg("--model-dir")
|
|
.arg(&models)
|
|
.arg("--embedding-worker")
|
|
.arg(&worker)
|
|
.arg("--text-only")
|
|
.env("SEARCHI_FORCE_CPU", "1")
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
embedded.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&embedded.stderr)
|
|
);
|
|
let output = Command::new(env!("CARGO_BIN_EXE_searchi"))
|
|
.args(["search", "--db"])
|
|
.arg(&db)
|
|
.args(["cancel the supplier partnership", "--json"])
|
|
.arg("--model-dir")
|
|
.arg(&models)
|
|
.arg("--embedding-worker")
|
|
.arg(&worker)
|
|
.env("SEARCHI_FORCE_CPU", "1")
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
let cli: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
assert_eq!(cli[0]["filename"], "supplier.txt");
|
|
let mut server = Server::start_with_assets(&db, Some((&models, &worker)));
|
|
server.request(1,"initialize",json!({"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"query-role-regression","version":"1"}}));
|
|
server.send(json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
|
|
let stats = server.request(2, "tools/call", json!({"name":"stats","arguments":{}}));
|
|
let status = &stats["result"]["structuredContent"];
|
|
assert_eq!(status["worker"]["role"], "query");
|
|
assert_eq!(status["worker"]["context_tokens"], 512);
|
|
assert_eq!(status["worker"]["device"], "cpu");
|
|
assert_eq!(status["worker"]["media_supported"], true);
|
|
let found = server.request(
|
|
3,
|
|
"tools/call",
|
|
json!({"name":"search","arguments":{"query":"cancel the supplier partnership","limit":2}}),
|
|
);
|
|
let first = &found["result"]["structuredContent"]["results"][0];
|
|
assert_eq!(first["filename"], "supplier.txt");
|
|
let handle = first["source_handle"].as_str().unwrap();
|
|
let source = server.request(
|
|
4,
|
|
"tools/call",
|
|
json!({"name":"read_source","arguments":{"handle":handle}}),
|
|
);
|
|
assert!(source["result"]["structuredContent"]["text"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("sixty days"));
|
|
let code=server.request(5,"tools/call",json!({"name":"search","arguments":{"query":"retry failed requests with exponential backoff","code_only":true}}));
|
|
let first = &code["result"]["structuredContent"]["results"][0];
|
|
assert_eq!(first["filename"], "retry.rs");
|
|
assert_eq!(first["source"]["kind"], "line");
|
|
let handle = first["source_handle"].as_str().unwrap();
|
|
let source = server.request(
|
|
6,
|
|
"tools/call",
|
|
json!({"name":"read_source","arguments":{"handle":handle}}),
|
|
);
|
|
assert!(source["result"]["structuredContent"]["text"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("2_u64.pow"));
|
|
}
|