test(protocols,skills): add 90 tests for MCP types + skill loader/runner

zclaw-protocols: +43 tests covering mcp_types serde, ContentBlock
variants, transport config builders, and domain type roundtrips.

zclaw-skills: +47 tests covering SKILL.md/TOML parsing, auto-classify,
PromptOnlySkill execution, and SkillManifest/SkillResult roundtrips.

Batch 8 of audit plan (plans/stateless-petting-rossum.md).
This commit is contained in:
iven
2026-04-19 11:24:57 +08:00
parent 226beb708b
commit beeb529d8f
6 changed files with 981 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
//! Tests for MCP Transport configuration (McpServerConfig)
//!
//! These tests cover McpServerConfig builder methods without spawning processes.
use std::collections::HashMap;
use zclaw_protocols::McpServerConfig;
#[test]
fn npx_config_creates_correct_command() {
let config = McpServerConfig::npx("@modelcontextprotocol/server-memory");
assert_eq!(config.command, "npx");
assert_eq!(config.args, vec!["-y", "@modelcontextprotocol/server-memory"]);
assert!(config.env.is_empty());
assert!(config.cwd.is_none());
}
#[test]
fn node_config_creates_correct_command() {
let config = McpServerConfig::node("/path/to/server.js");
assert_eq!(config.command, "node");
assert_eq!(config.args, vec!["/path/to/server.js"]);
}
#[test]
fn python_config_creates_correct_command() {
let config = McpServerConfig::python("mcp_server.py");
assert_eq!(config.command, "python");
assert_eq!(config.args, vec!["mcp_server.py"]);
}
#[test]
fn env_adds_variables() {
let config = McpServerConfig::node("server.js")
.env("API_KEY", "secret123")
.env("DEBUG", "true");
assert_eq!(config.env.get("API_KEY").unwrap(), "secret123");
assert_eq!(config.env.get("DEBUG").unwrap(), "true");
}
#[test]
fn cwd_sets_working_directory() {
let config = McpServerConfig::node("server.js").cwd("/tmp/work");
assert_eq!(config.cwd.unwrap(), "/tmp/work");
}
#[test]
fn combined_builder_pattern() {
let config = McpServerConfig::npx("@scope/server")
.env("PORT", "3000")
.cwd("/app");
assert_eq!(config.command, "npx");
assert_eq!(config.args.len(), 2);
assert_eq!(config.env.len(), 1);
assert_eq!(config.cwd.unwrap(), "/app");
}

View File

@@ -0,0 +1,186 @@
//! Tests for MCP domain types (mcp.rs) — McpTool, McpContent, McpResource, etc.
use std::collections::HashMap;
use zclaw_protocols::*;
// === McpTool ===
#[test]
fn mcp_tool_roundtrip() {
let tool = McpTool {
name: "search".to_string(),
description: "Search documents".to_string(),
input_schema: serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}),
};
let json = serde_json::to_string(&tool).unwrap();
let parsed: McpTool = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, "search");
assert_eq!(parsed.description, "Search documents");
}
#[test]
fn mcp_tool_empty_description() {
let tool = McpTool {
name: "ping".to_string(),
description: String::new(),
input_schema: serde_json::json!({}),
};
let parsed: McpTool = serde_json::from_str(&serde_json::to_string(&tool).unwrap()).unwrap();
assert!(parsed.description.is_empty());
}
// === McpContent ===
#[test]
fn mcp_content_text_roundtrip() {
let content = McpContent::Text { text: "hello".to_string() };
let json = serde_json::to_string(&content).unwrap();
let parsed: McpContent = serde_json::from_str(&json).unwrap();
match parsed {
McpContent::Text { text } => assert_eq!(text, "hello"),
_ => panic!("Expected Text"),
}
}
#[test]
fn mcp_content_image_roundtrip() {
let content = McpContent::Image {
data: "base64==".to_string(),
mime_type: "image/png".to_string(),
};
let json = serde_json::to_string(&content).unwrap();
let parsed: McpContent = serde_json::from_str(&json).unwrap();
match parsed {
McpContent::Image { data, mime_type } => {
assert_eq!(data, "base64==");
assert_eq!(mime_type, "image/png");
}
_ => panic!("Expected Image"),
}
}
#[test]
fn mcp_content_resource_roundtrip() {
let content = McpContent::Resource {
resource: McpResourceContent {
uri: "file:///test.txt".to_string(),
mime_type: Some("text/plain".to_string()),
text: Some("content".to_string()),
blob: None,
},
};
let json = serde_json::to_string(&content).unwrap();
let parsed: McpContent = serde_json::from_str(&json).unwrap();
match parsed {
McpContent::Resource { resource } => {
assert_eq!(resource.uri, "file:///test.txt");
assert_eq!(resource.text.unwrap(), "content");
}
_ => panic!("Expected Resource"),
}
}
// === McpToolCallRequest ===
#[test]
fn mcp_tool_call_request_serialization() {
let mut args = HashMap::new();
args.insert("query".to_string(), serde_json::json!("test"));
let req = McpToolCallRequest {
name: "search".to_string(),
arguments: args,
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"name\":\"search\""));
assert!(json.contains("\"query\":\"test\""));
}
// === McpToolCallResponse ===
#[test]
fn mcp_tool_call_response_parse_success() {
let json = r#"{"content":[{"type":"text","text":"found 3 results"}],"is_error":false}"#;
let resp: McpToolCallResponse = serde_json::from_str(json).unwrap();
assert!(!resp.is_error);
assert_eq!(resp.content.len(), 1);
}
#[test]
fn mcp_tool_call_response_parse_error() {
let json = r#"{"content":[{"type":"text","text":"tool not found"}],"is_error":true}"#;
let resp: McpToolCallResponse = serde_json::from_str(json).unwrap();
assert!(resp.is_error);
}
// === McpResource ===
#[test]
fn mcp_resource_roundtrip() {
let res = McpResource {
uri: "file:///doc.md".to_string(),
name: "Documentation".to_string(),
description: Some("Project docs".to_string()),
mime_type: Some("text/markdown".to_string()),
};
let json = serde_json::to_string(&res).unwrap();
let parsed: McpResource = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.uri, "file:///doc.md");
assert_eq!(parsed.description.unwrap(), "Project docs");
}
// === McpPrompt ===
#[test]
fn mcp_prompt_roundtrip() {
let prompt = McpPrompt {
name: "summarize".to_string(),
description: "Summarize text".to_string(),
arguments: vec![
McpPromptArgument {
name: "length".to_string(),
description: "Target length".to_string(),
required: false,
},
],
};
let json = serde_json::to_string(&prompt).unwrap();
let parsed: McpPrompt = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.arguments.len(), 1);
assert!(!parsed.arguments[0].required);
}
// === McpServerInfo ===
#[test]
fn mcp_server_info_roundtrip() {
let info = McpServerInfo {
name: "test-mcp".to_string(),
version: "2.0.0".to_string(),
protocol_version: "2024-11-05".to_string(),
};
let json = serde_json::to_string(&info).unwrap();
let parsed: McpServerInfo = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, "test-mcp");
assert_eq!(parsed.protocol_version, "2024-11-05");
}
// === McpCapabilities ===
#[test]
fn mcp_capabilities_default_empty() {
let caps = McpCapabilities::default();
assert!(caps.tools.is_none());
assert!(caps.resources.is_none());
assert!(caps.prompts.is_none());
}
#[test]
fn mcp_capabilities_with_tools() {
let caps = McpCapabilities {
tools: Some(McpToolCapabilities { list_changed: true }),
resources: None,
prompts: None,
};
let json = serde_json::to_string(&caps).unwrap();
assert!(json.contains("\"list_changed\":true"));
}

View File

@@ -0,0 +1,267 @@
//! Tests for MCP JSON-RPC types (mcp_types.rs)
//!
//! Covers: serialization, deserialization, builder patterns, edge cases.
use serde_json;
use zclaw_protocols::*;
// === JsonRpcRequest ===
#[test]
fn jsonrpc_request_new_has_correct_defaults() {
let req = JsonRpcRequest::new(42, "tools/list");
assert_eq!(req.jsonrpc, "2.0");
assert_eq!(req.id, 42);
assert_eq!(req.method, "tools/list");
assert!(req.params.is_none());
}
#[test]
fn jsonrpc_request_with_params() {
let req = JsonRpcRequest::new(1, "tools/call")
.with_params(serde_json::json!({"name": "search"}));
let serialized = serde_json::to_string(&req).unwrap();
assert!(serialized.contains("\"params\""));
assert!(serialized.contains("\"name\":\"search\""));
}
#[test]
fn jsonrpc_request_skip_null_params() {
let req = JsonRpcRequest::new(1, "ping");
let serialized = serde_json::to_string(&req).unwrap();
// params is None, should be skipped
assert!(!serialized.contains("\"params\""));
}
// === JsonRpcResponse ===
#[test]
fn jsonrpc_response_parse_success() {
let json = r#"{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}"#;
let resp: JsonRpcResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.id, 1);
assert!(resp.result.is_some());
assert!(resp.error.is_none());
}
#[test]
fn jsonrpc_response_parse_error() {
let json = r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32600,"message":"Invalid Request"}}"#;
let resp: JsonRpcResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.id, 2);
assert!(resp.result.is_none());
let err = resp.error.unwrap();
assert_eq!(err.code, -32600);
assert_eq!(err.message, "Invalid Request");
}
#[test]
fn jsonrpc_response_parse_error_with_data() {
let json = r#"{"jsonrpc":"2.0","id":3,"error":{"code":-32602,"message":"Bad params","data":{"field":"uri"}}}"#;
let resp: JsonRpcResponse = serde_json::from_str(json).unwrap();
let err = resp.error.unwrap();
assert!(err.data.is_some());
assert_eq!(err.data.unwrap()["field"], "uri");
}
// === InitializeRequest ===
#[test]
fn initialize_request_default() {
let req = InitializeRequest::default();
assert_eq!(req.protocol_version, "2024-11-05");
assert_eq!(req.client_info.name, "zclaw");
assert!(!req.client_info.version.is_empty());
}
#[test]
fn initialize_request_serializes() {
let req = InitializeRequest::default();
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"protocol_version\":\"2024-11-05\""));
assert!(json.contains("\"client_info\""));
}
// === ServerCapabilities ===
#[test]
fn server_capabilities_empty() {
let json = r#"{"protocol_version":"2024-11-05","capabilities":{},"server_info":{"name":"test","version":"1.0"}}"#;
let result: InitializeResult = serde_json::from_str(json).unwrap();
assert!(result.capabilities.tools.is_none());
assert!(result.capabilities.resources.is_none());
}
#[test]
fn server_capabilities_with_tools() {
let json = r#"{"protocol_version":"2024-11-05","capabilities":{"tools":{"list_changed":true}},"server_info":{"name":"test","version":"1.0"}}"#;
let result: InitializeResult = serde_json::from_str(json).unwrap();
let tools = result.capabilities.tools.unwrap();
assert!(tools.list_changed);
}
// === ContentBlock ===
#[test]
fn content_block_text() {
let json = r#"{"type":"text","text":"hello world"}"#;
let block: ContentBlock = serde_json::from_str(json).unwrap();
match block {
ContentBlock::Text { text } => assert_eq!(text, "hello world"),
_ => panic!("Expected Text variant"),
}
}
#[test]
fn content_block_image() {
let json = r#"{"type":"image","data":"base64data","mime_type":"image/png"}"#;
let block: ContentBlock = serde_json::from_str(json).unwrap();
match block {
ContentBlock::Image { data, mime_type } => {
assert_eq!(data, "base64data");
assert_eq!(mime_type, "image/png");
}
_ => panic!("Expected Image variant"),
}
}
#[test]
fn content_block_resource() {
let json = r#"{"type":"resource","resource":{"uri":"file:///test.txt","text":"content"}}"#;
let block: ContentBlock = serde_json::from_str(json).unwrap();
match block {
ContentBlock::Resource { resource } => {
assert_eq!(resource.uri, "file:///test.txt");
assert_eq!(resource.text.unwrap(), "content");
}
_ => panic!("Expected Resource variant"),
}
}
// === CallToolResult ===
#[test]
fn call_tool_result_parse() {
let json = r#"{"content":[{"type":"text","text":"result"}],"is_error":false}"#;
let result: CallToolResult = serde_json::from_str(json).unwrap();
assert!(!result.is_error);
assert_eq!(result.content.len(), 1);
}
#[test]
fn call_tool_result_error() {
let json = r#"{"content":[{"type":"text","text":"something went wrong"}],"is_error":true}"#;
let result: CallToolResult = serde_json::from_str(json).unwrap();
assert!(result.is_error);
}
// === ListToolsResult ===
#[test]
fn list_tools_result_with_cursor() {
let json = r#"{"tools":[{"name":"search","input_schema":{"type":"object"}}],"next_cursor":"abc123"}"#;
let result: ListToolsResult = serde_json::from_str(json).unwrap();
assert_eq!(result.tools.len(), 1);
assert_eq!(result.tools[0].name, "search");
assert_eq!(result.next_cursor.unwrap(), "abc123");
}
#[test]
fn list_tools_result_without_cursor() {
let json = r#"{"tools":[]}"#;
let result: ListToolsResult = serde_json::from_str(json).unwrap();
assert!(result.tools.is_empty());
assert!(result.next_cursor.is_none());
}
// === Resource types ===
#[test]
fn resource_parse_with_optional_fields() {
let json = r#"{"uri":"file:///doc.txt","name":"doc","description":"A doc","mime_type":"text/plain"}"#;
let res: Resource = serde_json::from_str(json).unwrap();
assert_eq!(res.uri, "file:///doc.txt");
assert_eq!(res.name, "doc");
assert_eq!(res.description.unwrap(), "A doc");
assert_eq!(res.mime_type.unwrap(), "text/plain");
}
#[test]
fn resource_parse_minimal() {
let json = r#"{"uri":"file:///x","name":"x"}"#;
let res: Resource = serde_json::from_str(json).unwrap();
assert!(res.description.is_none());
assert!(res.mime_type.is_none());
}
// === LoggingLevel ===
#[test]
fn logging_level_serialize_roundtrip() {
let levels = vec![
LoggingLevel::Debug,
LoggingLevel::Info,
LoggingLevel::Warning,
LoggingLevel::Error,
LoggingLevel::Critical,
LoggingLevel::Emergency,
];
for level in levels {
let json = serde_json::to_string(&level).unwrap();
let parsed: LoggingLevel = serde_json::from_str(&json).unwrap();
assert_eq!(std::mem::discriminant(&level), std::mem::discriminant(&parsed));
}
}
// === InitializedNotification ===
#[test]
fn initialized_notification_fields() {
let n = InitializedNotification::new();
assert_eq!(n.jsonrpc, "2.0");
assert_eq!(n.method, "notifications/initialized");
}
#[test]
fn initialized_notification_serializes() {
let n = InitializedNotification::default();
let json = serde_json::to_string(&n).unwrap();
assert!(json.contains("\"notifications/initialized\""));
}
// === Prompt types ===
#[test]
fn prompt_parse_with_arguments() {
let json = r#"{"name":"greet","description":"Greeting","arguments":[{"name":"lang","description":"Language","required":true}]}"#;
let prompt: Prompt = serde_json::from_str(json).unwrap();
assert_eq!(prompt.name, "greet");
assert_eq!(prompt.arguments.len(), 1);
assert!(prompt.arguments[0].required);
}
#[test]
fn prompt_message_parse() {
let json = r#"{"role":"user","content":{"type":"text","text":"hello"}}"#;
let msg: PromptMessage = serde_json::from_str(json).unwrap();
assert_eq!(msg.role, "user");
}
// === McpClientConfig ===
#[test]
fn mcp_client_config_roundtrip() {
let config = McpClientConfig {
server_url: "http://localhost:3000".to_string(),
server_info: McpServerInfo {
name: "test-server".to_string(),
version: "1.0.0".to_string(),
protocol_version: "2024-11-05".to_string(),
},
capabilities: McpCapabilities::default(),
};
let json = serde_json::to_string(&config).unwrap();
let parsed: McpClientConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.server_url, config.server_url);
assert_eq!(parsed.server_info.name, "test-server");
}