Skip to main content

fastmcp_rust/testing/
assertions.rs

1//! Assertion helpers for testing JSON-RPC and MCP compliance.
2//!
3//! Provides convenient assertion functions for validating protocol messages.
4
5use fastmcp_protocol::{JSONRPC_VERSION, JsonRpcMessage, JsonRpcRequest, JsonRpcResponse};
6
7/// Validates that a JSON-RPC message is well-formed.
8///
9/// Checks:
10/// - `jsonrpc` field is "2.0"
11/// - Request has required fields (method)
12/// - Response has either result or error (not both)
13///
14/// # Panics
15///
16/// Panics if the message is malformed.
17///
18/// # Example
19///
20/// ```ignore
21/// let request = JsonRpcRequest::new("test", None, 1i64);
22/// assert_json_rpc_valid(&JsonRpcMessage::Request(request));
23/// ```
24pub fn assert_json_rpc_valid(message: &JsonRpcMessage) {
25    match message {
26        JsonRpcMessage::Request(req) => {
27            assert_eq!(
28                req.jsonrpc.as_ref(),
29                JSONRPC_VERSION,
30                "JSON-RPC version must be 2.0"
31            );
32            assert!(
33                !req.method.is_empty(),
34                "JSON-RPC request must have a method"
35            );
36        }
37        JsonRpcMessage::Response(resp) => {
38            assert_eq!(
39                resp.jsonrpc.as_ref(),
40                JSONRPC_VERSION,
41                "JSON-RPC version must be 2.0"
42            );
43            // Response must have either result or error, not both
44            let has_result = resp.result.is_some();
45            let has_error = resp.error.is_some();
46            assert!(
47                has_result || has_error,
48                "JSON-RPC response must have either result or error"
49            );
50            assert!(
51                !(has_result && has_error),
52                "JSON-RPC response cannot have both result and error"
53            );
54        }
55    }
56}
57
58/// Validates that a JSON-RPC response indicates success.
59///
60/// # Panics
61///
62/// Panics if the response has an error.
63///
64/// # Example
65///
66/// ```ignore
67/// let response = JsonRpcResponse::success(RequestId::Number(1), json!({}));
68/// assert_json_rpc_success(&response);
69/// ```
70pub fn assert_json_rpc_success(response: &JsonRpcResponse) {
71    assert!(
72        response.error.is_none(),
73        "Expected success response but got error: {:?}",
74        response.error
75    );
76    assert!(
77        response.result.is_some(),
78        "Success response must have a result"
79    );
80}
81
82/// Validates that a JSON-RPC response indicates an error.
83///
84/// # Arguments
85///
86/// * `response` - The response to validate
87/// * `expected_code` - Optional expected error code
88///
89/// # Panics
90///
91/// Panics if the response is successful or has wrong error code.
92///
93/// # Example
94///
95/// ```ignore
96/// let response = JsonRpcResponse::error(
97///     Some(RequestId::Number(1)),
98///     McpError::method_not_found("unknown").into(),
99/// );
100/// assert_json_rpc_error(&response, Some(-32601));
101/// ```
102pub fn assert_json_rpc_error(response: &JsonRpcResponse, expected_code: Option<i32>) {
103    assert!(
104        response.error.is_some(),
105        "Expected error response but got success"
106    );
107    assert!(
108        response.result.is_none(),
109        "Error response should not have a result"
110    );
111
112    if let Some(expected) = expected_code {
113        let actual = response.error.as_ref().unwrap().code;
114        assert_eq!(
115            actual, expected,
116            "Expected error code {expected} but got {actual}"
117        );
118    }
119}
120
121/// Validates that an MCP response is compliant with the protocol.
122///
123/// Checks JSON-RPC validity plus MCP-specific constraints:
124/// - Successful initialize response has required fields
125/// - Tool list response has tools array
126/// - Resource list response has resources array
127/// - etc.
128///
129/// # Arguments
130///
131/// * `method` - The MCP method that was called
132/// * `response` - The response to validate
133///
134/// # Panics
135///
136/// Panics if the response is not MCP-compliant.
137///
138/// # Example
139///
140/// ```ignore
141/// let response = JsonRpcResponse::success(
142///     RequestId::Number(1),
143///     json!({"tools": []}),
144/// );
145/// assert_mcp_compliant("tools/list", &response);
146/// ```
147pub fn assert_mcp_compliant(method: &str, response: &JsonRpcResponse) {
148    // First validate JSON-RPC structure
149    assert_json_rpc_valid(&JsonRpcMessage::Response(response.clone()));
150
151    // If it's an error, no further MCP validation needed
152    if response.error.is_some() {
153        return;
154    }
155
156    let result = response.result.as_ref().expect("Response must have result");
157
158    // Method-specific validation
159    match method {
160        "initialize" => {
161            assert!(
162                result.get("protocolVersion").is_some(),
163                "Initialize response must have protocolVersion"
164            );
165            assert!(
166                result.get("capabilities").is_some(),
167                "Initialize response must have capabilities"
168            );
169            assert!(
170                result.get("serverInfo").is_some(),
171                "Initialize response must have serverInfo"
172            );
173        }
174        "tools/list" => {
175            assert!(
176                result.get("tools").is_some(),
177                "tools/list response must have tools array"
178            );
179            assert!(result["tools"].is_array(), "tools must be an array");
180        }
181        "tools/call" => {
182            assert!(
183                result.get("content").is_some(),
184                "tools/call response must have content"
185            );
186            assert!(result["content"].is_array(), "content must be an array");
187        }
188        "resources/list" => {
189            assert!(
190                result.get("resources").is_some(),
191                "resources/list response must have resources array"
192            );
193            assert!(result["resources"].is_array(), "resources must be an array");
194        }
195        "resources/read" => {
196            assert!(
197                result.get("contents").is_some(),
198                "resources/read response must have contents"
199            );
200            assert!(result["contents"].is_array(), "contents must be an array");
201        }
202        "resources/templates/list" => {
203            assert!(
204                result.get("resourceTemplates").is_some(),
205                "resources/templates/list response must have resourceTemplates"
206            );
207            assert!(
208                result["resourceTemplates"].is_array(),
209                "resourceTemplates must be an array"
210            );
211        }
212        "prompts/list" => {
213            assert!(
214                result.get("prompts").is_some(),
215                "prompts/list response must have prompts array"
216            );
217            assert!(result["prompts"].is_array(), "prompts must be an array");
218        }
219        "prompts/get" => {
220            assert!(
221                result.get("messages").is_some(),
222                "prompts/get response must have messages"
223            );
224            assert!(result["messages"].is_array(), "messages must be an array");
225        }
226        _ => {
227            // Unknown method - just verify it's valid JSON
228        }
229    }
230}
231
232/// Validates that a tool definition is MCP-compliant.
233///
234/// # Panics
235///
236/// Panics if the tool is malformed.
237pub fn assert_tool_valid(tool: &serde_json::Value) {
238    assert!(tool.get("name").is_some(), "Tool must have a name");
239    assert!(tool["name"].is_string(), "Tool name must be a string");
240    // inputSchema is optional but if present must be an object
241    if let Some(schema) = tool.get("inputSchema") {
242        assert!(schema.is_object(), "inputSchema must be an object");
243    }
244}
245
246/// Validates that a resource definition is MCP-compliant.
247///
248/// # Panics
249///
250/// Panics if the resource is malformed.
251pub fn assert_resource_valid(resource: &serde_json::Value) {
252    assert!(resource.get("uri").is_some(), "Resource must have a uri");
253    assert!(resource["uri"].is_string(), "Resource uri must be a string");
254    assert!(resource.get("name").is_some(), "Resource must have a name");
255    assert!(
256        resource["name"].is_string(),
257        "Resource name must be a string"
258    );
259}
260
261/// Validates that a prompt definition is MCP-compliant.
262///
263/// # Panics
264///
265/// Panics if the prompt is malformed.
266pub fn assert_prompt_valid(prompt: &serde_json::Value) {
267    assert!(prompt.get("name").is_some(), "Prompt must have a name");
268    assert!(prompt["name"].is_string(), "Prompt name must be a string");
269}
270
271/// Validates that content is MCP-compliant.
272///
273/// # Panics
274///
275/// Panics if the content is malformed.
276pub fn assert_content_valid(content: &serde_json::Value) {
277    assert!(content.get("type").is_some(), "Content must have a type");
278    let content_type = content["type"].as_str().expect("type must be a string");
279    match content_type {
280        "text" => {
281            assert!(
282                content.get("text").is_some(),
283                "Text content must have text field"
284            );
285        }
286        "image" => {
287            assert!(
288                content.get("data").is_some(),
289                "Image content must have data field"
290            );
291            assert!(
292                content.get("mimeType").is_some(),
293                "Image content must have mimeType field"
294            );
295        }
296        "audio" => {
297            assert!(
298                content.get("data").is_some(),
299                "Audio content must have data field"
300            );
301            assert!(
302                content.get("mimeType").is_some(),
303                "Audio content must have mimeType field"
304            );
305        }
306        "resource" => {
307            assert!(
308                content.get("resource").is_some(),
309                "Resource content must have resource field"
310            );
311        }
312        _ => {
313            // Unknown content type - allow for extensibility
314        }
315    }
316}
317
318/// Validates that a JSON-RPC request is a valid notification.
319///
320/// A notification has no `id` field.
321///
322/// # Panics
323///
324/// Panics if the request is not a notification.
325pub fn assert_is_notification(request: &JsonRpcRequest) {
326    assert!(
327        request.id.is_none(),
328        "Notification must not have an id field"
329    );
330}
331
332/// Validates that a JSON-RPC request is a valid request (not notification).
333///
334/// A request has an `id` field.
335///
336/// # Panics
337///
338/// Panics if the request is a notification.
339pub fn assert_is_request(request: &JsonRpcRequest) {
340    assert!(request.id.is_some(), "Request must have an id field");
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use fastmcp_protocol::RequestId;
347
348    #[test]
349    fn test_valid_request() {
350        let request = JsonRpcRequest::new("test/method", None, 1i64);
351        assert_json_rpc_valid(&JsonRpcMessage::Request(request));
352    }
353
354    #[test]
355    fn test_valid_success_response() {
356        let response = JsonRpcResponse::success(RequestId::Number(1), serde_json::json!({}));
357        assert_json_rpc_valid(&JsonRpcMessage::Response(response.clone()));
358        assert_json_rpc_success(&response);
359    }
360
361    #[test]
362    fn test_valid_error_response() {
363        let error = fastmcp_protocol::JsonRpcError {
364            code: -32601,
365            message: "Method not found".to_string(),
366            data: None,
367        };
368        let response = JsonRpcResponse {
369            jsonrpc: std::borrow::Cow::Borrowed(JSONRPC_VERSION),
370            id: Some(RequestId::Number(1)),
371            result: None,
372            error: Some(error),
373        };
374        assert_json_rpc_valid(&JsonRpcMessage::Response(response.clone()));
375        assert_json_rpc_error(&response, Some(-32601));
376    }
377
378    #[test]
379    fn test_mcp_compliant_tools_list() {
380        let response = JsonRpcResponse::success(
381            RequestId::Number(1),
382            serde_json::json!({
383                "tools": []
384            }),
385        );
386        assert_mcp_compliant("tools/list", &response);
387    }
388
389    #[test]
390    fn test_mcp_compliant_initialize() {
391        let response = JsonRpcResponse::success(
392            RequestId::Number(1),
393            serde_json::json!({
394                "protocolVersion": "2024-11-05",
395                "capabilities": {},
396                "serverInfo": {
397                    "name": "test",
398                    "version": "1.0"
399                }
400            }),
401        );
402        assert_mcp_compliant("initialize", &response);
403    }
404
405    #[test]
406    fn test_valid_tool() {
407        let tool = serde_json::json!({
408            "name": "my_tool",
409            "description": "A test tool",
410            "inputSchema": {
411                "type": "object"
412            }
413        });
414        assert_tool_valid(&tool);
415    }
416
417    #[test]
418    fn test_valid_resource() {
419        let resource = serde_json::json!({
420            "uri": "file:///test.txt",
421            "name": "Test File"
422        });
423        assert_resource_valid(&resource);
424    }
425
426    #[test]
427    fn test_valid_prompt() {
428        let prompt = serde_json::json!({
429            "name": "greeting",
430            "description": "A greeting prompt"
431        });
432        assert_prompt_valid(&prompt);
433    }
434
435    #[test]
436    fn test_valid_text_content() {
437        let content = serde_json::json!({
438            "type": "text",
439            "text": "Hello, world!"
440        });
441        assert_content_valid(&content);
442    }
443
444    #[test]
445    fn test_is_notification() {
446        let notification = JsonRpcRequest::notification("test", None);
447        assert_is_notification(&notification);
448    }
449
450    #[test]
451    fn test_is_request() {
452        let request = JsonRpcRequest::new("test", None, 1i64);
453        assert_is_request(&request);
454    }
455
456    // =========================================================================
457    // Additional coverage tests (bd-1fnm)
458    // =========================================================================
459
460    #[test]
461    fn error_response_without_expected_code() {
462        let error = fastmcp_protocol::JsonRpcError {
463            code: -32600,
464            message: "Invalid request".to_string(),
465            data: None,
466        };
467        let response = JsonRpcResponse {
468            jsonrpc: std::borrow::Cow::Borrowed(JSONRPC_VERSION),
469            id: Some(RequestId::Number(1)),
470            result: None,
471            error: Some(error),
472        };
473        // None code means we only check that it IS an error, not the code value
474        assert_json_rpc_error(&response, None);
475    }
476
477    #[test]
478    fn mcp_compliant_tools_call() {
479        let response = JsonRpcResponse::success(
480            RequestId::Number(1),
481            serde_json::json!({ "content": [{"type": "text", "text": "hello"}] }),
482        );
483        assert_mcp_compliant("tools/call", &response);
484    }
485
486    #[test]
487    fn mcp_compliant_resources_list() {
488        let response =
489            JsonRpcResponse::success(RequestId::Number(1), serde_json::json!({ "resources": [] }));
490        assert_mcp_compliant("resources/list", &response);
491    }
492
493    #[test]
494    fn mcp_compliant_resources_read() {
495        let response = JsonRpcResponse::success(
496            RequestId::Number(1),
497            serde_json::json!({ "contents": [{"uri": "file:///a", "text": "data"}] }),
498        );
499        assert_mcp_compliant("resources/read", &response);
500    }
501
502    #[test]
503    fn mcp_compliant_resource_templates_list() {
504        let response = JsonRpcResponse::success(
505            RequestId::Number(1),
506            serde_json::json!({ "resourceTemplates": [] }),
507        );
508        assert_mcp_compliant("resources/templates/list", &response);
509    }
510
511    #[test]
512    fn mcp_compliant_prompts_list() {
513        let response =
514            JsonRpcResponse::success(RequestId::Number(1), serde_json::json!({ "prompts": [] }));
515        assert_mcp_compliant("prompts/list", &response);
516    }
517
518    #[test]
519    fn mcp_compliant_prompts_get() {
520        let response = JsonRpcResponse::success(
521            RequestId::Number(1),
522            serde_json::json!({ "messages": [{"role": "user", "content": {}}] }),
523        );
524        assert_mcp_compliant("prompts/get", &response);
525    }
526
527    #[test]
528    fn mcp_compliant_error_response_early_return() {
529        let error = fastmcp_protocol::JsonRpcError {
530            code: -32601,
531            message: "Method not found".to_string(),
532            data: None,
533        };
534        let response = JsonRpcResponse {
535            jsonrpc: std::borrow::Cow::Borrowed(JSONRPC_VERSION),
536            id: Some(RequestId::Number(1)),
537            result: None,
538            error: Some(error),
539        };
540        // Error responses skip MCP-specific checks and return early
541        assert_mcp_compliant("tools/list", &response);
542    }
543
544    #[test]
545    fn mcp_compliant_unknown_method() {
546        let response = JsonRpcResponse::success(
547            RequestId::Number(1),
548            serde_json::json!({ "anything": true }),
549        );
550        // Unknown methods only get JSON-RPC validation
551        assert_mcp_compliant("custom/method", &response);
552    }
553
554    #[test]
555    fn content_valid_image() {
556        let content = serde_json::json!({
557            "type": "image",
558            "data": "base64data",
559            "mimeType": "image/png"
560        });
561        assert_content_valid(&content);
562    }
563
564    #[test]
565    fn content_valid_audio() {
566        let content = serde_json::json!({
567            "type": "audio",
568            "data": "base64data",
569            "mimeType": "audio/wav"
570        });
571        assert_content_valid(&content);
572    }
573
574    #[test]
575    fn content_valid_resource() {
576        let content = serde_json::json!({
577            "type": "resource",
578            "resource": { "uri": "file:///test.txt", "text": "data" }
579        });
580        assert_content_valid(&content);
581    }
582
583    #[test]
584    fn content_valid_unknown_type() {
585        let content = serde_json::json!({
586            "type": "custom_extension",
587            "data": "whatever"
588        });
589        // Unknown types pass for extensibility
590        assert_content_valid(&content);
591    }
592
593    #[test]
594    fn tool_valid_without_input_schema() {
595        let tool = serde_json::json!({
596            "name": "simple_tool"
597        });
598        // inputSchema is optional
599        assert_tool_valid(&tool);
600    }
601}