Skip to main content

ironflow_core/providers/http/tools/
routing.rs

1//! MCP tool call routing by connector prefix.
2//!
3//! Resolves tool names in the `{connector_id}__{tool_name}` format to their
4//! connector and tool components. Bare names (without a prefix) are rejected
5//! explicitly rather than falling back to any default server.
6
7use std::collections::HashSet;
8use std::fmt;
9
10/// Separator between the connector ID and the tool name.
11pub const CONNECTOR_SEPARATOR: &str = "__";
12
13/// Errors that can occur during MCP tool routing.
14///
15/// # Examples
16///
17/// ```
18/// use ironflow_core::providers::http::tools::routing::RoutingError;
19///
20/// let err = RoutingError::ConnectorNotFound {
21///     connector: "unknown".to_string(),
22///     tool: "search".to_string(),
23/// };
24/// assert!(err.to_string().contains("unknown"));
25/// ```
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum RoutingError {
28    /// The tool name has no connector prefix (bare name).
29    AmbiguousToolName {
30        /// The bare tool name that was received.
31        name: String,
32    },
33    /// The connector prefix does not match any registered connector.
34    ConnectorNotFound {
35        /// The connector prefix that was not found.
36        connector: String,
37        /// The tool name after the prefix.
38        tool: String,
39    },
40}
41
42impl fmt::Display for RoutingError {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            Self::AmbiguousToolName { name } => {
46                write!(f, "tool '{name}' has no connector prefix")
47            }
48            Self::ConnectorNotFound { connector, tool } => {
49                write!(f, "connector '{connector}' not found for tool '{tool}'")
50            }
51        }
52    }
53}
54
55impl std::error::Error for RoutingError {}
56
57/// Resolved components of a prefixed tool name.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct RoutedTool {
60    /// The connector ID (prefix before `__`).
61    pub connector: String,
62    /// The tool name (everything after the first `__`).
63    pub tool: String,
64    /// The full registry key (`connector__tool`).
65    pub registry_key: String,
66}
67
68/// Parse and validate a tool call name against a set of known connectors.
69///
70/// Resolution follows two tiers:
71/// 1. **Prefixed name** (`connector__tool`): extracts the prefix, checks it
72///    exists in `known_connectors`.
73/// 2. **Bare name** (`tool`): rejected with [`RoutingError::AmbiguousToolName`].
74///
75/// Nested underscores are preserved: `prefix__deep__search` resolves to
76/// connector `prefix` and tool `deep__search`.
77///
78/// # Errors
79///
80/// - [`RoutingError::AmbiguousToolName`] if the name has no `__` separator.
81/// - [`RoutingError::ConnectorNotFound`] if the prefix is not in `known_connectors`.
82///
83/// # Examples
84///
85/// ```
86/// use std::collections::HashSet;
87/// use ironflow_core::providers::http::tools::routing::route_tool_call;
88///
89/// let connectors: HashSet<String> = ["grafana"].iter().map(|s| s.to_string()).collect();
90///
91/// let routed = route_tool_call("grafana__query", &connectors).unwrap();
92/// assert_eq!(routed.connector, "grafana");
93/// assert_eq!(routed.tool, "query");
94/// ```
95pub fn route_tool_call(
96    name: &str,
97    known_connectors: &HashSet<String>,
98) -> Result<RoutedTool, RoutingError> {
99    let Some((connector, tool)) = name.split_once(CONNECTOR_SEPARATOR) else {
100        return Err(RoutingError::AmbiguousToolName {
101            name: name.to_string(),
102        });
103    };
104
105    if !known_connectors.contains(connector) {
106        return Err(RoutingError::ConnectorNotFound {
107            connector: connector.to_string(),
108            tool: tool.to_string(),
109        });
110    }
111
112    Ok(RoutedTool {
113        connector: connector.to_string(),
114        tool: tool.to_string(),
115        registry_key: name.to_string(),
116    })
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn connectors(names: &[&str]) -> HashSet<String> {
124        names.iter().map(|s| s.to_string()).collect()
125    }
126
127    #[test]
128    fn route_prefixed_tool_resolves() {
129        let known = connectors(&["grafana", "slack"]);
130        let routed = route_tool_call("grafana__query", &known).unwrap();
131        assert_eq!(routed.connector, "grafana");
132        assert_eq!(routed.tool, "query");
133        assert_eq!(routed.registry_key, "grafana__query");
134    }
135
136    #[test]
137    fn route_bare_name_rejected() {
138        let known = connectors(&["grafana"]);
139        let err = route_tool_call("query", &known).unwrap_err();
140        assert_eq!(
141            err,
142            RoutingError::AmbiguousToolName {
143                name: "query".to_string()
144            }
145        );
146        assert_eq!(err.to_string(), "tool 'query' has no connector prefix");
147    }
148
149    #[test]
150    fn route_unknown_connector_rejected() {
151        let known = connectors(&["grafana"]);
152        let err = route_tool_call("unknown__search", &known).unwrap_err();
153        assert_eq!(
154            err,
155            RoutingError::ConnectorNotFound {
156                connector: "unknown".to_string(),
157                tool: "search".to_string(),
158            }
159        );
160    }
161
162    #[test]
163    fn route_nested_underscores_preserved() {
164        let known = connectors(&["prefix"]);
165        let routed = route_tool_call("prefix__deep__search", &known).unwrap();
166        assert_eq!(routed.connector, "prefix");
167        assert_eq!(routed.tool, "deep__search");
168    }
169
170    #[test]
171    fn route_empty_prefix_rejected() {
172        let known = connectors(&["grafana"]);
173        let err = route_tool_call("__search", &known).unwrap_err();
174        assert_eq!(
175            err,
176            RoutingError::ConnectorNotFound {
177                connector: "".to_string(),
178                tool: "search".to_string(),
179            }
180        );
181    }
182
183    #[test]
184    fn route_empty_tool_name_resolves_if_connector_known() {
185        let known = connectors(&["prefix"]);
186        let routed = route_tool_call("prefix__", &known).unwrap();
187        assert_eq!(routed.connector, "prefix");
188        assert_eq!(routed.tool, "");
189    }
190
191    #[test]
192    fn display_ambiguous_tool_name() {
193        let err = RoutingError::AmbiguousToolName {
194            name: "search".to_string(),
195        };
196        assert_eq!(err.to_string(), "tool 'search' has no connector prefix");
197    }
198
199    #[test]
200    fn display_connector_not_found() {
201        let err = RoutingError::ConnectorNotFound {
202            connector: "unknown".to_string(),
203            tool: "search".to_string(),
204        };
205        assert_eq!(
206            err.to_string(),
207            "connector 'unknown' not found for tool 'search'"
208        );
209    }
210}