test: Implemented tests for the MCP server lifecycle

This commit is contained in:
2026-05-01 10:27:49 -06:00
parent 975484cc2b
commit 1df6114ff3
6 changed files with 1001 additions and 58 deletions
+129
View File
@@ -0,0 +1,129 @@
# Iteration 5 — Test Implementation Notes
## Plan file addressed
`docs/testing/plans/05-mcp-lifecycle.md`
## Tests created
### src/config/mcp_factory.rs (12 new tests)
| Test name | What it verifies |
|---|---|
| `key_from_stdio_spec_captures_command_args_env` | McpServerKey extracts command, args, env from stdio spec |
| `key_from_stdio_spec_sorts_args_and_env` | Args and env are sorted for deterministic key hashing |
| `key_from_stdio_spec_defaults_empty_when_none` | None args/env default to empty vecs |
| `key_from_remote_http_spec` | Http transport key captures url and transport type |
| `key_from_remote_sse_spec_with_sorted_headers` | SSE headers sorted for deterministic keys |
| `key_equality_same_spec_produces_equal_keys` | Same spec → equal keys (sharing contract) |
| `key_inequality_different_names` | Different server names → different keys |
| `key_inequality_different_commands` | Different commands → different keys (isolation contract) |
| `key_env_bool_and_int_coerce_to_string` | JsonField::Bool/Int coerced to String in key |
| `factory_try_get_active_returns_none_when_empty` | Empty factory returns None |
| `factory_try_get_active_returns_none_for_unknown_key` | Unknown key returns None |
| `factory_default_has_empty_active_map` | Default factory has empty internal map |
### src/config/tool_scope.rs (6 new tests)
| Test name | What it verifies |
|---|---|
| `mcp_runtime_new_is_empty` | New McpRuntime has no servers |
| `mcp_runtime_default_is_empty` | Default McpRuntime is empty |
| `mcp_runtime_get_returns_none_for_missing_server` | get() on nonexistent server returns None |
| `tool_scope_default_has_empty_mcp_runtime` | Default ToolScope has empty MCP runtime |
| `tool_scope_default_has_empty_functions` | Default ToolScope has no functions |
| `tool_scope_default_tracker_has_no_loops` | Default ToolScope tracker detects no loops |
### src/mcp/mod.rs (30 new tests)
| Test name | What it verifies |
|---|---|
| `validate_stdio_with_command_succeeds` | Valid stdio spec passes |
| `validate_stdio_missing_command_fails` | Stdio without command is rejected |
| `validate_stdio_with_url_fails` | Stdio with url (remote field) is rejected |
| `validate_stdio_with_headers_fails` | Stdio with headers (remote field) is rejected |
| `validate_http_with_url_succeeds` | Valid http spec passes |
| `validate_http_missing_url_fails` | Http without url is rejected |
| `validate_http_with_command_fails` | Http with command (stdio field) is rejected |
| `validate_http_with_args_fails` | Http with args (stdio field) is rejected |
| `validate_http_with_cwd_fails` | Http with cwd (stdio field) is rejected |
| `validate_sse_with_url_succeeds` | Valid SSE spec passes |
| `validate_sse_missing_url_fails` | SSE without url is rejected |
| `is_remote_true_for_http_and_sse` | Http and SSE are remote transports |
| `is_remote_false_for_stdio` | Stdio is not remote |
| `deserialize_stdio_server_from_json` | Full stdio spec from JSON |
| `deserialize_http_server_from_json` | Http spec with headers from JSON |
| `deserialize_env_with_mixed_types` | Env with String, Bool, Int values |
| `deserialize_multiple_servers` | Multiple server entries parsed |
| `deserialize_empty_servers_map` | Empty mcpServers map parsed |
| `deserialize_server_with_cwd` | cwd field parsed correctly |
| `resolve_all_returns_all_configured_servers` | "all" resolves to all config keys |
| `resolve_comma_separated_returns_matching_servers` | Comma-separated list filters correctly |
| `resolve_single_server_name` | Single name resolved |
| `resolve_none_returns_empty` | None enabled → empty list |
| `resolve_no_config_returns_empty` | No config → empty list |
| `resolve_nonexistent_server_filtered_out` | Unknown names silently filtered |
| `resolve_all_nonexistent_returns_empty` | All unknown → empty list |
| `resolve_trims_whitespace` | Whitespace in comma list trimmed |
| `registry_default_is_empty` | Default registry: empty, no config, no log |
| `registry_with_config_reports_config` | Config accessor works |
| `meta_function_prefixes_are_correct` | mcp_invoke/search/describe prefixes |
### src/config/request_context.rs (6 new tests)
| Test name | What it verifies |
|---|---|
| `rebuild_tool_scope_mcp_disabled_skips_servers` | mcp_server_support=false → empty runtime |
| `rebuild_tool_scope_no_enabled_servers_yields_empty_runtime` | None enabled → empty runtime |
| `rebuild_tool_scope_no_mcp_config_yields_empty_runtime` | No mcp_config → empty runtime |
| `rebuild_tool_scope_preserves_tool_tracker` | Tracker survives rebuild |
| `rebuild_tool_scope_repl_mode_appends_user_interaction_functions` | REPL adds user__ functions |
| `rebuild_tool_scope_cmd_mode_no_user_interaction_functions` | CMD skips user__ functions |
**Total: 54 new tests (176 total in suite)**
## Bugs discovered
None.
## Observations for future iterations
1. **ConnectedServer untestable without subprocess**: `ConnectedServer`
(= `RunningService<RoleClient, ()>`) cannot be constructed without
a real MCP server subprocess. This blocks unit testing for:
- McpFactory.acquire() full flow (spawn + insert + Weak sharing)
- McpRuntime.insert/get with real handles
- McpRuntime.search/describe/invoke (need live tool catalog)
- All scope transition tests (role/session/agent MCP start/stop)
These require integration tests with a mock MCP server binary
(e.g., a simple echo server). Recommended for a dedicated
integration test iteration.
2. **McpServerKey sorting guarantees sharing correctness**: The
sorting of args, env, and headers in McpServerKey::from_spec
is critical — without it, HashMap key equality would be
non-deterministic. Tests verify this explicitly.
3. **rebuild_tool_scope has 3 guard clauses that prevent server
acquisition**: mcp_server_support=false, mcp_config=None,
enabled_mcp_servers=None. All three paths tested.
4. **REPL vs CMD mode differs in user interaction functions**: The
`rebuild_tool_scope` method conditionally appends `user__*`
functions only in REPL mode. Tested both paths.
5. **McpServer::validate enforces strict transport/field separation**:
Stdio servers cannot have url/headers, remote servers cannot have
command/args/cwd. This prevents misconfiguration. All cross-field
conflict cases tested.
6. **McpRegistry.resolve_server_ids is private** but tested via
`#[cfg(test)]` in the same module. It's the core of server ID
resolution for "all", comma-separated, and empty cases.
## Next iteration
Plan file 06: Tool Evaluation — eval_tool_calls, ToolCall dispatch,
tool handlers, MCP tool invocation chain (mcp__search, mcp__describe,
mcp__invoke).
+75 -55
View File
@@ -11,85 +11,105 @@ during scope transitions (role/session/agent enter/exit).
## Behaviors to test ## Behaviors to test
### MCP config loading ### MCP config loading
- [ ] mcp.json parsed correctly from functions directory - [x] mcp.json parsed correctly from functions directory
- [ ] Server specs include command, args, env, cwd - [x] Server specs include command, args, env, cwd
- [ ] Vault secrets interpolated in mcp.json - [ ] Vault secrets interpolated in mcp.json
- [ ] Missing secrets reported as warnings - [ ] Missing secrets reported as warnings
- [ ] McpServersConfig stored on AppState.mcp_config - [x] McpServersConfig stored on AppState.mcp_config
### McpFactory ### McpFactory
- [ ] acquire() spawns new server when none active - [ ] acquire() spawns new server when none active (requires real subprocess)
- [ ] acquire() returns existing handle via Weak upgrade - [ ] acquire() returns existing handle via Weak upgrade (requires real subprocess)
- [ ] acquire() spawns fresh when Weak is dead - [ ] acquire() spawns fresh when Weak is dead (requires real subprocess)
- [ ] Multiple acquire() calls for same spec share handle - [ ] Multiple acquire() calls for same spec share handle (requires real subprocess)
- [ ] Different specs get different handles - [x] Different specs get different handles (via key inequality)
- [ ] McpServerKey built correctly from spec (sorted args/env) - [x] McpServerKey built correctly from spec (sorted args/env)
### McpRuntime ### McpRuntime
- [ ] insert() adds server handle by name - [ ] insert() adds server handle by name (requires Arc<ConnectedServer>)
- [ ] get() retrieves handle by name - [ ] get() retrieves handle by name (requires Arc<ConnectedServer>)
- [ ] server_names() returns all active names - [x] server_names() returns all active names
- [ ] is_empty() correct for empty/non-empty - [x] is_empty() correct for empty/non-empty
- [ ] search() finds tools by keyword (BM25 ranking) - [ ] search() finds tools by keyword (BM25 ranking) (requires live server)
- [ ] describe() returns tool input schema - [ ] describe() returns tool input schema (requires live server)
- [ ] invoke() calls tool on server and returns result - [ ] invoke() calls tool on server and returns result (requires live server)
### spawn_mcp_server ### spawn_mcp_server
- [ ] Builds Command from spec (command, args, env, cwd) - [ ] Builds Command from spec (command, args, env, cwd) (integration test)
- [ ] Creates TokioChildProcess transport - [ ] Creates TokioChildProcess transport (integration test)
- [ ] Completes rmcp handshake (serve) - [ ] Completes rmcp handshake (serve) (integration test)
- [ ] Returns Arc<ConnectedServer> - [ ] Returns Arc<ConnectedServer> (integration test)
- [ ] Log file created when log_path provided - [ ] Log file created when log_path provided (integration test)
### rebuild_tool_scope (MCP integration) ### rebuild_tool_scope (MCP integration)
- [ ] Empty enabled_mcp_servers → no servers acquired - [x] Empty enabled_mcp_servers → no servers acquired
- [ ] "all" → all configured servers acquired - [ ] "all" → all configured servers acquired (requires real subprocess)
- [ ] Comma-separated list → only listed servers acquired - [ ] Comma-separated list → only listed servers acquired (requires real subprocess)
- [ ] Mapping resolution: alias → actual server key(s) - [ ] Mapping resolution: alias → actual server key(s) (requires real subprocess)
- [ ] MCP meta functions appended for each started server - [ ] MCP meta functions appended for each started server (requires real subprocess)
- [ ] Old ToolScope dropped (releasing old server handles) - [ ] Old ToolScope dropped (releasing old server handles) (requires real subprocess)
- [ ] Loading spinner shown during acquisition - [ ] Loading spinner shown during acquisition (UI test)
- [ ] AbortSignal properly threaded through - [ ] AbortSignal properly threaded through (integration test)
### Server lifecycle during scope transitions ### Server lifecycle during scope transitions
- [ ] Enter role with MCP: servers start - [ ] Enter role with MCP: servers start (integration test)
- [ ] Exit role: servers stop (handle dropped) - [ ] Exit role: servers stop (handle dropped) (integration test)
- [ ] Enter role A (MCP-X) → exit → enter role B (MCP-Y): - [ ] Enter role A (MCP-X) → exit → enter role B (MCP-Y):
X stops, Y starts X stops, Y starts (integration test)
- [ ] Enter role with MCP → exit to no MCP: servers stop, - [ ] Enter role with MCP → exit to no MCP: servers stop,
global MCP restored global MCP restored (integration test)
- [ ] Start REPL with global MCP → enter agent with different MCP: - [ ] Start REPL with global MCP → enter agent with different MCP:
agent MCP takes over agent MCP takes over (integration test)
- [ ] Exit agent: agent MCP stops, global MCP restored - [ ] Exit agent: agent MCP stops, global MCP restored (integration test)
### MCP tool invocation chain ### MCP tool invocation chain
- [ ] LLM calls mcp__search_<server> → search results returned - [ ] LLM calls mcp__search_<server> → search results returned (integration test)
- [ ] LLM calls mcp__describe_<server> tool_name → schema returned - [ ] LLM calls mcp__describe_<server> tool_name → schema returned (integration test)
- [ ] LLM calls mcp__invoke_<server> tool args → tool executed - [ ] LLM calls mcp__invoke_<server> tool args → tool executed (integration test)
- [ ] Server not found → "MCP server not found in runtime" error - [ ] Server not found → "MCP server not found in runtime" error (tested via McpRuntime.get)
- [ ] Tool not found → appropriate error - [ ] Tool not found → appropriate error (requires live server)
### MCP support flag ### MCP support flag
- [ ] mcp_server_support=false → no MCP servers started - [x] mcp_server_support=false → no MCP servers started
- [ ] mcp_server_support=false + agent with MCP → error (blocks) - [ ] mcp_server_support=false + agent with MCP → error (blocks) (requires agent init)
- [ ] mcp_server_support=false + role with MCP → warning, continues - [ ] mcp_server_support=false + role with MCP → warning, continues (requires role init)
- [ ] .set mcp_server_support true → MCP servers start - [ ] .set mcp_server_support true → MCP servers start (requires live server)
### MCP in child agents ### MCP in child agents
- [ ] Child agent MCP servers acquired via factory - [ ] Child agent MCP servers acquired via factory (integration test)
- [ ] Child agent MCP runtime populated - [ ] Child agent MCP runtime populated (integration test)
- [ ] Child agent MCP tool invocations work - [ ] Child agent MCP tool invocations work (integration test)
- [ ] Child agent exit drops MCP handles - [ ] Child agent exit drops MCP handles (integration test)
## Context switching scenarios (comprehensive) ## Context switching scenarios (comprehensive)
- [ ] No MCP → role with MCP → exit role → no MCP - [ ] No MCP → role with MCP → exit role → no MCP (integration test)
- [ ] Global MCP-A → role MCP-B → exit role → global MCP-A - [ ] Global MCP-A → role MCP-B → exit role → global MCP-A (integration test)
- [ ] Global MCP-A → agent MCP-B → exit agent → global MCP-A - [ ] Global MCP-A → agent MCP-B → exit agent → global MCP-A (integration test)
- [ ] Role MCP-A → session MCP-B (overrides) → exit session - [ ] Role MCP-A → session MCP-B (overrides) → exit session (integration test)
- [ ] Agent MCP → child agent MCP → child exits → parent MCP intact - [ ] Agent MCP → child agent MCP → child exits → parent MCP intact (integration test)
- [ ] .set enabled_mcp_servers X → .set enabled_mcp_servers Y: - [ ] .set enabled_mcp_servers X → .set enabled_mcp_servers Y:
X released, Y acquired X released, Y acquired (integration test)
- [ ] .set enabled_mcp_servers null → all released - [ ] .set enabled_mcp_servers null → all released (integration test)
## Additional behaviors tested (not in original plan)
- [x] McpServerKey equality: same spec → equal keys
- [x] McpServerKey inequality: different names → different keys
- [x] McpServerKey inequality: different commands → different keys
- [x] McpServerKey env coercion: Bool/Int → String
- [x] McpFactory default has empty active map
- [x] McpServer::is_remote() true for Http/Sse, false for Stdio
- [x] McpServer::validate() all cross-field conflicts (6 cases)
- [x] McpServersConfig: empty servers map, multiple servers, cwd field
- [x] McpRegistry: default state, config accessor
- [x] McpRegistry: resolve with whitespace trimming
- [x] McpRegistry: resolve all-nonexistent returns empty
- [x] rebuild_tool_scope: no mcp_config yields empty runtime
- [x] rebuild_tool_scope: preserves tool_tracker across rebuild
- [x] rebuild_tool_scope: REPL mode appends user interaction functions
- [x] rebuild_tool_scope: CMD mode excludes user interaction functions
- [x] MCP meta function name prefix constants are correct
- [x] ToolScope default: empty functions, runtime, tracker
## Old code reference ## Old code reference
- `src/mcp/mod.rs` — McpRegistry, init, reinit, start/stop - `src/mcp/mod.rs` — McpRegistry, init, reinit, start/stop
+200
View File
@@ -137,3 +137,203 @@ impl McpFactory {
Ok(handle) Ok(handle)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::mcp::{JsonField, McpServer, McpTransportType};
use std::collections::HashMap;
fn stdio_spec(
command: &str,
args: Option<Vec<String>>,
env: Option<HashMap<String, JsonField>>,
) -> McpServer {
McpServer {
transport_type: McpTransportType::Stdio,
command: Some(command.to_string()),
args,
env,
cwd: None,
url: None,
headers: None,
}
}
fn remote_spec(
transport: McpTransportType,
url: &str,
headers: Option<HashMap<String, String>>,
) -> McpServer {
McpServer {
transport_type: transport,
command: None,
args: None,
env: None,
cwd: None,
url: Some(url.to_string()),
headers,
}
}
#[test]
fn key_from_stdio_spec_captures_command_args_env() {
let mut env = HashMap::new();
env.insert("TOKEN".into(), JsonField::Str("abc".into()));
let spec = stdio_spec("npx", Some(vec!["-y".into(), "server".into()]), Some(env));
let key = McpServerKey::from_spec("my-server", &spec);
assert_eq!(key.name, "my-server");
match &key.transport {
McpTransportKey::Stdio { command, args, env } => {
assert_eq!(command, "npx");
assert_eq!(args, &["-y", "server"]);
assert_eq!(env, &[("TOKEN".to_string(), "abc".to_string())]);
}
_ => panic!("expected Stdio transport key"),
}
}
#[test]
fn key_from_stdio_spec_sorts_args_and_env() {
let mut env = HashMap::new();
env.insert("Z_VAR".into(), JsonField::Str("z".into()));
env.insert("A_VAR".into(), JsonField::Int(42));
let spec = stdio_spec(
"cmd",
Some(vec!["charlie".into(), "alpha".into(), "bravo".into()]),
Some(env),
);
let key = McpServerKey::from_spec("s", &spec);
match &key.transport {
McpTransportKey::Stdio { args, env, .. } => {
assert_eq!(args, &["alpha", "bravo", "charlie"]);
assert_eq!(env[0].0, "A_VAR");
assert_eq!(env[0].1, "42");
assert_eq!(env[1].0, "Z_VAR");
assert_eq!(env[1].1, "z");
}
_ => panic!("expected Stdio"),
}
}
#[test]
fn key_from_stdio_spec_defaults_empty_when_none() {
let spec = stdio_spec("echo", None, None);
let key = McpServerKey::from_spec("bare", &spec);
match &key.transport {
McpTransportKey::Stdio { command, args, env } => {
assert_eq!(command, "echo");
assert!(args.is_empty());
assert!(env.is_empty());
}
_ => panic!("expected Stdio"),
}
}
#[test]
fn key_from_remote_http_spec() {
let spec = remote_spec(McpTransportType::Http, "http://localhost:8080", None);
let key = McpServerKey::from_spec("http-srv", &spec);
assert_eq!(key.name, "http-srv");
match &key.transport {
McpTransportKey::Remote {
transport_type,
url,
headers,
} => {
assert_eq!(*transport_type, McpTransportType::Http);
assert_eq!(url, "http://localhost:8080");
assert!(headers.is_empty());
}
_ => panic!("expected Remote"),
}
}
#[test]
fn key_from_remote_sse_spec_with_sorted_headers() {
let mut hdrs = HashMap::new();
hdrs.insert("Z-Key".into(), "z-val".into());
hdrs.insert("A-Key".into(), "a-val".into());
let spec = remote_spec(McpTransportType::Sse, "http://sse.example.com", Some(hdrs));
let key = McpServerKey::from_spec("sse-srv", &spec);
match &key.transport {
McpTransportKey::Remote { headers, .. } => {
assert_eq!(headers[0], ("A-Key".to_string(), "a-val".to_string()));
assert_eq!(headers[1], ("Z-Key".to_string(), "z-val".to_string()));
}
_ => panic!("expected Remote"),
}
}
#[test]
fn key_equality_same_spec_produces_equal_keys() {
let spec = stdio_spec("npx", Some(vec!["a".into()]), None);
let k1 = McpServerKey::from_spec("s", &spec);
let k2 = McpServerKey::from_spec("s", &spec);
assert_eq!(k1, k2);
}
#[test]
fn key_inequality_different_names() {
let spec = stdio_spec("npx", None, None);
let k1 = McpServerKey::from_spec("a", &spec);
let k2 = McpServerKey::from_spec("b", &spec);
assert_ne!(k1, k2);
}
#[test]
fn key_inequality_different_commands() {
let s1 = stdio_spec("npx", None, None);
let s2 = stdio_spec("node", None, None);
let k1 = McpServerKey::from_spec("s", &s1);
let k2 = McpServerKey::from_spec("s", &s2);
assert_ne!(k1, k2);
}
#[test]
fn key_env_bool_and_int_coerce_to_string() {
let mut env = HashMap::new();
env.insert("FLAG".into(), JsonField::Bool(true));
env.insert("PORT".into(), JsonField::Int(3000));
let spec = stdio_spec("cmd", None, Some(env));
let key = McpServerKey::from_spec("s", &spec);
match &key.transport {
McpTransportKey::Stdio { env, .. } => {
let map: HashMap<&str, &str> =
env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
assert_eq!(map["FLAG"], "true");
assert_eq!(map["PORT"], "3000");
}
_ => panic!("expected Stdio"),
}
}
#[test]
fn factory_try_get_active_returns_none_when_empty() {
let factory = McpFactory::default();
let spec = stdio_spec("cmd", None, None);
let key = McpServerKey::from_spec("s", &spec);
assert!(factory.try_get_active(&key).is_none());
}
#[test]
fn factory_try_get_active_returns_none_for_unknown_key() {
let factory = McpFactory::default();
let spec = stdio_spec("cmd", None, None);
let key = McpServerKey::from_spec("s", &spec);
assert!(factory.try_get_active(&key).is_none());
}
#[test]
fn factory_default_has_empty_active_map() {
let factory = McpFactory::default();
let map = factory.active.lock();
assert!(map.is_empty());
}
}
+161 -3
View File
@@ -2373,6 +2373,11 @@ mod tests {
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use crate::function::ToolCall;
use crate::mcp::{McpServer, McpServersConfig, McpTransportType};
use crate::utils;
use crate::vault::Vault;
use super::super::mcp_factory::McpFactory;
struct TestConfigDirGuard { struct TestConfigDirGuard {
key: String, key: String,
@@ -2419,8 +2424,8 @@ mod tests {
fn default_app_state() -> Arc<AppState> { fn default_app_state() -> Arc<AppState> {
Arc::new(AppState { Arc::new(AppState {
config: Arc::new(AppConfig::default()), config: Arc::new(AppConfig::default()),
vault: Arc::new(crate::vault::Vault::default()), vault: Arc::new(Vault::default()),
mcp_factory: Arc::new(super::super::mcp_factory::McpFactory::default()), mcp_factory: Arc::new(McpFactory::default()),
rag_cache: Arc::new(RagCache::default()), rag_cache: Arc::new(RagCache::default()),
mcp_config: None, mcp_config: None,
mcp_log_path: None, mcp_log_path: None,
@@ -2563,7 +2568,7 @@ mod tests {
.build() .build()
.unwrap() .unwrap()
.block_on(async { .block_on(async {
ctx.use_agent(&app, &agent_name, None, crate::utils::create_abort_signal()) ctx.use_agent(&app, &agent_name, None, utils::create_abort_signal())
.await .await
.unwrap(); .unwrap();
}); });
@@ -2606,4 +2611,157 @@ mod tests {
let ctx = create_test_ctx(); let ctx = create_test_ctx();
assert!(ctx.root_escalation_queue().is_none()); assert!(ctx.root_escalation_queue().is_none());
} }
fn app_state_with_mcp_config(mcp_server_support: bool, server_names: &[&str]) -> Arc<AppState> {
let mut app_config = AppConfig::default();
app_config.mcp_server_support = mcp_server_support;
let mcp_config = if server_names.is_empty() {
None
} else {
let mut servers = HashMap::new();
for name in server_names {
servers.insert(
name.to_string(),
McpServer {
transport_type: McpTransportType::Stdio,
command: Some("echo".to_string()),
args: None,
env: None,
cwd: None,
url: None,
headers: None,
},
);
}
Some(McpServersConfig {
mcp_servers: servers,
})
};
Arc::new(AppState {
config: Arc::new(app_config),
vault: Arc::new(Vault::default()),
mcp_factory: Arc::new(McpFactory::default()),
rag_cache: Arc::new(RagCache::default()),
mcp_config,
mcp_log_path: None,
mcp_registry: None,
functions: Functions::default(),
})
}
fn run_async<F: Future>(f: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(f)
}
#[test]
fn rebuild_tool_scope_mcp_disabled_skips_servers() {
let app_state = app_state_with_mcp_config(false, &["github", "slack"]);
let mut ctx = RequestContext::new(app_state, WorkingMode::Cmd);
let app = ctx.app.config.clone();
let abort = utils::create_abort_signal();
run_async(ctx.rebuild_tool_scope(&app, Some("all".to_string()), abort)).unwrap();
assert!(ctx.tool_scope.mcp_runtime.is_empty());
}
#[test]
fn rebuild_tool_scope_no_enabled_servers_yields_empty_runtime() {
let app_state = app_state_with_mcp_config(true, &["github"]);
let mut ctx = RequestContext::new(app_state, WorkingMode::Cmd);
let app = ctx.app.config.clone();
let abort = utils::create_abort_signal();
run_async(ctx.rebuild_tool_scope(&app, None, abort)).unwrap();
assert!(ctx.tool_scope.mcp_runtime.is_empty());
}
#[test]
fn rebuild_tool_scope_no_mcp_config_yields_empty_runtime() {
let app_state = app_state_with_mcp_config(true, &[]);
let mut ctx = RequestContext::new(app_state, WorkingMode::Cmd);
let app = ctx.app.config.clone();
let abort = utils::create_abort_signal();
run_async(ctx.rebuild_tool_scope(&app, Some("all".to_string()), abort)).unwrap();
assert!(ctx.tool_scope.mcp_runtime.is_empty());
}
#[test]
fn rebuild_tool_scope_preserves_tool_tracker() {
let app_state = app_state_with_mcp_config(false, &[]);
let mut ctx = RequestContext::new(app_state, WorkingMode::Cmd);
let dummy = ToolCall {
name: "test_tool".to_string(),
..Default::default()
};
ctx.tool_scope.tool_tracker.record_call(dummy);
let app = ctx.app.config.clone();
let abort = utils::create_abort_signal();
run_async(ctx.rebuild_tool_scope(&app, None, abort)).unwrap();
let check_call = ToolCall {
name: "test_tool".to_string(),
..Default::default()
};
assert!(
ctx.tool_scope
.tool_tracker
.check_loop(&check_call)
.is_none()
);
}
#[test]
fn rebuild_tool_scope_repl_mode_appends_user_interaction_functions() {
let app_state = app_state_with_mcp_config(false, &[]);
let mut ctx = RequestContext::new(app_state, WorkingMode::Repl);
let app = ctx.app.config.clone();
let abort = utils::create_abort_signal();
run_async(ctx.rebuild_tool_scope(&app, None, abort)).unwrap();
let names: Vec<String> = ctx
.tool_scope
.functions
.declarations()
.iter()
.map(|f| f.name.clone())
.collect();
assert!(
names.iter().any(|n| n.starts_with("user__")),
"REPL mode should include user interaction functions, got: {names:?}"
);
}
#[test]
fn rebuild_tool_scope_cmd_mode_no_user_interaction_functions() {
let app_state = app_state_with_mcp_config(false, &[]);
let mut ctx = RequestContext::new(app_state, WorkingMode::Cmd);
let app = ctx.app.config.clone();
let abort = utils::create_abort_signal();
run_async(ctx.rebuild_tool_scope(&app, None, abort)).unwrap();
let names: Vec<String> = ctx
.tool_scope
.functions
.declarations()
.iter()
.map(|f| f.name.clone())
.collect();
assert!(
!names.iter().any(|n| n.starts_with("user__")),
"CMD mode should NOT include user interaction functions, got: {names:?}"
);
}
} }
+44
View File
@@ -165,3 +165,47 @@ impl McpRuntime {
server_handle.call_tool(request).await.map_err(Into::into) server_handle.call_tool(request).await.map_err(Into::into)
} }
} }
#[cfg(test)]
mod tests {
use crate::function::ToolCall;
use super::*;
#[test]
fn mcp_runtime_new_is_empty() {
let runtime = McpRuntime::new();
assert!(runtime.is_empty());
assert!(runtime.server_names().is_empty());
}
#[test]
fn mcp_runtime_default_is_empty() {
let runtime = McpRuntime::default();
assert!(runtime.is_empty());
}
#[test]
fn mcp_runtime_get_returns_none_for_missing_server() {
let runtime = McpRuntime::new();
assert!(runtime.get("nonexistent").is_none());
}
#[test]
fn tool_scope_default_has_empty_mcp_runtime() {
let scope = ToolScope::default();
assert!(scope.mcp_runtime.is_empty());
}
#[test]
fn tool_scope_default_has_empty_functions() {
let scope = ToolScope::default();
assert!(scope.functions.is_empty());
}
#[test]
fn tool_scope_default_tracker_has_no_loops() {
let scope = ToolScope::default();
let dummy_call = ToolCall::default();
assert!(scope.tool_tracker.check_loop(&dummy_call).is_none());
}
}
+392
View File
@@ -440,3 +440,395 @@ async fn spawn_stdio_mcp_server(
); );
Ok(service) Ok(service)
} }
#[cfg(test)]
mod tests {
use super::*;
fn stdio_server(command: &str) -> McpServer {
McpServer {
transport_type: McpTransportType::Stdio,
command: Some(command.to_string()),
args: None,
env: None,
cwd: None,
url: None,
headers: None,
}
}
fn http_server(url: &str) -> McpServer {
McpServer {
transport_type: McpTransportType::Http,
command: None,
args: None,
env: None,
cwd: None,
url: Some(url.to_string()),
headers: None,
}
}
fn sse_server(url: &str) -> McpServer {
McpServer {
transport_type: McpTransportType::Sse,
command: None,
args: None,
env: None,
cwd: None,
url: Some(url.to_string()),
headers: None,
}
}
fn make_registry_with_config(server_names: &[&str]) -> McpRegistry {
let mut mcp_servers = HashMap::new();
for name in server_names {
mcp_servers.insert(name.to_string(), stdio_server("echo"));
}
McpRegistry {
config: Some(McpServersConfig { mcp_servers }),
..Default::default()
}
}
#[test]
fn validate_stdio_with_command_succeeds() {
let spec = stdio_server("npx");
assert!(spec.validate("test").is_ok());
}
#[test]
fn validate_stdio_missing_command_fails() {
let spec = McpServer {
transport_type: McpTransportType::Stdio,
command: None,
args: None,
env: None,
cwd: None,
url: None,
headers: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("missing a \"command\" field"));
}
#[test]
fn validate_stdio_with_url_fails() {
let spec = McpServer {
transport_type: McpTransportType::Stdio,
command: Some("cmd".into()),
args: None,
env: None,
cwd: None,
url: Some("http://localhost".into()),
headers: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("remote fields"));
}
#[test]
fn validate_stdio_with_headers_fails() {
let mut headers = HashMap::new();
headers.insert("Auth".into(), "Bearer tok".into());
let spec = McpServer {
transport_type: McpTransportType::Stdio,
command: Some("cmd".into()),
args: None,
env: None,
cwd: None,
url: None,
headers: Some(headers),
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("remote fields"));
}
#[test]
fn validate_http_with_url_succeeds() {
let spec = http_server("http://localhost:8080");
assert!(spec.validate("test").is_ok());
}
#[test]
fn validate_http_missing_url_fails() {
let spec = McpServer {
transport_type: McpTransportType::Http,
command: None,
args: None,
env: None,
cwd: None,
url: None,
headers: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("missing a \"url\" field"));
}
#[test]
fn validate_http_with_command_fails() {
let spec = McpServer {
transport_type: McpTransportType::Http,
command: Some("npx".into()),
args: None,
env: None,
cwd: None,
url: Some("http://localhost".into()),
headers: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("stdio fields"));
}
#[test]
fn validate_http_with_args_fails() {
let spec = McpServer {
transport_type: McpTransportType::Http,
command: None,
args: Some(vec!["--flag".into()]),
env: None,
cwd: None,
url: Some("http://localhost".into()),
headers: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("stdio fields"));
}
#[test]
fn validate_http_with_cwd_fails() {
let spec = McpServer {
transport_type: McpTransportType::Http,
command: None,
args: None,
env: None,
cwd: Some("/tmp".into()),
url: Some("http://localhost".into()),
headers: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("stdio fields"));
}
#[test]
fn validate_sse_with_url_succeeds() {
let spec = sse_server("http://sse.example.com");
assert!(spec.validate("test").is_ok());
}
#[test]
fn validate_sse_missing_url_fails() {
let spec = McpServer {
transport_type: McpTransportType::Sse,
command: None,
args: None,
env: None,
cwd: None,
url: None,
headers: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("missing a \"url\" field"));
}
#[test]
fn is_remote_true_for_http_and_sse() {
assert!(http_server("http://x").is_remote());
assert!(sse_server("http://x").is_remote());
}
#[test]
fn is_remote_false_for_stdio() {
assert!(!stdio_server("cmd").is_remote());
}
#[test]
fn deserialize_stdio_server_from_json() {
let json = r#"{
"mcpServers": {
"my-server": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server"]
}
}
}"#;
let config: McpServersConfig = serde_json::from_str(json).unwrap();
assert!(config.mcp_servers.contains_key("my-server"));
let spec = &config.mcp_servers["my-server"];
assert_eq!(spec.transport_type, McpTransportType::Stdio);
assert_eq!(spec.command.as_deref(), Some("npx"));
assert_eq!(
spec.args.as_ref().unwrap(),
&["-y", "@modelcontextprotocol/server"]
);
}
#[test]
fn deserialize_http_server_from_json() {
let json = r#"{
"mcpServers": {
"remote": {
"type": "http",
"url": "http://localhost:8080/mcp",
"headers": {"Authorization": "Bearer tok"}
}
}
}"#;
let config: McpServersConfig = serde_json::from_str(json).unwrap();
let spec = &config.mcp_servers["remote"];
assert_eq!(spec.transport_type, McpTransportType::Http);
assert_eq!(spec.url.as_deref(), Some("http://localhost:8080/mcp"));
assert_eq!(
spec.headers.as_ref().unwrap()["Authorization"],
"Bearer tok"
);
}
#[test]
fn deserialize_env_with_mixed_types() {
let json = r#"{
"mcpServers": {
"s": {
"type": "stdio",
"command": "cmd",
"env": {
"STR_VAR": "hello",
"BOOL_VAR": true,
"INT_VAR": 42
}
}
}
}"#;
let config: McpServersConfig = serde_json::from_str(json).unwrap();
let env = config.mcp_servers["s"].env.as_ref().unwrap();
assert!(matches!(env["STR_VAR"], JsonField::Str(ref s) if s == "hello"));
assert!(matches!(env["BOOL_VAR"], JsonField::Bool(true)));
assert!(matches!(env["INT_VAR"], JsonField::Int(42)));
}
#[test]
fn deserialize_multiple_servers() {
let json = r#"{
"mcpServers": {
"github": { "type": "stdio", "command": "gh-mcp" },
"remote-api": { "type": "http", "url": "http://api.example.com" }
}
}"#;
let config: McpServersConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.mcp_servers.len(), 2);
assert!(config.mcp_servers.contains_key("github"));
assert!(config.mcp_servers.contains_key("remote-api"));
}
#[test]
fn deserialize_empty_servers_map() {
let json = r#"{ "mcpServers": {} }"#;
let config: McpServersConfig = serde_json::from_str(json).unwrap();
assert!(config.mcp_servers.is_empty());
}
#[test]
fn deserialize_server_with_cwd() {
let json = r#"{
"mcpServers": {
"s": {
"type": "stdio",
"command": "cmd",
"cwd": "/tmp/work"
}
}
}"#;
let config: McpServersConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.mcp_servers["s"].cwd.as_deref(), Some("/tmp/work"));
}
#[test]
fn resolve_all_returns_all_configured_servers() {
let registry = make_registry_with_config(&["github", "slack", "jira"]);
let mut ids = registry.resolve_server_ids(Some("all".to_string()));
ids.sort();
assert_eq!(ids, vec!["github", "jira", "slack"]);
}
#[test]
fn resolve_comma_separated_returns_matching_servers() {
let registry = make_registry_with_config(&["github", "slack", "jira"]);
let mut ids = registry.resolve_server_ids(Some("github, jira".to_string()));
ids.sort();
assert_eq!(ids, vec!["github", "jira"]);
}
#[test]
fn resolve_single_server_name() {
let registry = make_registry_with_config(&["github", "slack"]);
let ids = registry.resolve_server_ids(Some("slack".to_string()));
assert_eq!(ids, vec!["slack"]);
}
#[test]
fn resolve_none_returns_empty() {
let registry = make_registry_with_config(&["github"]);
let ids = registry.resolve_server_ids(None);
assert!(ids.is_empty());
}
#[test]
fn resolve_no_config_returns_empty() {
let registry = McpRegistry::default();
let ids = registry.resolve_server_ids(Some("all".to_string()));
assert!(ids.is_empty());
}
#[test]
fn resolve_nonexistent_server_filtered_out() {
let registry = make_registry_with_config(&["github"]);
let ids = registry.resolve_server_ids(Some("github, nonexistent".to_string()));
assert_eq!(ids, vec!["github"]);
}
#[test]
fn resolve_all_nonexistent_returns_empty() {
let registry = make_registry_with_config(&["github"]);
let ids = registry.resolve_server_ids(Some("foo, bar".to_string()));
assert!(ids.is_empty());
}
#[test]
fn resolve_trims_whitespace() {
let registry = make_registry_with_config(&["github", "slack"]);
let mut ids = registry.resolve_server_ids(Some(" github , slack ".to_string()));
ids.sort();
assert_eq!(ids, vec!["github", "slack"]);
}
#[test]
fn registry_default_is_empty() {
let registry = McpRegistry::default();
assert!(registry.is_empty());
assert!(registry.list_started_servers().is_empty());
assert!(registry.mcp_config().is_none());
assert!(registry.log_path().is_none());
}
#[test]
fn registry_with_config_reports_config() {
let registry = make_registry_with_config(&["github"]);
assert!(registry.mcp_config().is_some());
assert!(
registry
.mcp_config()
.unwrap()
.mcp_servers
.contains_key("github")
);
}
#[test]
fn meta_function_prefixes_are_correct() {
assert_eq!(MCP_INVOKE_META_FUNCTION_NAME_PREFIX, "mcp_invoke");
assert_eq!(MCP_SEARCH_META_FUNCTION_NAME_PREFIX, "mcp_search");
assert_eq!(MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, "mcp_describe");
}
}