1use crate::error::{FlowError, FlowErrorLocation, Result};
2use serde_json::Value;
3
4pub const MCP_COMPONENT: &str = "mcp";
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum NodeKind {
16 Adapter {
18 namespace: String,
19 adapter: String,
20 operation: String,
21 },
22 Mcp { server_id: String, tool: String },
30 Builtin(String),
32}
33
34pub fn classify_node_type(node_type: &str) -> NodeKind {
44 if node_type == MCP_COMPONENT {
45 return NodeKind::Mcp {
46 server_id: String::new(),
47 tool: String::new(),
48 };
49 }
50
51 let parts = node_type.split('.').collect::<Vec<_>>();
52 if parts.len() >= 3 {
53 let namespace = parts[0].to_string();
54 let adapter = parts[1].to_string();
55 let operation = parts[2..].join(".");
56 NodeKind::Adapter {
57 namespace,
58 adapter,
59 operation,
60 }
61 } else {
62 NodeKind::Builtin(node_type.to_string())
63 }
64}
65
66pub fn mcp_server_and_tool(config: &Value) -> Option<(String, String)> {
71 let server = config.get("server").and_then(Value::as_str)?;
72 let tool = config.get("tool").and_then(Value::as_str)?;
73 Some((server.to_string(), tool.to_string()))
74}
75
76pub fn validate_mcp_config(node_id: &str, config: &Value) -> Result<()> {
88 let location = || FlowErrorLocation::at_path(format!("nodes.{node_id}"));
89 let reject = |message: &str| {
90 Err(FlowError::McpConfig {
91 node_id: node_id.to_string(),
92 message: message.to_string(),
93 location: location(),
94 })
95 };
96
97 let Some(obj) = config.as_object() else {
100 return reject("MCP node config must be an object");
101 };
102
103 match obj.get("server").and_then(Value::as_str) {
104 Some(server) if !server.is_empty() => {}
105 _ => return reject("MCP node config 'server' must be a non-empty string"),
106 }
107
108 match obj.get("tool").and_then(Value::as_str) {
109 Some(tool) if !tool.is_empty() => {}
110 _ => return reject("MCP node config 'tool' must be a non-empty string"),
111 }
112
113 if let Some(arguments) = obj.get("arguments")
114 && !arguments.is_object()
115 {
116 return reject("MCP node config 'arguments' must be an object");
117 }
118
119 if let Some(output) = obj.get("output")
120 && !output.is_string()
121 {
122 return reject("MCP node config 'output' must be a string");
123 }
124
125 Ok(())
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use serde_json::json;
132
133 #[test]
134 fn classifies_mcp_node() {
135 assert_eq!(
136 classify_node_type("mcp"),
137 NodeKind::Mcp {
138 server_id: String::new(),
139 tool: String::new(),
140 }
141 );
142 }
143
144 #[test]
145 fn mcp_server_and_tool_read_from_payload() {
146 let config = json!({ "server": "github", "tool": "get_issue" });
147 assert_eq!(
148 mcp_server_and_tool(&config),
149 Some(("github".to_string(), "get_issue".to_string()))
150 );
151 }
152
153 #[test]
154 fn legacy_mcp_prefix_is_no_longer_special() {
155 assert_eq!(
158 classify_node_type("mcp:github/get_issue"),
159 NodeKind::Builtin("mcp:github/get_issue".to_string())
160 );
161 }
162
163 #[test]
164 fn classifies_adapter_and_builtin_unchanged() {
165 assert_eq!(
166 classify_node_type("weather.api.forecast"),
167 NodeKind::Adapter {
168 namespace: "weather".to_string(),
169 adapter: "api".to_string(),
170 operation: "forecast".to_string(),
171 }
172 );
173 assert_eq!(
174 classify_node_type("questions"),
175 NodeKind::Builtin("questions".to_string())
176 );
177 }
178
179 #[test]
180 fn validates_mcp_config_happy_path() {
181 let config = json!({
182 "server": "github",
183 "tool": "get_issue",
184 "arguments": { "owner": "{{ flow.owner }}", "number": "{{ input.issue_number }}" },
185 "output": "issue"
186 });
187 validate_mcp_config("lookup_issue", &config).expect("valid config");
188 }
189
190 #[test]
191 fn validates_mcp_config_allows_missing_optional_keys() {
192 let config = json!({ "server": "github", "tool": "get_issue" });
193 validate_mcp_config("lookup_issue", &config).expect("server+tool only is valid");
194 }
195
196 #[test]
197 fn rejects_missing_server() {
198 let config = json!({ "tool": "get_issue" });
199 let err = validate_mcp_config("lookup_issue", &config).unwrap_err();
200 match err {
201 FlowError::McpConfig { message, .. } => assert!(message.contains("server")),
202 other => panic!("expected McpConfig, got {other:?}"),
203 }
204 }
205
206 #[test]
207 fn rejects_empty_server() {
208 let config = json!({ "server": "", "tool": "get_issue" });
209 let err = validate_mcp_config("lookup_issue", &config).unwrap_err();
210 assert!(matches!(err, FlowError::McpConfig { .. }));
211 }
212
213 #[test]
214 fn rejects_missing_tool() {
215 let config = json!({ "server": "github" });
216 let err = validate_mcp_config("lookup_issue", &config).unwrap_err();
217 match err {
218 FlowError::McpConfig { message, .. } => assert!(message.contains("tool")),
219 other => panic!("expected McpConfig, got {other:?}"),
220 }
221 }
222
223 #[test]
224 fn rejects_non_object_arguments() {
225 let config =
226 json!({ "server": "github", "tool": "get_issue", "arguments": "not-an-object" });
227 let err = validate_mcp_config("lookup_issue", &config).unwrap_err();
228 assert!(matches!(err, FlowError::McpConfig { .. }));
229 }
230
231 #[test]
232 fn rejects_non_string_output() {
233 let config = json!({ "server": "github", "tool": "get_issue", "output": 42 });
234 let err = validate_mcp_config("lookup_issue", &config).unwrap_err();
235 assert!(matches!(err, FlowError::McpConfig { .. }));
236 }
237
238 #[test]
239 fn rejects_non_object_config() {
240 let err = validate_mcp_config("lookup_issue", &json!("scalar")).unwrap_err();
241 assert!(matches!(err, FlowError::McpConfig { .. }));
242 }
243}