Skip to main content

nika_engine/ast/
invoke.rs

1//! Invoke Action - MCP tool calls and resource reads
2//!
3//! Defines the invoke verb parameters for MCP integration:
4//! - Tool calls: `mcp` + `tool` + optional `params`
5//! - Resource reads: `mcp` + `resource`
6//!
7//! Tool and resource are mutually exclusive - exactly one must be specified.
8
9use serde::Deserialize;
10
11use crate::error::NikaError;
12
13/// Invoke action - MCP integration
14///
15/// Used to call MCP server tools or read MCP resources.
16/// Exactly one of `tool` or `resource` must be specified.
17///
18/// # Examples
19///
20/// Tool call:
21/// ```yaml
22/// invoke:
23///   mcp: novanet
24///   tool: novanet_context
25///   params:
26///     entity: qr-code
27///     locale: fr-FR
28/// ```
29///
30/// Resource read:
31/// ```yaml
32/// invoke:
33///   mcp: novanet
34///   resource: entity://qr-code/fr-FR
35/// ```
36#[derive(Debug, Clone, Deserialize)]
37pub struct InvokeParams {
38    /// MCP server name (must match a key in workflow's `mcp` config)
39    ///
40    /// Also accepts `server` as an alias.
41    /// Optional for builtin tools (nika:* prefix) which don't need MCP.
42    #[serde(alias = "server", default)]
43    pub mcp: Option<String>,
44
45    /// Tool name to call (mutually exclusive with `resource`)
46    #[serde(default)]
47    pub tool: Option<String>,
48
49    /// Parameters to pass to the tool
50    #[serde(default)]
51    pub params: Option<serde_json::Value>,
52
53    /// Resource URI to read (mutually exclusive with `tool`)
54    #[serde(default)]
55    pub resource: Option<String>,
56
57    /// Timeout in seconds for tool execution
58    ///
59    /// Overrides the global `INVOKE_TASK_DEADLINE` (300s) for this task.
60    /// If not set, falls back to the global default.
61    #[serde(default)]
62    pub timeout: Option<u64>,
63}
64
65impl InvokeParams {
66    /// Returns true if this invoke targets a builtin tool (nika:* prefix).
67    #[inline]
68    pub fn is_builtin_tool(&self) -> bool {
69        self.tool.as_ref().is_some_and(|t| t.starts_with("nika:"))
70    }
71}
72
73impl InvokeParams {
74    /// Validate invoke parameters.
75    ///
76    /// # Errors
77    ///
78    /// Returns an error string if:
79    /// - `mcp` server name is empty (unless builtin tool with nika:* prefix)
80    /// - Both `tool` and `resource` are `Some` (mutually exclusive)
81    /// - Both `tool` and `resource` are `None` (one is required)
82    /// - `tool` is Some but empty string
83    /// - `resource` is Some but empty string
84    ///
85    pub fn validate(&self) -> Result<(), NikaError> {
86        // Builtin tools don't require mcp
87        // Validate MCP server name is not empty (for non-builtin tools)
88        if !self.is_builtin_tool() {
89            match &self.mcp {
90                None => {
91                    return Err(NikaError::ValidationError {
92                        reason: "'mcp' server name is required for non-builtin tools".into(),
93                    })
94                }
95                Some(mcp) if mcp.trim().is_empty() => {
96                    return Err(NikaError::ValidationError {
97                        reason: "'mcp' server name cannot be empty".into(),
98                    });
99                }
100                _ => {}
101            }
102        }
103
104        match (&self.tool, &self.resource) {
105            (Some(tool), Some(_)) if !tool.trim().is_empty() => Err(NikaError::ValidationError {
106                reason: "'tool' and 'resource' are mutually exclusive - specify only one".into(),
107            }),
108            (Some(tool), None) if tool.trim().is_empty() => Err(NikaError::ValidationError {
109                reason: "'tool' name cannot be empty".into(),
110            }),
111            (None, Some(resource)) if resource.trim().is_empty() => {
112                Err(NikaError::ValidationError {
113                    reason: "'resource' URI cannot be empty".into(),
114                })
115            }
116            (Some(_), Some(_)) => Err(NikaError::ValidationError {
117                reason: "'tool' and 'resource' are mutually exclusive - specify only one".into(),
118            }),
119            (None, None) => Err(NikaError::ValidationError {
120                reason: "either 'tool' or 'resource' must be specified".into(),
121            }),
122            _ => Ok(()),
123        }
124    }
125
126    /// Returns `true` if this is a tool call (has `tool` set).
127    #[inline]
128    pub fn is_tool_call(&self) -> bool {
129        self.tool.is_some()
130    }
131
132    /// Returns `true` if this is a resource read (has `resource` set).
133    #[inline]
134    pub fn is_resource_read(&self) -> bool {
135        self.resource.is_some()
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::serde_yaml;
143    use serde_json::json;
144
145    #[test]
146    fn parse_tool_call() {
147        let yaml = r#"
148mcp: novanet
149tool: novanet_context
150params:
151  entity: qr-code
152"#;
153        let params: InvokeParams = serde_yaml::from_str(yaml).unwrap();
154        assert_eq!(params.mcp, Some("novanet".to_string()));
155        assert_eq!(params.tool, Some("novanet_context".to_string()));
156        assert_eq!(params.params, Some(json!({"entity": "qr-code"})));
157        assert!(params.resource.is_none());
158    }
159
160    #[test]
161    fn parse_resource_read() {
162        let yaml = r#"
163mcp: novanet
164resource: entity://qr-code/fr-FR
165"#;
166        let params: InvokeParams = serde_yaml::from_str(yaml).unwrap();
167        assert_eq!(params.mcp, Some("novanet".to_string()));
168        assert!(params.tool.is_none());
169        assert_eq!(params.resource, Some("entity://qr-code/fr-FR".to_string()));
170    }
171
172    #[test]
173    fn validate_ok_tool() {
174        let params = InvokeParams {
175            mcp: Some("test".to_string()),
176            tool: Some("test_tool".to_string()),
177            params: None,
178            resource: None,
179            timeout: None,
180        };
181        assert!(params.validate().is_ok());
182        assert!(params.is_tool_call());
183        assert!(!params.is_resource_read());
184    }
185
186    #[test]
187    fn validate_ok_resource() {
188        let params = InvokeParams {
189            mcp: Some("test".to_string()),
190            tool: None,
191            params: None,
192            resource: Some("test://resource".to_string()),
193            timeout: None,
194        };
195        assert!(params.validate().is_ok());
196        assert!(!params.is_tool_call());
197        assert!(params.is_resource_read());
198    }
199
200    #[test]
201    fn validate_err_both() {
202        let params = InvokeParams {
203            mcp: Some("test".to_string()),
204            tool: Some("test_tool".to_string()),
205            params: None,
206            resource: Some("test://resource".to_string()),
207            timeout: None,
208        };
209        let result = params.validate();
210        assert!(result.is_err());
211        assert!(result
212            .unwrap_err()
213            .to_string()
214            .contains("mutually exclusive"));
215    }
216
217    #[test]
218    fn validate_err_neither() {
219        let params = InvokeParams {
220            mcp: Some("test".to_string()),
221            tool: None,
222            params: None,
223            resource: None,
224            timeout: None,
225        };
226        let result = params.validate();
227        assert!(result.is_err());
228        assert!(result
229            .unwrap_err()
230            .to_string()
231            .contains("must be specified"));
232    }
233
234    // =========================================================================
235    // Empty String Validation Tests
236    // =========================================================================
237
238    #[test]
239    fn validate_err_empty_mcp() {
240        let params = InvokeParams {
241            mcp: Some("".to_string()),
242            tool: Some("test_tool".to_string()),
243            params: None,
244            resource: None,
245            timeout: None,
246        };
247        let result = params.validate();
248        assert!(result.is_err());
249        assert!(result.unwrap_err().to_string().contains("mcp"));
250    }
251
252    #[test]
253    fn validate_err_whitespace_mcp() {
254        let params = InvokeParams {
255            mcp: Some("   ".to_string()),
256            tool: Some("test_tool".to_string()),
257            params: None,
258            resource: None,
259            timeout: None,
260        };
261        let result = params.validate();
262        assert!(result.is_err());
263        assert!(result.unwrap_err().to_string().contains("mcp"));
264    }
265
266    #[test]
267    fn validate_err_empty_tool() {
268        let params = InvokeParams {
269            mcp: Some("test".to_string()),
270            tool: Some("".to_string()),
271            params: None,
272            resource: None,
273            timeout: None,
274        };
275        let result = params.validate();
276        assert!(result.is_err());
277        assert!(result.unwrap_err().to_string().contains("tool"));
278    }
279
280    #[test]
281    fn validate_err_whitespace_tool() {
282        let params = InvokeParams {
283            mcp: Some("test".to_string()),
284            tool: Some("  \t  ".to_string()),
285            params: None,
286            resource: None,
287            timeout: None,
288        };
289        let result = params.validate();
290        assert!(result.is_err());
291        assert!(result.unwrap_err().to_string().contains("tool"));
292    }
293
294    #[test]
295    fn validate_err_empty_resource() {
296        let params = InvokeParams {
297            mcp: Some("test".to_string()),
298            tool: None,
299            params: None,
300            resource: Some("".to_string()),
301            timeout: None,
302        };
303        let result = params.validate();
304        assert!(result.is_err());
305        assert!(result.unwrap_err().to_string().contains("resource"));
306    }
307
308    #[test]
309    fn validate_err_whitespace_resource() {
310        let params = InvokeParams {
311            mcp: Some("test".to_string()),
312            tool: None,
313            params: None,
314            resource: Some("   ".to_string()),
315            timeout: None,
316        };
317        let result = params.validate();
318        assert!(result.is_err());
319        assert!(result.unwrap_err().to_string().contains("resource"));
320    }
321
322    // =========================================================================
323    // Builtin Tools Tests (nika:* prefix)
324    // =========================================================================
325
326    #[test]
327    fn validate_ok_builtin_tool_without_mcp() {
328        // Builtin tools (nika:* prefix) don't require mcp
329        let params = InvokeParams {
330            mcp: None,
331            tool: Some("nika:sleep".to_string()),
332            params: Some(json!({"duration": "1s"})),
333            resource: None,
334            timeout: None,
335        };
336        assert!(params.validate().is_ok());
337        assert!(params.is_builtin_tool());
338    }
339
340    #[test]
341    fn validate_ok_builtin_tool_with_mcp() {
342        // Builtin tools can optionally specify mcp (ignored)
343        let params = InvokeParams {
344            mcp: Some("ignored".to_string()),
345            tool: Some("nika:log".to_string()),
346            params: Some(json!({"level": "info", "message": "test"})),
347            resource: None,
348            timeout: None,
349        };
350        assert!(params.validate().is_ok());
351        assert!(params.is_builtin_tool());
352    }
353
354    #[test]
355    fn validate_err_non_builtin_without_mcp() {
356        // Non-builtin tools (no nika:* prefix) require mcp
357        let params = InvokeParams {
358            mcp: None,
359            tool: Some("novanet_context".to_string()),
360            params: None,
361            resource: None,
362            timeout: None,
363        };
364        let result = params.validate();
365        assert!(result.is_err());
366        assert!(result.unwrap_err().to_string().contains("mcp"));
367    }
368
369    #[test]
370    fn is_builtin_tool_detects_nika_prefix() {
371        let params = InvokeParams {
372            mcp: None,
373            tool: Some("nika:sleep".to_string()),
374            params: None,
375            resource: None,
376            timeout: None,
377        };
378        assert!(params.is_builtin_tool());
379    }
380
381    #[test]
382    fn is_builtin_tool_rejects_non_nika() {
383        let params = InvokeParams {
384            mcp: Some("test".to_string()),
385            tool: Some("novanet_context".to_string()),
386            params: None,
387            resource: None,
388            timeout: None,
389        };
390        assert!(!params.is_builtin_tool());
391    }
392
393    #[test]
394    fn parse_builtin_tool_without_mcp() {
395        // YAML without mcp field should parse successfully for builtin tools
396        let yaml = r#"
397tool: nika:sleep
398params:
399  duration: "1s"
400"#;
401        let params: InvokeParams = serde_yaml::from_str(yaml).unwrap();
402        assert!(params.mcp.is_none());
403        assert_eq!(params.tool, Some("nika:sleep".to_string()));
404        assert!(params.validate().is_ok());
405    }
406
407    // =========================================================================
408    // Timeout Field Tests
409    // =========================================================================
410
411    #[test]
412    fn parse_tool_call_with_timeout() {
413        let yaml = r#"
414mcp: novanet
415tool: novanet_context
416timeout: 60
417params:
418  entity: qr-code
419"#;
420        let params: InvokeParams = serde_yaml::from_str(yaml).unwrap();
421        assert_eq!(params.timeout, Some(60));
422        assert!(params.validate().is_ok());
423    }
424
425    #[test]
426    fn parse_tool_call_without_timeout() {
427        let yaml = r#"
428mcp: novanet
429tool: novanet_context
430"#;
431        let params: InvokeParams = serde_yaml::from_str(yaml).unwrap();
432        assert_eq!(params.timeout, None);
433    }
434}