a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
324 lines
11 KiB
Rust
324 lines
11 KiB
Rust
use super::*;
|
|
|
|
#[test]
|
|
fn load_for_dir_without_project_does_not_load_process_cwd_config() {
|
|
let _guard = crate::storage::lock_test_env();
|
|
let original_cwd = std::env::current_dir().expect("current cwd");
|
|
let previous_home = std::env::var_os("JCODE_HOME");
|
|
let home = tempfile::tempdir().expect("home tempdir");
|
|
let project = tempfile::tempdir().expect("project tempdir");
|
|
crate::env::set_var("JCODE_HOME", home.path());
|
|
std::env::set_current_dir(project.path()).expect("set project cwd");
|
|
std::fs::write(
|
|
project.path().join(".mcp.json"),
|
|
r#"{"mcpServers":{"cwd-only":{"command":"cwd-server"}}}"#,
|
|
)
|
|
.expect("write project MCP config");
|
|
|
|
let result = std::panic::catch_unwind(|| {
|
|
let unbound = McpConfig::load_for_dir(None);
|
|
assert!(!unbound.servers.contains_key("cwd-only"));
|
|
|
|
let bound = McpConfig::load_for_dir(Some(project.path()));
|
|
assert!(bound.servers.contains_key("cwd-only"));
|
|
});
|
|
|
|
std::env::set_current_dir(original_cwd).expect("restore cwd");
|
|
if let Some(previous_home) = previous_home {
|
|
crate::env::set_var("JCODE_HOME", previous_home);
|
|
} else {
|
|
crate::env::remove_var("JCODE_HOME");
|
|
}
|
|
result.expect("MCP cwd isolation assertions");
|
|
}
|
|
|
|
#[test]
|
|
fn test_json_rpc_request_serialization() {
|
|
let request = JsonRpcRequest::new(1, "tools/list", None);
|
|
let json = serde_json::to_string(&request).unwrap();
|
|
assert!(json.contains("\"jsonrpc\":\"2.0\""));
|
|
assert!(json.contains("\"id\":1"));
|
|
assert!(json.contains("\"method\":\"tools/list\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_json_rpc_response_deserialization() {
|
|
let json = r#"{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}"#;
|
|
let response: JsonRpcResponse = serde_json::from_str(json).unwrap();
|
|
assert_eq!(response.id, Some(1));
|
|
assert!(response.result.is_some());
|
|
assert!(response.error.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_json_rpc_error_response() {
|
|
let json = r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Invalid Request"}}"#;
|
|
let response: JsonRpcResponse = serde_json::from_str(json).unwrap();
|
|
assert!(response.error.is_some());
|
|
let err = response.error.unwrap();
|
|
assert_eq!(err.code, -32600);
|
|
assert_eq!(err.message, "Invalid Request");
|
|
}
|
|
|
|
#[test]
|
|
fn test_mcp_config_deserialization() {
|
|
let json = r#"{
|
|
"servers": {
|
|
"test-server": {
|
|
"command": "/usr/bin/test-mcp",
|
|
"args": ["--port", "8080"],
|
|
"env": {"API_KEY": "secret"}
|
|
}
|
|
}
|
|
}"#;
|
|
let config: McpConfig = serde_json::from_str(json).unwrap();
|
|
assert_eq!(config.servers.len(), 1);
|
|
let server = config.servers.get("test-server").unwrap();
|
|
assert_eq!(server.command, "/usr/bin/test-mcp");
|
|
assert_eq!(server.args, vec!["--port", "8080"]);
|
|
assert_eq!(server.env.get("API_KEY"), Some(&"secret".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_mcp_config_empty() {
|
|
let json = r#"{}"#;
|
|
let config: McpConfig = serde_json::from_str(json).unwrap();
|
|
assert!(config.servers.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_mcp_config_accepts_claude_mcp_servers_key() {
|
|
// Claude Code uses `mcpServers`, not `servers`.
|
|
let json = r#"{
|
|
"mcpServers": {
|
|
"claude-server": {
|
|
"command": "npx",
|
|
"args": ["-y", "some-mcp"]
|
|
}
|
|
}
|
|
}"#;
|
|
let config: McpConfig = serde_json::from_str(json).unwrap();
|
|
assert_eq!(config.servers.len(), 1);
|
|
let server = config.servers.get("claude-server").unwrap();
|
|
assert_eq!(server.command, "npx");
|
|
assert!(server.is_stdio());
|
|
}
|
|
|
|
#[test]
|
|
fn test_mcp_http_server_is_not_stdio() {
|
|
let json = r#"{
|
|
"mcpServers": {
|
|
"remote": {
|
|
"type": "http",
|
|
"url": "https://example.com/mcp"
|
|
}
|
|
}
|
|
}"#;
|
|
let config: McpConfig = serde_json::from_str(json).unwrap();
|
|
let server = config.servers.get("remote").unwrap();
|
|
assert!(!server.is_stdio());
|
|
assert_eq!(server.url.as_deref(), Some("https://example.com/mcp"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_claude_json_global_and_project_servers() {
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let cwd = temp.path().join("myproject");
|
|
std::fs::create_dir_all(&cwd).unwrap();
|
|
let claude_json = temp.path().join(".claude.json");
|
|
|
|
let body = serde_json::json!({
|
|
"mcpServers": {
|
|
"global-srv": { "command": "global-bin" }
|
|
},
|
|
"projects": {
|
|
cwd.to_string_lossy(): {
|
|
"mcpServers": {
|
|
"project-srv": { "command": "project-bin", "args": ["--flag"] }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
std::fs::write(&claude_json, serde_json::to_string_pretty(&body).unwrap()).unwrap();
|
|
|
|
let config = McpConfig::load_claude_json(&claude_json, Some(&cwd));
|
|
assert_eq!(config.servers.len(), 2);
|
|
assert_eq!(
|
|
config.servers.get("global-srv").unwrap().command,
|
|
"global-bin"
|
|
);
|
|
assert_eq!(
|
|
config.servers.get("project-srv").unwrap().command,
|
|
"project-bin"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_project_locals_resolves_against_given_dir_not_cwd() {
|
|
// Issue #420: remote/client sessions must load project-local MCP config
|
|
// from the session working directory, not the server process cwd.
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let project = temp.path().join("client-project");
|
|
std::fs::create_dir_all(&project).unwrap();
|
|
std::fs::write(
|
|
project.join(".mcp.json"),
|
|
r#"{"mcpServers":{"example-server":{"command":"npx","args":["-y","some-mcp-server"]}}}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
// The process cwd (this repo) is unrelated to `project`; resolution must
|
|
// come from the explicit path.
|
|
let config = McpConfig::load_project_locals(&project);
|
|
assert_eq!(config.servers.len(), 1);
|
|
let server = config.servers.get("example-server").unwrap();
|
|
assert_eq!(server.command, "npx");
|
|
assert!(server.is_stdio());
|
|
|
|
// A directory with no project-local config yields nothing.
|
|
let empty = temp.path().join("empty-project");
|
|
std::fs::create_dir_all(&empty).unwrap();
|
|
assert!(McpConfig::load_project_locals(&empty).servers.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_project_locals_merge_order() {
|
|
// `.jcode/mcp.json` loads first, then `.mcp.json` overrides same-named
|
|
// servers, then `.claude/mcp.json`.
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let project = temp.path();
|
|
std::fs::create_dir_all(project.join(".jcode")).unwrap();
|
|
std::fs::create_dir_all(project.join(".claude")).unwrap();
|
|
std::fs::write(
|
|
project.join(".jcode/mcp.json"),
|
|
r#"{"servers":{"shared-name":{"command":"jcode-bin"},"jcode-only":{"command":"a"}}}"#,
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
project.join(".mcp.json"),
|
|
r#"{"mcpServers":{"shared-name":{"command":"claude-bin"}}}"#,
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
project.join(".claude/mcp.json"),
|
|
r#"{"mcpServers":{"legacy-only":{"command":"c"}}}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let config = McpConfig::load_project_locals(project);
|
|
assert_eq!(config.servers.len(), 3);
|
|
assert_eq!(
|
|
config.servers.get("shared-name").unwrap().command,
|
|
"claude-bin",
|
|
".mcp.json must override .jcode/mcp.json for same-named servers"
|
|
);
|
|
assert!(config.servers.contains_key("jcode-only"));
|
|
assert!(config.servers.contains_key("legacy-only"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_server_enabled_defaults_true() {
|
|
// Existing configs without the flag keep current behavior (issue #436).
|
|
let json = r#"{"servers":{"srv":{"command":"bin"}}}"#;
|
|
let config: McpConfig = serde_json::from_str(json).unwrap();
|
|
assert!(config.servers.get("srv").unwrap().is_enabled());
|
|
}
|
|
|
|
#[test]
|
|
fn test_server_enabled_false_opencode_style() {
|
|
let json = r#"{"servers":{"srv":{"command":"bin","enabled":false}}}"#;
|
|
let config: McpConfig = serde_json::from_str(json).unwrap();
|
|
assert!(!config.servers.get("srv").unwrap().is_enabled());
|
|
}
|
|
|
|
#[test]
|
|
fn test_server_disabled_true_claude_style() {
|
|
let json = r#"{"mcpServers":{"srv":{"command":"bin","disabled":true}}}"#;
|
|
let config: McpConfig = serde_json::from_str(json).unwrap();
|
|
assert!(!config.servers.get("srv").unwrap().is_enabled());
|
|
}
|
|
|
|
#[test]
|
|
fn test_server_disabled_wins_over_enabled() {
|
|
// `disabled` (Claude Code style) wins when both spellings are present.
|
|
let json = r#"{"servers":{"srv":{"command":"bin","enabled":true,"disabled":true}}}"#;
|
|
let config: McpConfig = serde_json::from_str(json).unwrap();
|
|
assert!(!config.servers.get("srv").unwrap().is_enabled());
|
|
|
|
let json = r#"{"servers":{"srv":{"command":"bin","enabled":false,"disabled":false}}}"#;
|
|
let config: McpConfig = serde_json::from_str(json).unwrap();
|
|
assert!(config.servers.get("srv").unwrap().is_enabled());
|
|
}
|
|
|
|
#[test]
|
|
fn test_disabled_server_survives_save_roundtrip() {
|
|
// Disabled servers must stay in config (kept, not spawned), including
|
|
// through a save/load cycle.
|
|
let json = r#"{"servers":{"off":{"command":"bin","enabled":false}}}"#;
|
|
let config: McpConfig = serde_json::from_str(json).unwrap();
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let path = temp.path().join("mcp.json");
|
|
config.save_to_file(&path).unwrap();
|
|
let reloaded = McpConfig::load_from_file(&path).unwrap();
|
|
assert!(!reloaded.servers.get("off").unwrap().is_enabled());
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_def_deserialization() {
|
|
let json = r#"{
|
|
"name": "read_file",
|
|
"description": "Read a file from disk",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string"}
|
|
},
|
|
"required": ["path"]
|
|
}
|
|
}"#;
|
|
let tool: McpToolDef = serde_json::from_str(json).unwrap();
|
|
assert_eq!(tool.name, "read_file");
|
|
assert_eq!(tool.description, Some("Read a file from disk".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_call_result_text() {
|
|
let json = r#"{
|
|
"content": [{"type": "text", "text": "File contents here"}],
|
|
"isError": false
|
|
}"#;
|
|
let result: ToolCallResult = serde_json::from_str(json).unwrap();
|
|
assert!(!result.is_error);
|
|
assert_eq!(result.content.len(), 1);
|
|
match &result.content[0] {
|
|
ContentBlock::Text { text, .. } => assert_eq!(text, "File contents here"),
|
|
_ => panic!("Expected text block"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_call_result_error() {
|
|
let json = r#"{
|
|
"content": [{"type": "text", "text": "File not found"}],
|
|
"isError": true
|
|
}"#;
|
|
let result: ToolCallResult = serde_json::from_str(json).unwrap();
|
|
assert!(result.is_error);
|
|
}
|
|
|
|
#[test]
|
|
fn test_initialize_result() {
|
|
let json = r#"{
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {
|
|
"tools": {"listChanged": true}
|
|
},
|
|
"serverInfo": {
|
|
"name": "test-server",
|
|
"version": "1.0.0"
|
|
}
|
|
}"#;
|
|
let result: InitializeResult = serde_json::from_str(json).unwrap();
|
|
assert_eq!(result.protocol_version, "2024-11-05");
|
|
assert!(result.server_info.is_some());
|
|
}
|