Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions crates/terraphim_mcp_server/tests/support/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,50 @@
//! Test support for `terraphim_mcp_server` integration tests.
//!
//! Provides a hermetic test root + `apply_hermetic_env` so stdio-driven tests
//! can spawn the real `terraphim_mcp_server` binary without depending on a
//! sibling `terraphim_settings/` repository or the host's `.terraphim/`
//! config. Refs #143.

use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::{Context, Result};

// Each integration-test binary compiles its own copy of this module, so the
// helpers below can appear "unused" when only some of them are referenced by a
// particular test target. Suppress the noise rather than gating on a feature
// flag we do not need.
#[allow(dead_code)]
static COUNTER: AtomicU64 = AtomicU64::new(0);

#[allow(dead_code)]
fn create_unique_test_root() -> Result<PathBuf> {
let nonce = COUNTER.fetch_add(1, Ordering::SeqCst);
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system time before unix epoch")?
.as_nanos();

let root = std::env::temp_dir().join(format!(
"terraphim-mcp-server-hermetic-tests-{}-{}-{}",
std::process::id(),
ts,
nonce
));

fs::create_dir_all(&root)?;
Ok(root)
}

/// Resolve the path to the terraphim_mcp_server binary.
///
/// Priority:
/// 1. `TERRAPHIM_MCP_SERVER_BIN` environment variable (set by CI/build-runner)
/// 2. `../../target/debug/terraphim_mcp_server` relative to current dir
/// 3. `../../target/release/terraphim_mcp_server` relative to current dir
#[allow(dead_code)]
pub fn mcp_server_binary() -> anyhow::Result<std::path::PathBuf> {
if let Ok(bin) = std::env::var("TERRAPHIM_MCP_SERVER_BIN") {
let path = std::path::PathBuf::from(bin);
Expand Down Expand Up @@ -35,3 +76,11 @@ pub fn mcp_server_binary() -> anyhow::Result<std::path::PathBuf> {
"terraphim_mcp_server binary not found. Set TERRAPHIM_MCP_SERVER_BIN or run: cargo build -p terraphim_mcp_server"
)
}

/// Create a fresh, unique hermetic test root under `std::env::temp_dir()`.
/// Tests should `cmd.current_dir(&root)` so `terraphim_config::project::discover()`
/// does not walk up to a host `.terraphim/` directory. Refs #143.
#[allow(dead_code)]
pub fn create_hermetic_root() -> Result<PathBuf> {
create_unique_test_root()
}
244 changes: 115 additions & 129 deletions crates/terraphim_mcp_server/tests/test_all_mcp_tools.rs
Original file line number Diff line number Diff line change
@@ -1,42 +1,55 @@
use std::env;
//! Exercise several MCP tools (`tools/list`, `json_decode`, `find_files`,
//! `grep_files`) by spawning the real `terraphim_mcp_server` binary over
//! stdio.
//!
//! Hermetic: the spawned process runs with `cwd` set to a unique temp dir so
//! `terraphim_config::project::discover()` cannot walk up to the host's
//! `.terraphim/`. The MCP server uses its embedded default config, so no
//! settings file is written and the test has no external dependencies. Refs #143.
//!
//! Tool selection rationale: this test deliberately avoids KG-backed tools
//! (`build_autocomplete_index`, `autocomplete_terms`, `search`,
//! `find_matches`, etc.) because each of them triggers `ensure_thesaurus_loaded`
//! which walks the default KG path (`default_data_path.join("kg")`) and
//! hangs in CI when that path is empty. The lightweight tools exercised here
//! verify the JSON-RPC round-trip without paying the KG-load cost; the
//! KG-backed tools have their own test coverage in the agent / cli crates.

mod support;

use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};

use serde_json::Value;

use support::{create_hermetic_root, mcp_server_binary};

#[test]
fn test_all_mcp_tools() {
// Set the environment variable for local dev settings
unsafe {
env::set_var(
"TERRAPHIM_SETTINGS_PATH",
"../terraphim_settings/default/settings_local_dev.toml",
);
}

println!("Starting comprehensive MCP server test for all tools...");

// Start the MCP server
let mut command = Command::new("cargo");
command.arg("run");
if std::env::var_os("CI").is_some() {
command.arg("--features").arg("zlob");
}
let mut child = command
.args(["--", "--verbose"])
.current_dir(".")
let root = create_hermetic_root().expect("create hermetic root");
let binary = mcp_server_binary().expect("locate terraphim_mcp_server binary");

let mut command = Command::new(&binary);
command
.args(["--verbose"])
.current_dir(&root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("Failed to start MCP server");
.stderr(Stdio::piped());

let mut child = command.spawn().expect("Failed to start MCP server");

let mut stdin = child.stdin.take().expect("Failed to get stdin");
let stdout = child.stdout.take().expect("Failed to get stdout");
let mut reader = BufReader::new(stdout);

// Wait for server to start
// Give the server time to bind stdio JSON-RPC framing. No timeout flag
// is used (project policy).
std::thread::sleep(std::time::Duration::from_secs(3));

// Step 1: Send initialization request
// Step 1: Initialize the session.
let init_request = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
Expand All @@ -54,154 +67,127 @@ fn test_all_mcp_tools() {
});

println!("1. Sending initialization request...");
writeln!(stdin, "{}", init_request).expect("Failed to write to stdin");
let line = format!("{}\n", init_request);
stdin.write_all(line.as_bytes()).expect("Failed to write to stdin");
stdin.flush().expect("Failed to flush stdin");

// Read response
let mut response = String::new();
reader
.read_line(&mut response)
.expect("Failed to read response");
println!("Init Response: {}", response.trim());

// Step 2: List available tools
let init_value: Value =
serde_json::from_str(&response).expect("initialize response must be valid JSON");
assert!(
init_value.get("result").is_some(),
"initialize response missing `result`: {response}"
);

// Step 2: Acknowledge initialization. The MCP server requires the
// `notifications/initialized` frame before it will dispatch subsequent
// requests; without it `tools/list` returns EOF over stdio.
let initialized_notification = serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
});

println!("2. Sending initialized notification...");
let line = format!("{}\n", initialized_notification);
stdin.write_all(line.as_bytes()).expect("Failed to write notification");
stdin.flush().expect("Failed to flush stdin");
std::thread::sleep(std::time::Duration::from_millis(100));

// Step 3: List available tools. We assert that the response is valid
// and non-empty before exercising downstream tools, so a missing role
// surfaces here rather than as a downstream mystery error.
let tools_request = serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
});

println!("2. Listing available tools...");
writeln!(stdin, "{}", tools_request).expect("Failed to write to stdin");
println!("3. Listing available tools...");
let line = format!("{}\n", tools_request);
stdin.write_all(line.as_bytes()).expect("Failed to write to stdin");
stdin.flush().expect("Failed to flush stdin");

// Read the tools list response
response.clear();
reader
.read_line(&mut response)
.expect("Failed to read response");
println!("Tools list response: '{}'", response.trim());

// Check if response is empty
if response.trim().is_empty() {
println!("ERROR: Tools list response is empty!");
// Try to read more lines to see if there's a delayed response
for i in 0..5 {
response.clear();
if reader.read_line(&mut response).is_ok() {
println!("Additional response line {}: '{}'", i, response.trim());
}
}
} else {
// Parse the response to see what tools are available
if let Ok(tools_response) = serde_json::from_str::<serde_json::Value>(&response) {
println!("Parsed tools response: {:#?}", tools_response);

// Check if tools are present
if let Some(result) = tools_response.get("result")
&& let Some(tools) = result.get("tools")
&& let Some(tools_array) = tools.as_array()
{
println!("Number of tools available: {}", tools_array.len());
for (i, tool) in tools_array.iter().enumerate() {
println!("Tool {}: {:?}", i, tool.get("name"));
}

// If we have tools, test a few of them
if !tools_array.is_empty() {
test_specific_tools(&mut stdin, &mut reader);
}
}
} else {
println!("Failed to parse tools response as JSON");
}
}
let tools_value: Value =
serde_json::from_str(&response).expect("tools/list response must be valid JSON");
let tools = tools_value
.get("result")
.and_then(|r| r.get("tools"))
.and_then(|t| t.as_array())
.expect("tools/list result must contain a tools array");
assert!(
!tools.is_empty(),
"expected at least one tool registered, got: {response}"
);
println!("Number of tools available: {}", tools.len());

// `json_decode` is a pure JSON utility with no KG dependency.
exercise_call_tool(&mut stdin, &mut reader, "json_decode",
serde_json::json!({"jsonlines": "{\"a\":1}\n{\"b\":2}\n"}));

// `find_files` is a lightweight file-search that does not load the
// thesaurus. We point it at the hermetic root so it returns quickly.
exercise_call_tool(&mut stdin, &mut reader, "find_files",
serde_json::json!({"query": "non-existent-prefix", "path": root.to_string_lossy(), "limit": 5}));

// `grep_files` is also lightweight. An empty query against the hermetic
// root returns no matches without spinning up the thesaurus.
exercise_call_tool(&mut stdin, &mut reader, "grep_files",
serde_json::json!({"query": "no-such-pattern-xyzzy", "path": root.to_string_lossy(), "limit": 5}));

println!("Test completed!");

// Clean up
child.kill().expect("Failed to kill child process");
child.wait().expect("Failed to wait for child");
}

fn test_specific_tools(
fn exercise_call_tool(
stdin: &mut std::process::ChildStdin,
reader: &mut BufReader<std::process::ChildStdout>,
tool: &str,
arguments: Value,
) {
println!("Testing specific tools...");

// Test 3: Build autocomplete index
let build_index_request = serde_json::json!({
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": 3,
"id": 99,
"method": "tools/call",
"params": {
"name": "build_autocomplete_index",
"arguments": {
"role": "Terraphim Engineer"
}
"name": tool,
"arguments": arguments,
}
});

println!("3. Testing build_autocomplete_index...");
writeln!(stdin, "{}", build_index_request).expect("Failed to write to stdin");
println!("Calling {tool} with arguments {arguments}");
let line = format!("{}\n", request);
stdin.write_all(line.as_bytes()).expect("Failed to write to stdin");
stdin.flush().expect("Failed to flush stdin");

// Read response
let mut response = String::new();
reader
.read_line(&mut response)
.expect("Failed to read response");
println!("Build index response: '{}'", response.trim());

// Test 4: Autocomplete terms
let autocomplete_request = serde_json::json!({
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "autocomplete_terms",
"arguments": {
"query": "terraphim",
"limit": 5
}
}
});

println!("4. Testing autocomplete_terms...");
writeln!(stdin, "{}", autocomplete_request).expect("Failed to write to stdin");
stdin.flush().expect("Failed to flush stdin");

// Read response
response.clear();
reader
.read_line(&mut response)
.expect("Failed to read response");
println!("Autocomplete response: '{}'", response.trim());

// Test 5: Search
let search_request = serde_json::json!({
"jsonrpc": "2.0",
"id": 5,
"method": "tools/call",
"params": {
"name": "search",
"arguments": {
"query": "terraphim",
"limit": 3
}
}
});

println!("5. Testing search...");
writeln!(stdin, "{}", search_request).expect("Failed to write to stdin");
stdin.flush().expect("Failed to flush stdin");

// Read response
response.clear();
reader
.read_line(&mut response)
.expect("Failed to read response");
println!("Search response: '{}'", response.trim());
}
println!("{tool} response: '{}'", response.trim());

let value: Value =
serde_json::from_str(&response).unwrap_or_else(|e| panic!(
"{tool} response must be valid JSON, got error {e}: {response}"
));
// tools/call returns either a `result` (success or structured error
// content) or `error`. Either is acceptable; we just verify the
// response is well-formed JSON-RPC.
assert!(
value.get("result").is_some() || value.get("error").is_some(),
"{tool} response missing result/error: {response}"
);
}
Loading
Loading