Skip to content

Commit 67ef45b

Browse files
authored
Merge pull request #556 from anchapin/feature/issue-536
[#536] Add property-based tests for MCP client
2 parents dece22f + c780412 commit 67ef45b

2 files changed

Lines changed: 328 additions & 0 deletions

File tree

orchestrator/src/mcp/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ pub use client::{ClientState, McpClient};
5353
#[cfg(test)]
5454
mod integration;
5555

56+
// Property-based tests module
57+
#[cfg(test)]
58+
mod proptests;
59+
60+
#[cfg(test)]
5661
#[cfg(test)]
5762
mod tests {
5863
use crate::mcp::{McpError, McpRequest};

orchestrator/src/mcp/proptests.rs

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
//! Property-Based Tests for MCP Client
2+
//!
3+
//! This module contains property-based tests using proptest to verify invariants
4+
//! hold for random inputs across the MCP client implementation.
5+
//!
6+
//! # Test Strategies
7+
//!
8+
//! - **Message Serialization**: Round-trip validity - serializing and deserializing
9+
//! should preserve the original message structure
10+
//! - **Retry Logic**: Exponential backoff properties - delays should increase
11+
//! exponentially within bounds
12+
//! - **Error Handling**: Edge cases like network failures, invalid JSON, timeouts
13+
//!
14+
//! # Running the Tests
15+
//!
16+
//! ```bash
17+
//! cargo test --lib mcp::proptests
18+
//! ```
19+
20+
use proptest::collection::btree_map;
21+
use proptest::prelude::*;
22+
use serde_json::Value;
23+
24+
// Import MCP types for testing
25+
use crate::mcp::protocol::{
26+
ClientCapabilities, ClientInfo, InitializeParams, McpError, McpRequest, McpResponse,
27+
ServerCapabilities, ServerInfo, Tool, ToolCallParams,
28+
};
29+
use crate::mcp::retry::RetryConfig;
30+
use std::time::Duration;
31+
32+
// Helper: Generate arbitrary JSON values
33+
fn arb_json_value() -> impl Strategy<Value = Value> {
34+
prop_oneof![
35+
Just(Value::Null),
36+
any::<bool>().prop_map(Value::Bool),
37+
any::<i64>().prop_map(|n| Value::Number(n.into())),
38+
".*".prop_map(Value::String),
39+
prop::collection::vec(any::<i64>().prop_map(|n| Value::Number(n.into())), 0..3)
40+
.prop_map(Value::Array),
41+
]
42+
}
43+
44+
// Helper: Generate arbitrary tool definitions
45+
fn arb_tool() -> impl Strategy<Value = Tool> {
46+
("[a-z_]+", "[a-zA-Z ]+", arb_json_value()).prop_map(|(name, description, input_schema)| Tool {
47+
name,
48+
description,
49+
input_schema,
50+
})
51+
}
52+
53+
// Helper: Generate arbitrary tool call parameters
54+
fn arb_tool_call_params() -> impl Strategy<Value = ToolCallParams> {
55+
("[a-z_]+", arb_json_value()).prop_map(|(name, arguments)| ToolCallParams {
56+
name,
57+
arguments: serde_json::to_value(arguments).unwrap(),
58+
})
59+
}
60+
61+
// ============================================================================
62+
// Property 1: Message Serialization Round-Trip
63+
// ============================================================================
64+
65+
proptest! {
66+
/// Test that MCP requests can be serialized and deserialized correctly
67+
#[test]
68+
fn prop_request_serialization_roundtrip(
69+
id in 0u64..1000,
70+
method in "[a-z_]+",
71+
params in prop::option::of(arb_json_value())
72+
) {
73+
let original = McpRequest::new(id, &method, params);
74+
let serialized = serde_json::to_string(&original).unwrap();
75+
let deserialized: McpRequest = serde_json::from_str(&serialized).unwrap();
76+
77+
prop_assert_eq!(original.id, deserialized.id);
78+
prop_assert_eq!(original.method, deserialized.method);
79+
prop_assert_eq!(original.jsonrpc, deserialized.jsonrpc);
80+
}
81+
82+
/// Test that MCP responses can be serialized and deserialized correctly
83+
#[test]
84+
fn prop_response_serialization_roundtrip(
85+
id in 0u64..1000,
86+
result in prop::option::of(arb_json_value()),
87+
error_code in prop::option::of(0i32..1000)
88+
) {
89+
let original = match result {
90+
Some(r) => McpResponse::ok(id, r),
91+
None => match error_code {
92+
Some(code) => McpResponse::err(id, McpError::new(code, "test error")),
93+
None => McpResponse::ok(id, Value::Null),
94+
}
95+
};
96+
97+
let serialized = serde_json::to_string(&original).unwrap();
98+
let deserialized: McpResponse = serde_json::from_str(&serialized).unwrap();
99+
100+
prop_assert_eq!(original.id, deserialized.id);
101+
prop_assert_eq!(original.jsonrpc, deserialized.jsonrpc);
102+
}
103+
104+
/// Test that tool definitions can be serialized and deserialized correctly
105+
#[test]
106+
fn prop_tool_serialization_roundtrip(tool in arb_tool()) {
107+
let serialized = serde_json::to_string(&tool).unwrap();
108+
let deserialized: Tool = serde_json::from_str(&serialized).unwrap();
109+
110+
prop_assert_eq!(tool.name, deserialized.name);
111+
prop_assert_eq!(tool.description, deserialized.description);
112+
}
113+
}
114+
115+
// ============================================================================
116+
// Property 2: Retry Logic Properties
117+
// ============================================================================
118+
119+
proptest! {
120+
/// Test that retry delays increase exponentially
121+
#[test]
122+
fn prop_retry_delays_increase_exponentially(
123+
base_delay_ms in 10u64..1000,
124+
max_delay_ms in 1000u64..60000,
125+
attempt in 1usize..10usize
126+
) {
127+
let config = RetryConfig::new()
128+
.base_delay(Duration::from_millis(base_delay_ms))
129+
.max_delay(Duration::from_millis(max_delay_ms));
130+
131+
let delay = config.calculate_delay(attempt);
132+
133+
// Delay should not exceed max_delay
134+
prop_assert!(delay <= Duration::from_millis(max_delay_ms));
135+
136+
// For higher attempts, delay should be larger (with some tolerance for jitter)
137+
if attempt > 1 {
138+
let delay_prev = config.calculate_delay(attempt - 1);
139+
// Allow for jitter to cause non-monotonic delays in rare cases
140+
// but check that delay is roughly exponential
141+
let ratio = delay.as_millis() as f64 / delay_prev.as_millis().max(1) as f64;
142+
prop_assert!(ratio < 3.0); // Should be roughly 2x, but with jitter
143+
}
144+
}
145+
146+
/// Test that retry delays are bounded by max_delay
147+
#[test]
148+
fn prop_retry_delays_bounded_by_max(
149+
base_delay_ms in 10u64..1000,
150+
max_delay_ms in 1000u64..60000,
151+
attempt in 1usize..20usize
152+
) {
153+
let config = RetryConfig::new()
154+
.base_delay(Duration::from_millis(base_delay_ms))
155+
.max_delay(Duration::from_millis(max_delay_ms));
156+
157+
let delay = config.calculate_delay(attempt);
158+
prop_assert!(delay <= Duration::from_millis(max_delay_ms));
159+
}
160+
161+
/// Test that max_attempts is stored correctly
162+
#[test]
163+
fn prop_retry_max_attempts_stored(
164+
max_attempts in 1usize..10usize
165+
) {
166+
let config = RetryConfig::new().max_attempts(max_attempts);
167+
prop_assert_eq!(config.max_attempts, max_attempts);
168+
}
169+
}
170+
171+
// ============================================================================
172+
// Property 3: Error Handling Invariants
173+
// ============================================================================
174+
175+
proptest! {
176+
/// Test that MCP errors have valid error codes
177+
#[test]
178+
fn prop_error_codes_valid(code in -32000i32..32000i32, message in "[a-zA-Z ]+") {
179+
let error = McpError::new(code, &message);
180+
181+
prop_assert_eq!(error.code, code);
182+
prop_assert!(error.message.contains(&message));
183+
}
184+
185+
/// Test that standard error methods produce valid errors
186+
#[test]
187+
fn prop_standard_errors_valid(method in "[a-z_]+") {
188+
let not_found = McpError::method_not_found(&method);
189+
prop_assert_eq!(not_found.code, -32601);
190+
191+
let invalid_params = McpError::invalid_params(&method);
192+
prop_assert_eq!(invalid_params.code, -32602);
193+
194+
let internal = McpError::internal_error(&method);
195+
prop_assert_eq!(internal.code, -32603);
196+
}
197+
198+
/// Test that error serialization preserves all fields
199+
#[test]
200+
fn prop_error_serialization_preserves_fields(
201+
code in -32000i32..32000i32,
202+
message in "[a-zA-Z ]+",
203+
data in prop::option::of(any::<i64>().prop_map(|n| Value::Number(n.into())))
204+
) {
205+
let original = match data {
206+
Some(d) => McpError::with_data(code, &message, d),
207+
None => McpError::new(code, &message),
208+
};
209+
210+
let serialized = serde_json::to_string(&original).unwrap();
211+
let deserialized: McpError = serde_json::from_str(&serialized).unwrap();
212+
213+
prop_assert_eq!(original.code, deserialized.code);
214+
prop_assert_eq!(original.message, deserialized.message);
215+
prop_assert_eq!(original.data, deserialized.data);
216+
}
217+
}
218+
219+
// ============================================================================
220+
// Property 4: Initialize Parameters Invariants
221+
// ============================================================================
222+
223+
proptest! {
224+
/// Test that client info can be serialized and deserialized correctly
225+
#[test]
226+
fn prop_client_info_serialization_roundtrip(
227+
name in "[a-zA-Z]+",
228+
version in "[0-9.]+"
229+
) {
230+
let client_info = ClientInfo {
231+
name: name.clone(),
232+
version: version.clone(),
233+
};
234+
235+
let serialized = serde_json::to_string(&client_info).unwrap();
236+
let deserialized: ClientInfo = serde_json::from_str(&serialized).unwrap();
237+
238+
prop_assert_eq!(deserialized.name, name);
239+
prop_assert_eq!(deserialized.version, version);
240+
}
241+
242+
/// Test that server info is preserved in responses
243+
#[test]
244+
fn prop_server_info_preserved(
245+
name in "[a-zA-Z]+",
246+
version in "[0-9.]+"
247+
) {
248+
let server_info = ServerInfo {
249+
name: name.clone(),
250+
version: version.clone(),
251+
};
252+
253+
let serialized = serde_json::to_string(&server_info).unwrap();
254+
let deserialized: ServerInfo = serde_json::from_str(&serialized).unwrap();
255+
256+
prop_assert_eq!(deserialized.name, name);
257+
prop_assert_eq!(deserialized.version, version);
258+
}
259+
}
260+
261+
// ============================================================================
262+
// Property 5: Tool Call Parameters Invariants
263+
// ============================================================================
264+
265+
proptest! {
266+
/// Test that tool call parameters preserve the tool name and arguments
267+
#[test]
268+
fn prop_tool_call_params_preserve_data(params in arb_tool_call_params()) {
269+
let serialized = serde_json::to_string(&params).unwrap();
270+
let deserialized: ToolCallParams = serde_json::from_str(&serialized).unwrap();
271+
272+
prop_assert_eq!(params.name, deserialized.name);
273+
prop_assert_eq!(params.arguments, deserialized.arguments);
274+
}
275+
276+
/// Test that multiple tool calls can be batched
277+
#[test]
278+
fn prop_multiple_tool_calls_batching(
279+
tools in prop::collection::vec(arb_tool_call_params(), 1..5)
280+
) {
281+
// All tools should serialize successfully
282+
for tool in &tools {
283+
let serialized = serde_json::to_string(tool).unwrap();
284+
let deserialized: ToolCallParams = serde_json::from_str(&serialized).unwrap();
285+
prop_assert_eq!(&tool.name, &deserialized.name);
286+
}
287+
}
288+
}
289+
290+
// ============================================================================
291+
// Property 6: JSON Schema Validation Invariants
292+
// ============================================================================
293+
294+
proptest! {
295+
/// Test that valid JSON schemas can be parsed
296+
#[test]
297+
fn prop_valid_json_schema_parseable(schema_json in arb_json_value()) {
298+
// Any valid JSON should be parseable as a schema
299+
let serialized = serde_json::to_string(&schema_json).unwrap();
300+
let parsed: Value = serde_json::from_str(&serialized).unwrap();
301+
302+
prop_assert_eq!(schema_json, parsed);
303+
}
304+
305+
/// Test that tool schemas are preserved
306+
#[test]
307+
fn prop_tool_schema_preserved(
308+
name in "[a-z_]+",
309+
schema in arb_json_value()
310+
) {
311+
let tool = Tool {
312+
name: name.clone(),
313+
description: "Test tool".to_string(),
314+
input_schema: schema.clone(),
315+
};
316+
317+
let serialized = serde_json::to_string(&tool).unwrap();
318+
let deserialized: Tool = serde_json::from_str(&serialized).unwrap();
319+
320+
prop_assert_eq!(tool.name, deserialized.name);
321+
prop_assert_eq!(tool.input_schema, deserialized.input_schema);
322+
}
323+
}

0 commit comments

Comments
 (0)