Skip to main content

nika_mcp/
types.rs

1//! MCP Protocol Types
2//!
3//! Core types for MCP (Model Context Protocol) integration:
4//! - [`McpConfig`]: Server configuration (name, command, args, env, cwd)
5//! - [`ToolCallRequest`]: Request to invoke an MCP tool
6//! - [`ToolCallResult`]: Result from a tool invocation
7//! - [`ContentBlock`]: Content block in tool results (text, image, resource)
8//! - [`ResourceContent`]: Resource content from MCP server
9//! - [`ToolDefinition`]: Tool schema from MCP server
10//! - [`McpErrorCode`]: JSON-RPC error codes
11
12use rustc_hash::FxHashMap;
13
14use serde::{Deserialize, Serialize};
15
16// ═══════════════════════════════════════════════════════════════════════════
17// MCP JSON-RPC Error Codes
18// ═══════════════════════════════════════════════════════════════════════════
19
20/// MCP JSON-RPC error codes per MCP specification.
21///
22/// These error codes follow the JSON-RPC 2.0 specification and are preserved
23/// from rmcp errors for better debugging and error handling.
24///
25/// # Error Code Ranges
26///
27/// - `-32700`: Parse error (invalid JSON)
28/// - `-32600`: Invalid request
29/// - `-32601`: Method not found
30/// - `-32602`: Invalid params
31/// - `-32603`: Internal error
32/// - `-32000` to `-32099`: Server errors (implementation-defined)
33///
34/// # Example
35///
36/// ```rust
37/// use nika_mcp::McpErrorCode;
38///
39/// let code = McpErrorCode::from_code(-32602);
40/// assert_eq!(code, McpErrorCode::InvalidParams);
41/// assert!(code.is_client_error());
42/// ```
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
44#[serde(into = "i32", try_from = "i32")]
45pub enum McpErrorCode {
46    /// Parse error: Invalid JSON was received by the server (-32700)
47    ParseError,
48    /// Invalid request: The JSON sent is not a valid Request object (-32600)
49    InvalidRequest,
50    /// Method not found: The method does not exist / is not available (-32601)
51    MethodNotFound,
52    /// Invalid params: Invalid method parameter(s) (-32602)
53    InvalidParams,
54    /// Internal error: Internal JSON-RPC error (-32603)
55    InternalError,
56    /// Server error: Implementation-defined server errors (-32000 to -32099)
57    ServerError(i32),
58    /// Unknown error code (not in JSON-RPC spec)
59    Unknown(i32),
60}
61
62impl McpErrorCode {
63    /// Create an error code from a numeric JSON-RPC error code.
64    pub fn from_code(code: i32) -> Self {
65        match code {
66            -32700 => Self::ParseError,
67            -32600 => Self::InvalidRequest,
68            -32601 => Self::MethodNotFound,
69            -32602 => Self::InvalidParams,
70            -32603 => Self::InternalError,
71            c if (-32099..=-32000).contains(&c) => Self::ServerError(c),
72            c => Self::Unknown(c),
73        }
74    }
75
76    /// Get the numeric error code.
77    pub fn code(&self) -> i32 {
78        match self {
79            Self::ParseError => -32700,
80            Self::InvalidRequest => -32600,
81            Self::MethodNotFound => -32601,
82            Self::InvalidParams => -32602,
83            Self::InternalError => -32603,
84            Self::ServerError(c) | Self::Unknown(c) => *c,
85        }
86    }
87
88    /// Check if this is a client-side error (invalid request/params).
89    pub fn is_client_error(&self) -> bool {
90        matches!(
91            self,
92            Self::ParseError | Self::InvalidRequest | Self::InvalidParams
93        )
94    }
95
96    /// Check if this is a server-side error.
97    pub fn is_server_error(&self) -> bool {
98        matches!(
99            self,
100            Self::InternalError | Self::MethodNotFound | Self::ServerError(_)
101        )
102    }
103
104    /// Get a human-readable description of the error code.
105    pub fn description(&self) -> &'static str {
106        match self {
107            Self::ParseError => "Invalid JSON was received",
108            Self::InvalidRequest => "The JSON sent is not a valid Request object",
109            Self::MethodNotFound => "The method does not exist or is not available",
110            Self::InvalidParams => "Invalid method parameter(s)",
111            Self::InternalError => "Internal JSON-RPC error",
112            Self::ServerError(_) => "Server error",
113            Self::Unknown(_) => "Unknown error",
114        }
115    }
116
117    /// Check if this error is potentially recoverable through retry.
118    ///
119    /// Returns `true` for transient errors that might succeed on retry:
120    /// - Internal errors (might be temporary server issues)
121    /// - Server errors (implementation-defined, often transient)
122    ///
123    /// Returns `false` for client errors that require fixing the request:
124    /// - Parse errors (invalid JSON won't become valid)
125    /// - Invalid request (malformed request structure)
126    /// - Invalid params (wrong parameters)
127    /// - Method not found (method doesn't exist)
128    pub fn is_retryable(&self) -> bool {
129        matches!(
130            self,
131            Self::InternalError | Self::ServerError(_) | Self::Unknown(_)
132        )
133    }
134}
135
136impl std::fmt::Display for McpErrorCode {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        write!(f, "{} ({})", self.description(), self.code())
139    }
140}
141
142impl From<McpErrorCode> for i32 {
143    fn from(code: McpErrorCode) -> Self {
144        code.code()
145    }
146}
147
148impl From<i32> for McpErrorCode {
149    fn from(code: i32) -> Self {
150        Self::from_code(code)
151    }
152}
153
154/// MCP server configuration.
155///
156/// Defines how to spawn and connect to an MCP server process.
157///
158/// # Example YAML
159///
160/// ```yaml
161/// mcp:
162///   novanet:
163///     command: "npx"
164///     args: ["-y", "@novanet/mcp-server"]
165///     env:
166///       NEO4J_URI: "bolt://localhost:7687"
167///     cwd: "/path/to/project"
168/// ```
169#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
170pub struct McpConfig {
171    /// Server name (key in mcp: block)
172    #[serde(skip)]
173    pub name: String,
174
175    /// Command to execute (e.g., "npx", "node", "python")
176    pub command: String,
177
178    /// Command arguments
179    #[serde(default)]
180    pub args: Vec<String>,
181
182    /// Environment variables for the process
183    #[serde(default)]
184    pub env: FxHashMap<String, String>,
185
186    /// Working directory for the process
187    #[serde(default)]
188    pub cwd: Option<String>,
189}
190
191impl McpConfig {
192    /// Create a new McpConfig with the given name and command.
193    pub fn new(name: impl Into<String>, command: impl Into<String>) -> Self {
194        Self {
195            name: name.into(),
196            command: command.into(),
197            args: Vec::new(),
198            env: FxHashMap::default(),
199            cwd: None,
200        }
201    }
202
203    /// Add an argument to the command.
204    pub fn with_arg(mut self, arg: impl Into<String>) -> Self {
205        self.args.push(arg.into());
206        self
207    }
208
209    /// Add multiple arguments to the command.
210    pub fn with_args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
211        self.args.extend(args.into_iter().map(Into::into));
212        self
213    }
214
215    /// Set an environment variable.
216    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
217        self.env.insert(key.into(), value.into());
218        self
219    }
220
221    /// Set the working directory.
222    pub fn with_cwd(mut self, cwd: impl Into<String>) -> Self {
223        self.cwd = Some(cwd.into());
224        self
225    }
226
227    /// Expand environment variables and tilde in all config strings.
228    ///
229    /// Applies shellexpand to:
230    /// - `command` - the executable path
231    /// - `args` - each argument
232    /// - `env` values (NOT keys - keys are literal identifiers)
233    /// - `cwd` - working directory
234    ///
235    /// # Syntax Supported
236    /// - `$VAR` or `${VAR}` - environment variable
237    /// - `~/path` - home directory expansion
238    /// - `${VAR:-default}` - with default value (shell-compatible)
239    ///
240    /// # Errors
241    /// Returns error if a referenced variable is not set.
242    ///
243    /// # Example
244    /// ```rust,ignore
245    /// let config = McpConfig::new("test", "$HOME/bin/server")
246    ///     .with_arg("--config=$XDG_CONFIG_HOME/test.json")
247    ///     .with_env("DATA_DIR", "${XDG_DATA_HOME}/test")
248    ///     .expand_env_vars()?;
249    /// ```
250    pub fn expand_env_vars(mut self) -> Result<Self, String> {
251        // Expand command
252        self.command = shellexpand::full(&self.command)
253            .map_err(|e| format!("Failed to expand command '{}': {}", self.command, e))?
254            .into_owned();
255
256        // Expand args
257        let mut expanded_args = Vec::with_capacity(self.args.len());
258        for arg in &self.args {
259            let expanded = shellexpand::full(arg)
260                .map_err(|e| format!("Failed to expand arg '{}': {}", arg, e))?
261                .into_owned();
262            expanded_args.push(expanded);
263        }
264        self.args = expanded_args;
265
266        // Expand env VALUES (not keys - keys are identifiers)
267        let mut expanded_env = FxHashMap::default();
268        for (key, value) in self.env.drain() {
269            let expanded_value = shellexpand::full(&value)
270                .map_err(|e| format!("Failed to expand env '{}={}': {}", key, value, e))?
271                .into_owned();
272            expanded_env.insert(key, expanded_value);
273        }
274        self.env = expanded_env;
275
276        // Expand cwd
277        if let Some(cwd) = self.cwd.as_mut() {
278            *cwd = shellexpand::full(cwd)
279                .map_err(|e| format!("Failed to expand cwd '{}': {}", cwd, e))?
280                .into_owned();
281        }
282
283        Ok(self)
284    }
285}
286
287/// Request to call an MCP tool.
288///
289/// Sent to an MCP server to invoke a specific tool with arguments.
290#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
291pub struct ToolCallRequest {
292    /// Tool name (e.g., "novanet_context", "read_file")
293    pub name: String,
294
295    /// Tool arguments as JSON object
296    #[serde(default)]
297    pub arguments: serde_json::Value,
298}
299
300impl ToolCallRequest {
301    /// Create a new tool call request.
302    pub fn new(name: impl Into<String>) -> Self {
303        Self {
304            name: name.into(),
305            arguments: serde_json::Value::Object(serde_json::Map::new()),
306        }
307    }
308
309    /// Set the arguments from a JSON value.
310    pub fn with_arguments(mut self, args: serde_json::Value) -> Self {
311        self.arguments = args;
312        self
313    }
314}
315
316/// Result from an MCP tool call.
317///
318/// Contains one or more content blocks with the tool's output.
319#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
320pub struct ToolCallResult {
321    /// Content blocks returned by the tool
322    pub content: Vec<ContentBlock>,
323
324    /// Whether the tool call resulted in an error
325    #[serde(default)]
326    pub is_error: bool,
327}
328
329impl ToolCallResult {
330    /// Create a successful result with the given content blocks.
331    pub fn success(content: Vec<ContentBlock>) -> Self {
332        Self {
333            content,
334            is_error: false,
335        }
336    }
337
338    /// Create an error result with a text message.
339    pub fn error(message: impl Into<String>) -> Self {
340        Self {
341            content: vec![ContentBlock::text(message)],
342            is_error: true,
343        }
344    }
345
346    /// Extract all text content from the result.
347    ///
348    /// Joins all text blocks with newlines.
349    pub fn text(&self) -> String {
350        self.content
351            .iter()
352            .filter_map(|block| match block {
353                ContentBlock::Text { text } => Some(text.as_str()),
354                _ => None,
355            })
356            .collect::<Vec<_>>()
357            .join("\n")
358    }
359
360    /// Extract the first text block, if any.
361    pub fn first_text(&self) -> Option<&str> {
362        self.content.iter().find_map(|block| match block {
363            ContentBlock::Text { text } => Some(text.as_str()),
364            _ => None,
365        })
366    }
367
368    /// Check if result contains any non-text content (images, audio, resources).
369    pub fn has_media(&self) -> bool {
370        self.content.iter().any(|b| !b.is_text())
371    }
372
373    /// Get all image content blocks.
374    pub fn images(&self) -> Vec<&ContentBlock> {
375        self.content.iter().filter(|b| b.is_image()).collect()
376    }
377
378    /// Get all audio content blocks.
379    pub fn audio_blocks(&self) -> Vec<&ContentBlock> {
380        self.content.iter().filter(|b| b.is_audio()).collect()
381    }
382
383    /// Get all non-text content blocks (images, audio, resources, resource links).
384    pub fn media_blocks(&self) -> Vec<&ContentBlock> {
385        self.content.iter().filter(|b| !b.is_text()).collect()
386    }
387}
388
389/// Content block in MCP tool results.
390// ARCH-5: ContentBlock and ResourceContent are now defined in nika-core::mcp
391// and re-exported here for backwards compatibility.
392pub use nika_core::mcp::{ContentBlock, ResourceContent};
393
394/// Tool definition from MCP server.
395///
396/// Describes a tool that can be called, including its JSON Schema.
397#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
398pub struct ToolDefinition {
399    /// Tool name (e.g., "novanet_context")
400    pub name: String,
401
402    /// Human-readable description
403    #[serde(default)]
404    pub description: Option<String>,
405
406    /// JSON Schema for the tool's input parameters
407    #[serde(default, rename = "inputSchema")]
408    pub input_schema: Option<serde_json::Value>,
409}
410
411impl ToolDefinition {
412    /// Create a new tool definition.
413    pub fn new(name: impl Into<String>) -> Self {
414        Self {
415            name: name.into(),
416            description: None,
417            input_schema: None,
418        }
419    }
420
421    /// Set the description.
422    pub fn with_description(mut self, description: impl Into<String>) -> Self {
423        self.description = Some(description.into());
424        self
425    }
426
427    /// Set the input schema.
428    pub fn with_input_schema(mut self, schema: serde_json::Value) -> Self {
429        self.input_schema = Some(schema);
430        self
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use pretty_assertions::assert_eq;
438    use serde_saphyr as serde_yaml;
439    use serial_test::serial;
440
441    // ═══════════════════════════════════════════════════════════════
442    // McpConfig Tests
443    // ═══════════════════════════════════════════════════════════════
444
445    #[test]
446    fn test_mcp_config_deserialize() {
447        let yaml = r#"
448            command: "npx"
449            args:
450              - "-y"
451              - "@novanet/mcp-server"
452            env:
453              NEO4J_URI: "bolt://localhost:7687"
454              NEO4J_USER: "neo4j"
455            cwd: "/home/user/project"
456        "#;
457
458        let mut config: McpConfig = serde_yaml::from_str(yaml).unwrap();
459        config.name = "novanet".to_string();
460
461        assert_eq!(config.name, "novanet");
462        assert_eq!(config.command, "npx");
463        assert_eq!(config.args, vec!["-y", "@novanet/mcp-server"]);
464        assert_eq!(
465            config.env.get("NEO4J_URI"),
466            Some(&"bolt://localhost:7687".to_string())
467        );
468        assert_eq!(config.env.get("NEO4J_USER"), Some(&"neo4j".to_string()));
469        assert_eq!(config.cwd, Some("/home/user/project".to_string()));
470    }
471
472    #[test]
473    fn test_mcp_config_deserialize_minimal() {
474        let yaml = r#"
475            command: "node"
476        "#;
477
478        let config: McpConfig = serde_yaml::from_str(yaml).unwrap();
479
480        assert_eq!(config.command, "node");
481        assert!(config.args.is_empty());
482        assert!(config.env.is_empty());
483        assert!(config.cwd.is_none());
484    }
485
486    #[test]
487    fn test_mcp_config_builder() {
488        let config = McpConfig::new("test", "npx")
489            .with_args(["-y", "@test/server"])
490            .with_env("API_KEY", "secret")
491            .with_cwd("/tmp");
492
493        assert_eq!(config.name, "test");
494        assert_eq!(config.command, "npx");
495        assert_eq!(config.args, vec!["-y", "@test/server"]);
496        assert_eq!(config.env.get("API_KEY"), Some(&"secret".to_string()));
497        assert_eq!(config.cwd, Some("/tmp".to_string()));
498    }
499
500    #[test]
501    fn test_mcp_config_serialize_roundtrip() {
502        let config = McpConfig::new("test", "python")
503            .with_arg("server.py")
504            .with_env("DEBUG", "true");
505
506        let json = serde_json::to_string(&config).unwrap();
507        let parsed: McpConfig = serde_json::from_str(&json).unwrap();
508
509        // Note: name is skipped in serialization
510        assert_eq!(config.command, parsed.command);
511        assert_eq!(config.args, parsed.args);
512        assert_eq!(config.env, parsed.env);
513    }
514
515    // ═══════════════════════════════════════════════════════════════
516    // ToolCallRequest Tests
517    // ═══════════════════════════════════════════════════════════════
518
519    #[test]
520    fn test_tool_call_request_new() {
521        let request = ToolCallRequest::new("novanet_context");
522
523        assert_eq!(request.name, "novanet_context");
524        assert!(request.arguments.is_object());
525        assert!(request.arguments.as_object().unwrap().is_empty());
526    }
527
528    #[test]
529    fn test_tool_call_request_with_arguments() {
530        let args = serde_json::json!({
531            "entity": "qr-code",
532            "locale": "fr-FR"
533        });
534
535        let request = ToolCallRequest::new("novanet_context").with_arguments(args.clone());
536
537        assert_eq!(request.name, "novanet_context");
538        assert_eq!(request.arguments, args);
539    }
540
541    #[test]
542    fn test_tool_call_request_deserialize() {
543        let json = r#"{
544            "name": "read_file",
545            "arguments": {
546                "path": "/tmp/test.txt"
547            }
548        }"#;
549
550        let request: ToolCallRequest = serde_json::from_str(json).unwrap();
551
552        assert_eq!(request.name, "read_file");
553        assert_eq!(request.arguments["path"], "/tmp/test.txt");
554    }
555
556    // ═══════════════════════════════════════════════════════════════
557    // ToolCallResult Tests
558    // ═══════════════════════════════════════════════════════════════
559
560    #[test]
561    fn test_tool_result_text_extraction() {
562        let result = ToolCallResult::success(vec![
563            ContentBlock::text("First line"),
564            ContentBlock::image("base64data", "image/png"),
565            ContentBlock::text("Second line"),
566        ]);
567
568        assert_eq!(result.text(), "First line\nSecond line");
569        assert_eq!(result.first_text(), Some("First line"));
570        assert!(!result.is_error);
571    }
572
573    #[test]
574    fn test_tool_result_text_extraction_empty() {
575        let result = ToolCallResult::success(vec![ContentBlock::image("data", "image/png")]);
576
577        assert_eq!(result.text(), "");
578        assert_eq!(result.first_text(), None);
579    }
580
581    #[test]
582    fn test_tool_result_error() {
583        let result = ToolCallResult::error("Something went wrong");
584
585        assert!(result.is_error);
586        assert_eq!(result.text(), "Something went wrong");
587    }
588
589    #[test]
590    fn test_tool_result_deserialize() {
591        let json = r#"{
592            "content": [
593                {"type": "text", "text": "Hello, world!"}
594            ],
595            "is_error": false
596        }"#;
597
598        let result: ToolCallResult = serde_json::from_str(json).unwrap();
599
600        assert!(!result.is_error);
601        assert_eq!(result.content.len(), 1);
602        assert_eq!(result.first_text(), Some("Hello, world!"));
603    }
604
605    // ═══════════════════════════════════════════════════════════════
606    // ContentBlock Tests
607    // ═══════════════════════════════════════════════════════════════
608
609    #[test]
610    fn test_content_block_text() {
611        let block = ContentBlock::text("Hello");
612
613        assert!(block.is_text());
614        assert!(!block.is_image());
615        assert!(!block.is_resource());
616        assert!(matches!(block, ContentBlock::Text { ref text } if text == "Hello"));
617    }
618
619    #[test]
620    fn test_content_block_image() {
621        let block = ContentBlock::image("SGVsbG8=", "image/png");
622
623        assert!(block.is_image());
624        assert!(!block.is_text());
625        assert!(matches!(
626            block,
627            ContentBlock::Image { ref data, ref mime_type }
628            if data == "SGVsbG8=" && mime_type == "image/png"
629        ));
630    }
631
632    #[test]
633    fn test_content_block_resource() {
634        let resource = ResourceContent::new("file:///tmp/test.txt").with_text("File content");
635        let block = ContentBlock::resource(resource);
636
637        assert!(block.is_resource());
638        assert!(!block.is_text());
639        assert!(
640            matches!(block, ContentBlock::Resource(ref rc) if rc.uri == "file:///tmp/test.txt")
641        );
642    }
643
644    #[test]
645    fn test_content_block_deserialize() {
646        let json = r#"{
647            "type": "text",
648            "text": "Hello from MCP"
649        }"#;
650
651        let block: ContentBlock = serde_json::from_str(json).unwrap();
652
653        assert!(block.is_text());
654        assert!(matches!(block, ContentBlock::Text { ref text } if text == "Hello from MCP"));
655    }
656
657    // ═══════════════════════════════════════════════════════════════
658    // ResourceContent Tests
659    // ═══════════════════════════════════════════════════════════════
660
661    #[test]
662    fn test_resource_content_builder() {
663        let resource = ResourceContent::new("neo4j://entity/qr-code")
664            .with_mime_type("application/json")
665            .with_text(r#"{"name": "QR Code"}"#);
666
667        assert_eq!(resource.uri, "neo4j://entity/qr-code");
668        assert_eq!(resource.mime_type, Some("application/json".to_string()));
669        assert_eq!(resource.text, Some(r#"{"name": "QR Code"}"#.to_string()));
670    }
671
672    #[test]
673    fn test_resource_content_deserialize() {
674        let json = r#"{
675            "uri": "file:///tmp/data.json",
676            "mimeType": "application/json",
677            "text": "{\"key\": \"value\"}"
678        }"#;
679
680        let resource: ResourceContent = serde_json::from_str(json).unwrap();
681
682        assert_eq!(resource.uri, "file:///tmp/data.json");
683        assert_eq!(resource.mime_type, Some("application/json".to_string()));
684    }
685
686    // ═══════════════════════════════════════════════════════════════
687    // ToolDefinition Tests
688    // ═══════════════════════════════════════════════════════════════
689
690    #[test]
691    fn test_tool_definition_builder() {
692        let schema = serde_json::json!({
693            "type": "object",
694            "properties": {
695                "entity": {"type": "string"},
696                "locale": {"type": "string"}
697            },
698            "required": ["entity"]
699        });
700
701        let tool = ToolDefinition::new("novanet_context")
702            .with_description("Generate native content for an entity")
703            .with_input_schema(schema.clone());
704
705        assert_eq!(tool.name, "novanet_context");
706        assert_eq!(
707            tool.description,
708            Some("Generate native content for an entity".to_string())
709        );
710        assert_eq!(tool.input_schema, Some(schema));
711    }
712
713    #[test]
714    fn test_tool_definition_deserialize() {
715        let json = r#"{
716            "name": "read_resource",
717            "description": "Read a resource from the server",
718            "inputSchema": {
719                "type": "object",
720                "properties": {
721                    "uri": {"type": "string"}
722                }
723            }
724        }"#;
725
726        let tool: ToolDefinition = serde_json::from_str(json).unwrap();
727
728        assert_eq!(tool.name, "read_resource");
729        assert_eq!(
730            tool.description,
731            Some("Read a resource from the server".to_string())
732        );
733        assert!(tool.input_schema.is_some());
734    }
735
736    #[test]
737    fn test_tool_definition_minimal() {
738        let json = r#"{"name": "ping"}"#;
739
740        let tool: ToolDefinition = serde_json::from_str(json).unwrap();
741
742        assert_eq!(tool.name, "ping");
743        assert!(tool.description.is_none());
744        assert!(tool.input_schema.is_none());
745    }
746
747    // ═══════════════════════════════════════════════════════════════
748    // McpErrorCode Tests
749    // ═══════════════════════════════════════════════════════════════
750
751    #[test]
752    fn test_mcp_error_code_standard_codes() {
753        assert_eq!(McpErrorCode::from_code(-32700), McpErrorCode::ParseError);
754        assert_eq!(
755            McpErrorCode::from_code(-32600),
756            McpErrorCode::InvalidRequest
757        );
758        assert_eq!(
759            McpErrorCode::from_code(-32601),
760            McpErrorCode::MethodNotFound
761        );
762        assert_eq!(McpErrorCode::from_code(-32602), McpErrorCode::InvalidParams);
763        assert_eq!(McpErrorCode::from_code(-32603), McpErrorCode::InternalError);
764    }
765
766    #[test]
767    fn test_mcp_error_code_server_error_range() {
768        let code = McpErrorCode::from_code(-32050);
769        assert!(matches!(code, McpErrorCode::ServerError(-32050)));
770        assert!(code.is_server_error());
771        assert!(!code.is_client_error());
772    }
773
774    #[test]
775    fn test_mcp_error_code_unknown() {
776        let code = McpErrorCode::from_code(42);
777        assert!(matches!(code, McpErrorCode::Unknown(42)));
778        assert!(!code.is_server_error());
779        assert!(!code.is_client_error());
780    }
781
782    #[test]
783    fn test_mcp_error_code_client_errors() {
784        assert!(McpErrorCode::ParseError.is_client_error());
785        assert!(McpErrorCode::InvalidRequest.is_client_error());
786        assert!(McpErrorCode::InvalidParams.is_client_error());
787        assert!(!McpErrorCode::MethodNotFound.is_client_error());
788        assert!(!McpErrorCode::InternalError.is_client_error());
789    }
790
791    #[test]
792    fn test_mcp_error_code_server_errors() {
793        assert!(McpErrorCode::MethodNotFound.is_server_error());
794        assert!(McpErrorCode::InternalError.is_server_error());
795        assert!(McpErrorCode::ServerError(-32050).is_server_error());
796        assert!(!McpErrorCode::ParseError.is_server_error());
797    }
798
799    #[test]
800    fn test_mcp_error_code_display() {
801        let code = McpErrorCode::InvalidParams;
802        let display = format!("{}", code);
803        assert!(display.contains("-32602"));
804        assert!(display.contains("Invalid method parameter"));
805    }
806
807    #[test]
808    fn test_mcp_error_code_serde_roundtrip() {
809        let original = McpErrorCode::InvalidParams;
810        let json = serde_json::to_string(&original).unwrap();
811        assert_eq!(json, "-32602");
812
813        let parsed: McpErrorCode = serde_json::from_str(&json).unwrap();
814        assert_eq!(parsed, original);
815    }
816
817    #[test]
818    fn test_mcp_error_code_into_i32() {
819        let code = McpErrorCode::ParseError;
820        let num: i32 = code.into();
821        assert_eq!(num, -32700);
822    }
823
824    // ==========================================================================
825    // Tests for is_retryable()
826    // ==========================================================================
827
828    #[test]
829    fn test_mcp_error_code_retryable_internal_error() {
830        let code = McpErrorCode::InternalError;
831        assert!(code.is_retryable());
832    }
833
834    #[test]
835    fn test_mcp_error_code_retryable_server_error() {
836        let code = McpErrorCode::ServerError(-32050);
837        assert!(code.is_retryable());
838    }
839
840    #[test]
841    fn test_mcp_error_code_retryable_unknown() {
842        let code = McpErrorCode::Unknown(-999);
843        assert!(code.is_retryable());
844    }
845
846    #[test]
847    fn test_mcp_error_code_not_retryable_parse_error() {
848        let code = McpErrorCode::ParseError;
849        assert!(!code.is_retryable());
850    }
851
852    #[test]
853    fn test_mcp_error_code_not_retryable_invalid_request() {
854        let code = McpErrorCode::InvalidRequest;
855        assert!(!code.is_retryable());
856    }
857
858    #[test]
859    fn test_mcp_error_code_not_retryable_invalid_params() {
860        let code = McpErrorCode::InvalidParams;
861        assert!(!code.is_retryable());
862    }
863
864    #[test]
865    fn test_mcp_error_code_not_retryable_method_not_found() {
866        let code = McpErrorCode::MethodNotFound;
867        assert!(!code.is_retryable());
868    }
869
870    // ==========================================================================
871    // Tests for expand_env_vars()
872    // ==========================================================================
873
874    #[test]
875    #[serial]
876    fn test_expand_env_vars_command() {
877        std::env::set_var("NIKA_TEST_BIN", "/usr/local/bin");
878        let config = McpConfig::new("test", "$NIKA_TEST_BIN/server")
879            .expand_env_vars()
880            .unwrap();
881
882        assert_eq!(config.command, "/usr/local/bin/server");
883        std::env::remove_var("NIKA_TEST_BIN");
884    }
885
886    #[test]
887    #[serial]
888    fn test_expand_env_vars_args() {
889        std::env::set_var("NIKA_TEST_CONFIG", "/etc/mcp");
890        let config = McpConfig::new("test", "server")
891            .with_arg("--config=$NIKA_TEST_CONFIG/config.json")
892            .expand_env_vars()
893            .unwrap();
894
895        assert_eq!(config.args[0], "--config=/etc/mcp/config.json");
896        std::env::remove_var("NIKA_TEST_CONFIG");
897    }
898
899    #[test]
900    #[serial]
901    fn test_expand_env_vars_env_values() {
902        std::env::set_var("NIKA_TEST_ROOT", "/var/lib");
903        let config = McpConfig::new("test", "server")
904            .with_env("DATA_DIR", "$NIKA_TEST_ROOT/mcp")
905            .expand_env_vars()
906            .unwrap();
907
908        assert_eq!(config.env.get("DATA_DIR").unwrap(), "/var/lib/mcp");
909        std::env::remove_var("NIKA_TEST_ROOT");
910    }
911
912    #[test]
913    fn test_expand_env_vars_tilde() {
914        // shellexpand::full expands ~ only at the START of a string
915        let config = McpConfig::new("test", "~/bin/server")
916            .expand_env_vars()
917            .unwrap();
918
919        // Command should have ~ expanded (it's at the start)
920        assert!(!config.command.contains('~'));
921        assert!(config.command.contains("/bin/server"));
922        // Check it expanded to actual home
923        assert!(config.command.starts_with('/'));
924    }
925
926    #[test]
927    #[serial]
928    fn test_expand_env_vars_curly_brace_syntax() {
929        std::env::set_var("NIKA_TEST_PATH", "/opt/mcp");
930        let config = McpConfig::new("test", "${NIKA_TEST_PATH}/server")
931            .expand_env_vars()
932            .unwrap();
933
934        assert_eq!(config.command, "/opt/mcp/server");
935        std::env::remove_var("NIKA_TEST_PATH");
936    }
937
938    #[test]
939    fn test_expand_env_vars_no_expansion_needed() {
940        let config = McpConfig::new("test", "/usr/bin/server")
941            .with_arg("--port=8080")
942            .with_env("LOG_LEVEL", "debug")
943            .expand_env_vars()
944            .unwrap();
945
946        assert_eq!(config.command, "/usr/bin/server");
947        assert_eq!(config.args[0], "--port=8080");
948        assert_eq!(config.env.get("LOG_LEVEL").unwrap(), "debug");
949    }
950
951    #[test]
952    #[serial]
953    fn test_expand_env_vars_cwd() {
954        std::env::set_var("NIKA_TEST_DIR", "/home/user/projects");
955        let config = McpConfig::new("test", "server")
956            .with_cwd("$NIKA_TEST_DIR/mcp")
957            .expand_env_vars()
958            .unwrap();
959
960        assert_eq!(config.cwd.unwrap(), "/home/user/projects/mcp");
961        std::env::remove_var("NIKA_TEST_DIR");
962    }
963
964    // ═══════════════════════════════════════════════════════════════
965    // ContentBlock Enum Tests (PR1)
966    // ═══════════════════════════════════════════════════════════════
967
968    #[test]
969    fn test_content_block_audio_enum() {
970        let block = ContentBlock::audio("b64audio", "audio/wav");
971        assert!(block.is_audio());
972        assert!(!block.is_text());
973        assert!(!block.is_image());
974        assert!(matches!(
975            block,
976            ContentBlock::Audio { ref data, ref mime_type }
977            if data == "b64audio" && mime_type == "audio/wav"
978        ));
979    }
980
981    #[test]
982    fn test_content_block_resource_link_enum() {
983        let block = ContentBlock::resource_link(
984            "file:///tmp/test.txt",
985            Some("test.txt".to_string()),
986            Some("text/plain".to_string()),
987        );
988        assert!(block.is_resource_link());
989        assert!(!block.is_text());
990        assert!(!block.is_resource());
991    }
992
993    #[test]
994    fn test_content_block_serde_roundtrip_all_variants() {
995        let blocks = [
996            ContentBlock::text("hello"),
997            ContentBlock::image("b64", "image/png"),
998            ContentBlock::audio("b64", "audio/wav"),
999            ContentBlock::resource(ResourceContent::new("file:///test").with_text("content")),
1000            ContentBlock::resource_link("file:///link", None, None),
1001        ];
1002        for (i, block) in blocks.iter().enumerate() {
1003            let json = serde_json::to_string(block)
1004                .unwrap_or_else(|e| panic!("variant {i} failed to serialize: {e}"));
1005            let back: ContentBlock = serde_json::from_str(&json)
1006                .unwrap_or_else(|e| panic!("variant {i} failed to deserialize: {e}\nJSON: {json}"));
1007            assert_eq!(*block, back, "variant {i} roundtrip mismatch");
1008        }
1009    }
1010
1011    #[test]
1012    fn test_debug_print_all_variants_json() {
1013        let blocks = vec![
1014            ("text", ContentBlock::text("hello")),
1015            ("image", ContentBlock::image("b64", "image/png")),
1016            ("audio", ContentBlock::audio("b64", "audio/wav")),
1017            (
1018                "resource",
1019                ContentBlock::resource(ResourceContent::new("file:///test").with_text("content")),
1020            ),
1021            (
1022                "resource_link_none",
1023                ContentBlock::resource_link("file:///link", None, None),
1024            ),
1025            (
1026                "resource_link_full",
1027                ContentBlock::resource_link(
1028                    "file:///link",
1029                    Some("test.txt".into()),
1030                    Some("text/plain".into()),
1031                ),
1032            ),
1033        ];
1034        for (label, block) in &blocks {
1035            let json = serde_json::to_string_pretty(block).unwrap();
1036            println!("=== {label} ===\n{json}\n");
1037        }
1038    }
1039
1040    #[test]
1041    fn test_content_block_text_json_format() {
1042        let block = ContentBlock::text("hello world");
1043        let json = serde_json::to_value(&block).unwrap();
1044        assert_eq!(json["type"], "text");
1045        assert_eq!(json["text"], "hello world");
1046    }
1047
1048    #[test]
1049    fn test_content_block_image_json_has_mime_type_camel_case() {
1050        let block = ContentBlock::image("b64data", "image/png");
1051        let json = serde_json::to_value(&block).unwrap();
1052        assert_eq!(json["type"], "image");
1053        assert_eq!(json["mimeType"], "image/png");
1054        assert!(
1055            json.get("mime_type").is_none(),
1056            "should use mimeType not mime_type"
1057        );
1058    }
1059
1060    #[test]
1061    fn test_content_block_resource_link_skips_none_fields() {
1062        let block = ContentBlock::resource_link("file:///link", None, None);
1063        let json = serde_json::to_string(&block).unwrap();
1064        assert!(!json.contains("name"), "None name should be skipped");
1065        assert!(
1066            !json.contains("mimeType"),
1067            "None mimeType should be skipped"
1068        );
1069    }
1070
1071    // ═══════════════════════════════════════════════════════════════
1072    // ToolCallResult Media Helper Tests (PR1)
1073    // ═══════════════════════════════════════════════════════════════
1074
1075    #[test]
1076    fn test_has_media_false_for_text_only() {
1077        let result = ToolCallResult::success(vec![ContentBlock::text("hello")]);
1078        assert!(!result.has_media());
1079        assert!(result.images().is_empty());
1080        assert!(result.audio_blocks().is_empty());
1081        assert!(result.media_blocks().is_empty());
1082    }
1083
1084    #[test]
1085    fn test_has_media_true_for_mixed_content() {
1086        let result = ToolCallResult::success(vec![
1087            ContentBlock::text("description"),
1088            ContentBlock::image("b64", "image/png"),
1089            ContentBlock::audio("b64", "audio/wav"),
1090        ]);
1091        assert!(result.has_media());
1092        assert_eq!(result.images().len(), 1);
1093        assert_eq!(result.audio_blocks().len(), 1);
1094        assert_eq!(result.media_blocks().len(), 2);
1095    }
1096
1097    #[test]
1098    fn test_text_preserved_with_media() {
1099        let result = ToolCallResult::success(vec![
1100            ContentBlock::text("First line"),
1101            ContentBlock::image("b64data", "image/png"),
1102            ContentBlock::text("Second line"),
1103        ]);
1104        assert_eq!(result.text(), "First line\nSecond line");
1105        assert_eq!(result.first_text(), Some("First line"));
1106        assert!(result.has_media());
1107    }
1108
1109    #[test]
1110    fn test_empty_content_vec() {
1111        let result = ToolCallResult::success(vec![]);
1112        assert!(!result.has_media());
1113        assert_eq!(result.text(), "");
1114        assert!(result.images().is_empty());
1115        assert!(result.media_blocks().is_empty());
1116    }
1117
1118    #[test]
1119    fn test_with_blob_builder() {
1120        let rc = ResourceContent::new("file:///test")
1121            .with_blob("base64data")
1122            .with_mime_type("application/pdf");
1123        assert_eq!(rc.blob, Some("base64data".to_string()));
1124        assert_eq!(rc.mime_type, Some("application/pdf".to_string()));
1125    }
1126
1127    // ═══════════════════════════════════════════════════════════════════════
1128    // Exhaustive ContentBlock Serde Tests
1129    //
1130    // These tests simulate real MCP server JSON responses to catch silent
1131    // serialization/deserialization bugs (field naming, missing fields,
1132    // variant tagging, Option skipping, etc.).
1133    // ═══════════════════════════════════════════════════════════════════════
1134
1135    // ---------------------------------------------------------------
1136    // 1. Deserialization from external JSON (MCP server responses)
1137    // ---------------------------------------------------------------
1138
1139    #[test]
1140    fn test_deser_text_from_mcp_json() {
1141        let json = r#"{"type": "text", "text": "hello"}"#;
1142        let block: ContentBlock = serde_json::from_str(json).unwrap();
1143        assert!(block.is_text());
1144        assert_eq!(
1145            block,
1146            ContentBlock::Text {
1147                text: "hello".into()
1148            }
1149        );
1150    }
1151
1152    #[test]
1153    fn test_deser_image_from_mcp_json() {
1154        let json = r#"{"type": "image", "data": "b64data", "mimeType": "image/png"}"#;
1155        let block: ContentBlock = serde_json::from_str(json).unwrap();
1156        assert!(block.is_image());
1157        assert_eq!(
1158            block,
1159            ContentBlock::Image {
1160                data: "b64data".into(),
1161                mime_type: "image/png".into(),
1162            }
1163        );
1164    }
1165
1166    #[test]
1167    fn test_deser_audio_from_mcp_json() {
1168        let json = r#"{"type": "audio", "data": "b64data", "mimeType": "audio/wav"}"#;
1169        let block: ContentBlock = serde_json::from_str(json).unwrap();
1170        assert!(block.is_audio());
1171        assert_eq!(
1172            block,
1173            ContentBlock::Audio {
1174                data: "b64data".into(),
1175                mime_type: "audio/wav".into(),
1176            }
1177        );
1178    }
1179
1180    #[test]
1181    fn test_deser_resource_from_mcp_json() {
1182        let json = r#"{"type": "resource", "uri": "file:///test", "text": "content"}"#;
1183        let block: ContentBlock = serde_json::from_str(json).unwrap();
1184        assert!(block.is_resource());
1185        let expected =
1186            ContentBlock::Resource(ResourceContent::new("file:///test").with_text("content"));
1187        assert_eq!(block, expected);
1188    }
1189
1190    #[test]
1191    fn test_deser_resource_link_from_mcp_json() {
1192        let json = r#"{"type": "resource_link", "uri": "file:///link"}"#;
1193        let block: ContentBlock = serde_json::from_str(json).unwrap();
1194        assert!(block.is_resource_link());
1195        assert_eq!(
1196            block,
1197            ContentBlock::ResourceLink {
1198                uri: "file:///link".into(),
1199                name: None,
1200                mime_type: None,
1201            }
1202        );
1203    }
1204
1205    #[test]
1206    fn test_deser_resource_link_with_optional_fields() {
1207        let json = r#"{
1208            "type": "resource_link",
1209            "uri": "file:///link",
1210            "name": "report.pdf",
1211            "mimeType": "application/pdf"
1212        }"#;
1213        let block: ContentBlock = serde_json::from_str(json).unwrap();
1214        assert_eq!(
1215            block,
1216            ContentBlock::ResourceLink {
1217                uri: "file:///link".into(),
1218                name: Some("report.pdf".into()),
1219                mime_type: Some("application/pdf".into()),
1220            }
1221        );
1222    }
1223
1224    // ---------------------------------------------------------------
1225    // 2. Error cases — malformed / invalid JSON
1226    // ---------------------------------------------------------------
1227
1228    #[test]
1229    fn test_deser_image_missing_mime_type_fails() {
1230        let json = r#"{"type": "image", "data": "x"}"#;
1231        let result = serde_json::from_str::<ContentBlock>(json);
1232        assert!(
1233            result.is_err(),
1234            "image without mimeType should fail to deserialize"
1235        );
1236    }
1237
1238    #[test]
1239    fn test_deser_image_missing_data_fails() {
1240        let json = r#"{"type": "image", "mimeType": "image/png"}"#;
1241        let result = serde_json::from_str::<ContentBlock>(json);
1242        assert!(
1243            result.is_err(),
1244            "image without data should fail to deserialize"
1245        );
1246    }
1247
1248    #[test]
1249    fn test_deser_audio_missing_mime_type_fails() {
1250        let json = r#"{"type": "audio", "data": "x"}"#;
1251        let result = serde_json::from_str::<ContentBlock>(json);
1252        assert!(
1253            result.is_err(),
1254            "audio without mimeType should fail to deserialize"
1255        );
1256    }
1257
1258    #[test]
1259    fn test_deser_audio_missing_data_fails() {
1260        let json = r#"{"type": "audio", "mimeType": "audio/wav"}"#;
1261        let result = serde_json::from_str::<ContentBlock>(json);
1262        assert!(
1263            result.is_err(),
1264            "audio without data should fail to deserialize"
1265        );
1266    }
1267
1268    #[test]
1269    fn test_deser_text_missing_text_field_fails() {
1270        let json = r#"{"type": "text"}"#;
1271        let result = serde_json::from_str::<ContentBlock>(json);
1272        assert!(
1273            result.is_err(),
1274            "text without text field should fail to deserialize"
1275        );
1276    }
1277
1278    #[test]
1279    fn test_deser_resource_missing_uri_fails() {
1280        let json = r#"{"type": "resource", "text": "content"}"#;
1281        let result = serde_json::from_str::<ContentBlock>(json);
1282        assert!(
1283            result.is_err(),
1284            "resource without uri should fail to deserialize"
1285        );
1286    }
1287
1288    #[test]
1289    fn test_deser_resource_link_missing_uri_fails() {
1290        let json = r#"{"type": "resource_link", "name": "test.txt"}"#;
1291        let result = serde_json::from_str::<ContentBlock>(json);
1292        assert!(
1293            result.is_err(),
1294            "resource_link without uri should fail to deserialize"
1295        );
1296    }
1297
1298    #[test]
1299    fn test_deser_extra_unknown_fields_succeeds() {
1300        // serde default: unknown fields are ignored (no deny_unknown_fields)
1301        let json = r#"{"type": "text", "text": "hi", "extra": true, "count": 42}"#;
1302        let block: ContentBlock = serde_json::from_str(json).unwrap();
1303        assert_eq!(block, ContentBlock::text("hi"));
1304    }
1305
1306    #[test]
1307    fn test_deser_invalid_type_value_fails() {
1308        let json = r#"{"type": "video", "data": "x"}"#;
1309        let result = serde_json::from_str::<ContentBlock>(json);
1310        assert!(result.is_err(), "unknown type 'video' should fail");
1311    }
1312
1313    #[test]
1314    fn test_deser_empty_string_type_fails() {
1315        let json = r#"{"type": "", "text": "hello"}"#;
1316        let result = serde_json::from_str::<ContentBlock>(json);
1317        assert!(result.is_err(), "empty string type should fail");
1318    }
1319
1320    #[test]
1321    fn test_deser_missing_type_field_fails() {
1322        let json = r#"{"text": "hello"}"#;
1323        let result = serde_json::from_str::<ContentBlock>(json);
1324        assert!(result.is_err(), "missing type field should fail");
1325    }
1326
1327    #[test]
1328    fn test_deser_null_type_fails() {
1329        let json = r#"{"type": null, "text": "hello"}"#;
1330        let result = serde_json::from_str::<ContentBlock>(json);
1331        assert!(result.is_err(), "null type should fail");
1332    }
1333
1334    #[test]
1335    fn test_deser_numeric_type_fails() {
1336        let json = r#"{"type": 42, "text": "hello"}"#;
1337        let result = serde_json::from_str::<ContentBlock>(json);
1338        assert!(result.is_err(), "numeric type should fail");
1339    }
1340
1341    // ---------------------------------------------------------------
1342    // 3. Resource variant: flat JSON structure + skip_serializing_if
1343    // ---------------------------------------------------------------
1344
1345    #[test]
1346    fn test_resource_serializes_flat_not_nested() {
1347        let block = ContentBlock::resource(
1348            ResourceContent::new("file:///test")
1349                .with_text("hello")
1350                .with_mime_type("text/plain"),
1351        );
1352        let json = serde_json::to_value(&block).unwrap();
1353
1354        // Fields should be at top level, NOT nested under a "resource" key
1355        assert_eq!(json["type"], "resource");
1356        assert_eq!(json["uri"], "file:///test");
1357        assert_eq!(json["text"], "hello");
1358        assert_eq!(json["mimeType"], "text/plain");
1359        assert!(
1360            json.get("resource").is_none(),
1361            "should NOT have nested 'resource' key"
1362        );
1363    }
1364
1365    #[test]
1366    fn test_resource_content_none_fields_omitted_in_serialization() {
1367        let rc = ResourceContent::new("file:///bare");
1368        let json = serde_json::to_value(&rc).unwrap();
1369
1370        assert_eq!(json["uri"], "file:///bare");
1371        assert!(
1372            json.get("mimeType").is_none(),
1373            "None mimeType should be omitted"
1374        );
1375        assert!(json.get("text").is_none(), "None text should be omitted");
1376        assert!(json.get("blob").is_none(), "None blob should be omitted");
1377    }
1378
1379    #[test]
1380    fn test_resource_content_some_fields_present_in_serialization() {
1381        let rc = ResourceContent::new("file:///full")
1382            .with_mime_type("application/json")
1383            .with_text("{}")
1384            .with_blob("YmluYXJ5");
1385        let json = serde_json::to_value(&rc).unwrap();
1386
1387        assert_eq!(json["uri"], "file:///full");
1388        assert_eq!(json["mimeType"], "application/json");
1389        assert_eq!(json["text"], "{}");
1390        assert_eq!(json["blob"], "YmluYXJ5");
1391    }
1392
1393    #[test]
1394    fn test_resource_block_none_fields_omitted_via_content_block() {
1395        // When wrapped in ContentBlock, the skip_serializing_if should still apply
1396        let block = ContentBlock::resource(ResourceContent::new("file:///bare"));
1397        let json_str = serde_json::to_string(&block).unwrap();
1398
1399        assert!(
1400            !json_str.contains("mimeType"),
1401            "None mimeType should be omitted"
1402        );
1403        assert!(
1404            !json_str.contains("\"text\""),
1405            "None text should be omitted"
1406        );
1407        assert!(!json_str.contains("blob"), "None blob should be omitted");
1408        assert!(json_str.contains("\"type\""));
1409        assert!(json_str.contains("\"uri\""));
1410    }
1411
1412    #[test]
1413    fn test_resource_content_roundtrip_with_all_fields() {
1414        let original = ResourceContent::new("file:///test")
1415            .with_mime_type("application/octet-stream")
1416            .with_text("textual fallback")
1417            .with_blob("YmxvYg==");
1418
1419        let json = serde_json::to_string(&original).unwrap();
1420        let parsed: ResourceContent = serde_json::from_str(&json).unwrap();
1421        assert_eq!(original, parsed);
1422    }
1423
1424    #[test]
1425    fn test_resource_content_roundtrip_with_no_optional_fields() {
1426        let original = ResourceContent::new("file:///minimal");
1427        let json = serde_json::to_string(&original).unwrap();
1428        let parsed: ResourceContent = serde_json::from_str(&json).unwrap();
1429        assert_eq!(original, parsed);
1430    }
1431
1432    // ---------------------------------------------------------------
1433    // 4. ToolCallResult with mixed content
1434    // ---------------------------------------------------------------
1435
1436    #[test]
1437    fn test_tool_call_result_deser_mixed_content() {
1438        let json = r#"{
1439            "content": [
1440                {"type": "text", "text": "Analysis complete"},
1441                {"type": "image", "data": "iVBORw0KGgo=", "mimeType": "image/png"},
1442                {"type": "audio", "data": "UklGRg==", "mimeType": "audio/wav"},
1443                {"type": "text", "text": "See attached media"},
1444                {"type": "resource", "uri": "file:///data.json", "text": "{\"key\": 1}"},
1445                {"type": "resource_link", "uri": "file:///extra"}
1446            ],
1447            "is_error": false
1448        }"#;
1449
1450        let result: ToolCallResult = serde_json::from_str(json).unwrap();
1451
1452        assert!(!result.is_error);
1453        assert_eq!(result.content.len(), 6);
1454
1455        // text() joins only text blocks
1456        assert_eq!(result.text(), "Analysis complete\nSee attached media");
1457
1458        // has_media() should be true (image, audio, resource, resource_link)
1459        assert!(result.has_media());
1460
1461        // media_blocks() = everything except text = 4 blocks
1462        assert_eq!(result.media_blocks().len(), 4);
1463
1464        // images() = 1
1465        assert_eq!(result.images().len(), 1);
1466        assert!(result.images()[0].is_image());
1467
1468        // audio_blocks() = 1
1469        assert_eq!(result.audio_blocks().len(), 1);
1470        assert!(result.audio_blocks()[0].is_audio());
1471
1472        // first_text()
1473        assert_eq!(result.first_text(), Some("Analysis complete"));
1474    }
1475
1476    #[test]
1477    fn test_tool_call_result_deser_error_with_mixed_content() {
1478        let json = r#"{
1479            "content": [
1480                {"type": "text", "text": "Partial failure"},
1481                {"type": "image", "data": "corrupt", "mimeType": "image/jpeg"}
1482            ],
1483            "is_error": true
1484        }"#;
1485
1486        let result: ToolCallResult = serde_json::from_str(json).unwrap();
1487        assert!(result.is_error);
1488        assert!(result.has_media());
1489        assert_eq!(result.text(), "Partial failure");
1490    }
1491
1492    #[test]
1493    fn test_tool_call_result_deser_is_error_defaults_false() {
1494        // is_error has #[serde(default)], so omitting it should default to false
1495        let json = r#"{
1496            "content": [{"type": "text", "text": "ok"}]
1497        }"#;
1498
1499        let result: ToolCallResult = serde_json::from_str(json).unwrap();
1500        assert!(!result.is_error);
1501    }
1502
1503    #[test]
1504    fn test_tool_call_result_roundtrip_mixed() {
1505        let original = ToolCallResult::success(vec![
1506            ContentBlock::text("output"),
1507            ContentBlock::image("aW1n", "image/webp"),
1508            ContentBlock::audio("YXVk", "audio/mp3"),
1509            ContentBlock::resource(ResourceContent::new("file:///r").with_text("resource text")),
1510            ContentBlock::resource_link("file:///rl", Some("name".into()), None),
1511        ]);
1512
1513        let json = serde_json::to_string(&original).unwrap();
1514        let parsed: ToolCallResult = serde_json::from_str(&json).unwrap();
1515        assert_eq!(original, parsed);
1516    }
1517
1518    #[test]
1519    fn test_tool_call_result_empty_content_array() {
1520        let json = r#"{"content": [], "is_error": false}"#;
1521        let result: ToolCallResult = serde_json::from_str(json).unwrap();
1522        assert_eq!(result.content.len(), 0);
1523        assert!(!result.has_media());
1524        assert_eq!(result.text(), "");
1525        assert!(result.first_text().is_none());
1526    }
1527
1528    // ---------------------------------------------------------------
1529    // 5. Unicode and special characters
1530    // ---------------------------------------------------------------
1531
1532    #[test]
1533    fn test_text_block_with_emoji() {
1534        let json = r#"{"type": "text", "text": "Hello 🌍"}"#;
1535        let block: ContentBlock = serde_json::from_str(json).unwrap();
1536        assert_eq!(block, ContentBlock::text("Hello 🌍"));
1537    }
1538
1539    #[test]
1540    fn test_text_block_with_newlines() {
1541        let json = "{\"type\": \"text\", \"text\": \"line1\\nline2\"}";
1542        let block: ContentBlock = serde_json::from_str(json).unwrap();
1543        assert_eq!(block, ContentBlock::text("line1\nline2"));
1544    }
1545
1546    #[test]
1547    fn test_text_block_with_unicode_escapes() {
1548        // JSON unicode escape for e-acute: \u00e9
1549        let json = r#"{"type": "text", "text": "caf\u00e9"}"#;
1550        let block: ContentBlock = serde_json::from_str(json).unwrap();
1551        assert_eq!(block, ContentBlock::text("caf\u{00e9}"));
1552    }
1553
1554    #[test]
1555    fn test_text_block_with_json_special_chars() {
1556        // Text containing quotes, backslashes, tabs
1557        let json = r#"{"type": "text", "text": "quote: \" backslash: \\ tab: \t"}"#;
1558        let block: ContentBlock = serde_json::from_str(json).unwrap();
1559        assert_eq!(block, ContentBlock::text("quote: \" backslash: \\ tab: \t"));
1560    }
1561
1562    #[test]
1563    fn test_text_block_empty_string() {
1564        let json = r#"{"type": "text", "text": ""}"#;
1565        let block: ContentBlock = serde_json::from_str(json).unwrap();
1566        assert_eq!(block, ContentBlock::text(""));
1567    }
1568
1569    #[test]
1570    fn test_image_data_with_base64_special_chars() {
1571        // Base64 alphabet includes +, /, and = (padding)
1572        let b64 = "abc+def/ghi=";
1573        let json = format!(
1574            r#"{{"type": "image", "data": "{}", "mimeType": "image/png"}}"#,
1575            b64
1576        );
1577        let block: ContentBlock = serde_json::from_str(&json).unwrap();
1578        assert_eq!(
1579            block,
1580            ContentBlock::Image {
1581                data: b64.into(),
1582                mime_type: "image/png".into(),
1583            }
1584        );
1585    }
1586
1587    #[test]
1588    fn test_image_data_with_double_padding() {
1589        let b64 = "YQ==";
1590        let json = format!(
1591            r#"{{"type": "image", "data": "{}", "mimeType": "image/gif"}}"#,
1592            b64
1593        );
1594        let block: ContentBlock = serde_json::from_str(&json).unwrap();
1595        assert_eq!(
1596            block,
1597            ContentBlock::Image {
1598                data: b64.into(),
1599                mime_type: "image/gif".into(),
1600            }
1601        );
1602    }
1603
1604    #[test]
1605    fn test_resource_uri_with_special_chars() {
1606        let json = r#"{"type": "resource", "uri": "file:///path/to/my%20file.txt", "text": "ok"}"#;
1607        let block: ContentBlock = serde_json::from_str(json).unwrap();
1608        assert!(block.is_resource());
1609        if let ContentBlock::Resource(rc) = &block {
1610            assert_eq!(rc.uri, "file:///path/to/my%20file.txt");
1611        }
1612    }
1613
1614    // ---------------------------------------------------------------
1615    // 6. Field naming invariants (camelCase in JSON, snake_case in Rust)
1616    // ---------------------------------------------------------------
1617
1618    #[test]
1619    fn test_image_serialization_uses_camel_case_mime_type() {
1620        let block = ContentBlock::image("data", "image/jpeg");
1621        let json = serde_json::to_value(&block).unwrap();
1622
1623        // Must be "mimeType" (camelCase), NOT "mime_type" (snake_case)
1624        assert!(
1625            json.get("mimeType").is_some(),
1626            "should serialize as mimeType"
1627        );
1628        assert!(
1629            json.get("mime_type").is_none(),
1630            "should NOT serialize as mime_type"
1631        );
1632    }
1633
1634    #[test]
1635    fn test_audio_serialization_uses_camel_case_mime_type() {
1636        let block = ContentBlock::audio("data", "audio/ogg");
1637        let json = serde_json::to_value(&block).unwrap();
1638
1639        assert!(
1640            json.get("mimeType").is_some(),
1641            "should serialize as mimeType"
1642        );
1643        assert!(
1644            json.get("mime_type").is_none(),
1645            "should NOT serialize as mime_type"
1646        );
1647    }
1648
1649    #[test]
1650    fn test_resource_link_serialization_uses_camel_case_mime_type() {
1651        let block = ContentBlock::resource_link("file:///x", None, Some("text/html".into()));
1652        let json = serde_json::to_value(&block).unwrap();
1653
1654        assert!(
1655            json.get("mimeType").is_some(),
1656            "should serialize as mimeType"
1657        );
1658        assert!(
1659            json.get("mime_type").is_none(),
1660            "should NOT serialize as mime_type"
1661        );
1662    }
1663
1664    #[test]
1665    fn test_resource_content_serialization_uses_camel_case_mime_type() {
1666        let rc = ResourceContent::new("file:///x").with_mime_type("text/plain");
1667        let json = serde_json::to_value(&rc).unwrap();
1668
1669        assert!(
1670            json.get("mimeType").is_some(),
1671            "should serialize as mimeType"
1672        );
1673        assert!(
1674            json.get("mime_type").is_none(),
1675            "should NOT serialize as mime_type"
1676        );
1677    }
1678
1679    #[test]
1680    fn test_image_deser_rejects_snake_case_mime_type() {
1681        // MCP spec uses camelCase; snake_case should NOT work
1682        let json = r#"{"type": "image", "data": "x", "mime_type": "image/png"}"#;
1683        let result = serde_json::from_str::<ContentBlock>(json);
1684        assert!(
1685            result.is_err(),
1686            "snake_case mime_type should fail — serde renames to mimeType"
1687        );
1688    }
1689
1690    #[test]
1691    fn test_audio_deser_rejects_snake_case_mime_type() {
1692        let json = r#"{"type": "audio", "data": "x", "mime_type": "audio/wav"}"#;
1693        let result = serde_json::from_str::<ContentBlock>(json);
1694        assert!(
1695            result.is_err(),
1696            "snake_case mime_type should fail — serde renames to mimeType"
1697        );
1698    }
1699
1700    // ---------------------------------------------------------------
1701    // 7. Type tag value invariants (snake_case)
1702    // ---------------------------------------------------------------
1703
1704    #[test]
1705    fn test_type_tag_values_are_snake_case() {
1706        let cases: Vec<(ContentBlock, &str)> = vec![
1707            (ContentBlock::text("t"), "text"),
1708            (ContentBlock::image("d", "image/png"), "image"),
1709            (ContentBlock::audio("d", "audio/wav"), "audio"),
1710            (
1711                ContentBlock::resource(ResourceContent::new("file:///x")),
1712                "resource",
1713            ),
1714            (
1715                ContentBlock::resource_link("file:///x", None, None),
1716                "resource_link",
1717            ),
1718        ];
1719
1720        for (block, expected_tag) in cases {
1721            let json = serde_json::to_value(&block).unwrap();
1722            assert_eq!(
1723                json["type"].as_str().unwrap(),
1724                expected_tag,
1725                "wrong type tag for {:?}",
1726                block
1727            );
1728        }
1729    }
1730
1731    #[test]
1732    fn test_camel_case_type_tag_fails() {
1733        // "resourceLink" (camelCase) should NOT work, only "resource_link" (snake_case)
1734        let json = r#"{"type": "resourceLink", "uri": "file:///x"}"#;
1735        let result = serde_json::from_str::<ContentBlock>(json);
1736        assert!(result.is_err(), "camelCase type tag should fail");
1737    }
1738
1739    // ---------------------------------------------------------------
1740    // 8. Boundary and regression cases
1741    // ---------------------------------------------------------------
1742
1743    #[test]
1744    fn test_deser_resource_with_blob_no_text() {
1745        let json = r#"{"type": "resource", "uri": "file:///bin", "blob": "AQID"}"#;
1746        let block: ContentBlock = serde_json::from_str(json).unwrap();
1747        if let ContentBlock::Resource(rc) = &block {
1748            assert_eq!(rc.uri, "file:///bin");
1749            assert_eq!(rc.blob, Some("AQID".into()));
1750            assert!(rc.text.is_none());
1751            assert!(rc.mime_type.is_none());
1752        } else {
1753            panic!("expected Resource variant");
1754        }
1755    }
1756
1757    #[test]
1758    fn test_deser_resource_uri_only() {
1759        let json = r#"{"type": "resource", "uri": "file:///bare"}"#;
1760        let block: ContentBlock = serde_json::from_str(json).unwrap();
1761        let expected = ContentBlock::resource(ResourceContent::new("file:///bare"));
1762        assert_eq!(block, expected);
1763    }
1764
1765    #[test]
1766    fn test_content_block_clone_eq() {
1767        let blocks = vec![
1768            ContentBlock::text("t"),
1769            ContentBlock::image("d", "image/png"),
1770            ContentBlock::audio("d", "audio/wav"),
1771            ContentBlock::resource(ResourceContent::new("file:///x")),
1772            ContentBlock::resource_link("file:///x", Some("n".into()), Some("m".into())),
1773        ];
1774        for block in &blocks {
1775            let cloned = block.clone();
1776            assert_eq!(*block, cloned);
1777        }
1778    }
1779
1780    #[test]
1781    fn test_large_text_block_roundtrip() {
1782        let large_text = "a".repeat(100_000);
1783        let block = ContentBlock::text(&large_text);
1784        let json = serde_json::to_string(&block).unwrap();
1785        let parsed: ContentBlock = serde_json::from_str(&json).unwrap();
1786        assert_eq!(block, parsed);
1787    }
1788
1789    #[test]
1790    fn test_large_base64_data_roundtrip() {
1791        let large_data = "A".repeat(1_000_000); // ~750KB decoded
1792        let block = ContentBlock::image(&large_data, "image/tiff");
1793        let json = serde_json::to_string(&block).unwrap();
1794        let parsed: ContentBlock = serde_json::from_str(&json).unwrap();
1795        assert_eq!(block, parsed);
1796    }
1797
1798    #[test]
1799    fn test_tool_call_result_many_blocks_roundtrip() {
1800        let blocks: Vec<ContentBlock> = (0..100)
1801            .map(|i| {
1802                if i % 3 == 0 {
1803                    ContentBlock::text(format!("block-{i}"))
1804                } else if i % 3 == 1 {
1805                    ContentBlock::image(format!("data-{i}"), "image/png")
1806                } else {
1807                    ContentBlock::audio(format!("audio-{i}"), "audio/ogg")
1808                }
1809            })
1810            .collect();
1811
1812        let original = ToolCallResult::success(blocks);
1813        let json = serde_json::to_string(&original).unwrap();
1814        let parsed: ToolCallResult = serde_json::from_str(&json).unwrap();
1815        assert_eq!(original, parsed);
1816        assert_eq!(parsed.content.len(), 100);
1817    }
1818}