Skip to main content

greentic_flow/
ir.rs

1use crate::error::{FlowError, FlowErrorLocation, Result};
2use serde_json::Value;
3
4/// The op-key / component token that marks a node as an MCP tool invocation.
5///
6/// In the authoring YGTC this is the literal operation key (`mcp:`), and after
7/// lowering it becomes the runtime node's `component` string. It is a valid
8/// `greentic_types::ComponentId` (only `[A-Za-z0-9._-]`), so it survives
9/// pack/runtime load — server, tool, arguments and output live in the node
10/// PAYLOAD, never encoded into the key.
11pub const MCP_COMPONENT: &str = "mcp";
12
13/// Classification of a node's component type.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum NodeKind {
16    /// A node backed by an adapter operation in the form `<namespace>.<adapter>.<operation>`.
17    Adapter {
18        namespace: String,
19        adapter: String,
20        operation: String,
21    },
22    /// A node that invokes an MCP tool.
23    ///
24    /// The op key / component token is the literal `mcp`. `server_id` references
25    /// an admin-configured tenant MCP server and `tool` is the raw MCP tool
26    /// name; both are read from the node PAYLOAD, not from the key string. This
27    /// classification is purely structural: the flow compiler never probes the
28    /// server or the tool.
29    Mcp { server_id: String, tool: String },
30    /// Any other node type that does not match the adapter or MCP convention.
31    Builtin(String),
32}
33
34/// Classify a node's op-key / component token into [`NodeKind`].
35///
36/// The MCP convention keys on the exact token `mcp`. Because `server` and
37/// `tool` now live in the payload (not the key), this classifier returns an
38/// [`NodeKind::Mcp`] with empty `server_id`/`tool`; callers populate those from
39/// the payload via [`mcp_server_and_tool`] during load/validate.
40///
41/// MCP takes precedence over the adapter convention. Everything else falls back
42/// to the adapter split or [`NodeKind::Builtin`].
43pub 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
66/// Extract `(server, tool)` from a validated MCP node payload.
67///
68/// Returns the raw, non-empty `server` and `tool` strings. Use this after
69/// [`validate_mcp_config`] has confirmed they are present and well-formed.
70pub 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
76/// Structurally validate the `config` payload of an MCP node.
77///
78/// Per the MCP node contract the payload carries:
79/// - `server`: a non-empty string admin server id (required),
80/// - `tool`: a non-empty string MCP tool name (required),
81/// - `arguments`: an object mapping flow state to MCP tool input (optional),
82/// - `output`: a string flow-state key to bind the tool result under (optional).
83///
84/// This check is offline-only: it never contacts the MCP server. Missing or
85/// empty `server`/`tool`, a non-object `arguments`, or a non-string `output`
86/// are all rejected with [`FlowError::McpConfig`].
87pub 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    // A non-object config (e.g. a scalar or array under the mcp key) cannot
98    // carry the documented server/tool/arguments/output shape.
99    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        // The old `mcp:<server>/<tool>` key form is just a Builtin now: it is
156        // not a valid ComponentId and carries no special meaning.
157        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}