Skip to main content

turbomcp_openapi/
mapping.rs

1//! Route mapping configuration for OpenAPI to MCP conversion.
2
3use regex::Regex;
4
5use crate::error::Result;
6
7/// MCP component type that an OpenAPI operation maps to.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub enum McpType {
10    /// Map to MCP tool (callable operation).
11    #[default]
12    Tool,
13    /// Map to MCP resource (readable content).
14    Resource,
15    /// Skip this operation (don't expose via MCP).
16    Skip,
17}
18
19/// A single route mapping rule.
20#[derive(Debug, Clone)]
21pub struct RouteRule {
22    /// HTTP methods this rule applies to (empty = all methods).
23    pub methods: Vec<String>,
24    /// Path pattern (regex) this rule applies to (None = all paths).
25    pub pattern: Option<Regex>,
26    /// What MCP type to map matching operations to.
27    pub mcp_type: McpType,
28    /// Priority (higher = checked first).
29    pub priority: i32,
30}
31
32impl RouteRule {
33    /// Create a new route rule.
34    pub fn new(mcp_type: McpType) -> Self {
35        Self {
36            methods: Vec::new(),
37            pattern: None,
38            mcp_type,
39            priority: 0,
40        }
41    }
42
43    /// Set HTTP methods for this rule.
44    #[must_use]
45    pub fn methods<I, S>(mut self, methods: I) -> Self
46    where
47        I: IntoIterator<Item = S>,
48        S: Into<String>,
49    {
50        self.methods = methods.into_iter().map(Into::into).collect();
51        self
52    }
53
54    /// Set path pattern for this rule.
55    pub fn pattern(mut self, pattern: &str) -> Result<Self> {
56        self.pattern = Some(Regex::new(pattern)?);
57        Ok(self)
58    }
59
60    /// Set priority for this rule.
61    #[must_use]
62    pub fn priority(mut self, priority: i32) -> Self {
63        self.priority = priority;
64        self
65    }
66
67    /// Check if this rule matches a given method and path.
68    pub fn matches(&self, method: &str, path: &str) -> bool {
69        // Check method
70        if !self.methods.is_empty() && !self.methods.iter().any(|m| m.eq_ignore_ascii_case(method))
71        {
72            return false;
73        }
74
75        // Check path pattern
76        if let Some(ref pattern) = self.pattern
77            && !pattern.is_match(path)
78        {
79            return false;
80        }
81
82        true
83    }
84}
85
86/// Configuration for mapping OpenAPI operations to MCP components.
87#[derive(Debug, Clone, Default)]
88pub struct RouteMapping {
89    rules: Vec<RouteRule>,
90}
91
92impl RouteMapping {
93    /// Create a new empty route mapping.
94    pub fn new() -> Self {
95        Self::default()
96    }
97
98    /// Create default mapping rules:
99    /// - GET → Resource
100    /// - POST, PUT, PATCH, DELETE → Tool
101    pub fn default_rules() -> Self {
102        Self::new()
103            .map_methods(["GET"], McpType::Resource)
104            .map_methods(["POST", "PUT", "PATCH", "DELETE"], McpType::Tool)
105    }
106
107    /// Add a rule to map specific HTTP methods to an MCP type.
108    #[must_use]
109    pub fn map_methods<I, S>(mut self, methods: I, mcp_type: McpType) -> Self
110    where
111        I: IntoIterator<Item = S>,
112        S: Into<String>,
113    {
114        self.rules.push(RouteRule::new(mcp_type).methods(methods));
115        self
116    }
117
118    /// Add a rule to map a specific HTTP method to an MCP type.
119    #[must_use]
120    pub fn map_method(self, method: &str, mcp_type: McpType) -> Self {
121        self.map_methods([method], mcp_type)
122    }
123
124    /// Add a rule to map paths matching a pattern to an MCP type.
125    pub fn map_pattern(mut self, pattern: &str, mcp_type: McpType) -> Result<Self> {
126        self.rules.push(RouteRule::new(mcp_type).pattern(pattern)?);
127        Ok(self)
128    }
129
130    /// Add a rule to map specific methods and pattern to an MCP type.
131    pub fn map_rule<I, S>(
132        mut self,
133        methods: I,
134        pattern: &str,
135        mcp_type: McpType,
136        priority: i32,
137    ) -> Result<Self>
138    where
139        I: IntoIterator<Item = S>,
140        S: Into<String>,
141    {
142        self.rules.push(
143            RouteRule::new(mcp_type)
144                .methods(methods)
145                .pattern(pattern)?
146                .priority(priority),
147        );
148        Ok(self)
149    }
150
151    /// Add a custom route rule.
152    #[must_use]
153    pub fn add_rule(mut self, rule: RouteRule) -> Self {
154        self.rules.push(rule);
155        self
156    }
157
158    /// Skip operations matching a pattern.
159    pub fn skip_pattern(self, pattern: &str) -> Result<Self> {
160        self.map_pattern(pattern, McpType::Skip)
161    }
162
163    /// Determine the MCP type for a given HTTP method and path.
164    ///
165    /// Rules are checked in order of priority (highest first), then insertion order.
166    /// Returns `McpType::Tool` as default if no rule matches.
167    pub fn get_mcp_type(&self, method: &str, path: &str) -> McpType {
168        // Sort rules by priority (highest first)
169        let mut sorted_rules: Vec<_> = self.rules.iter().collect();
170        sorted_rules.sort_by(|a, b| b.priority.cmp(&a.priority));
171
172        for rule in sorted_rules {
173            if rule.matches(method, path) {
174                return rule.mcp_type;
175            }
176        }
177
178        // Default: use method-based heuristic
179        match method.to_uppercase().as_str() {
180            "GET" => McpType::Resource,
181            _ => McpType::Tool,
182        }
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn test_default_rules() {
192        let mapping = RouteMapping::default_rules();
193
194        assert_eq!(mapping.get_mcp_type("GET", "/users"), McpType::Resource);
195        assert_eq!(mapping.get_mcp_type("POST", "/users"), McpType::Tool);
196        assert_eq!(mapping.get_mcp_type("PUT", "/users/1"), McpType::Tool);
197        assert_eq!(mapping.get_mcp_type("DELETE", "/users/1"), McpType::Tool);
198    }
199
200    #[test]
201    fn test_pattern_matching() {
202        let mapping = RouteMapping::new()
203            .map_pattern(r"/admin/.*", McpType::Skip)
204            .unwrap()
205            .map_methods(["GET"], McpType::Resource);
206
207        assert_eq!(mapping.get_mcp_type("GET", "/admin/users"), McpType::Skip);
208        assert_eq!(mapping.get_mcp_type("GET", "/users"), McpType::Resource);
209    }
210
211    #[test]
212    fn test_priority() {
213        let mapping = RouteMapping::new()
214            .add_rule(
215                RouteRule::new(McpType::Resource)
216                    .methods(["GET"])
217                    .priority(0),
218            )
219            .add_rule(
220                RouteRule::new(McpType::Tool)
221                    .pattern(r"/api/.*")
222                    .unwrap()
223                    .priority(10),
224            );
225
226        // Higher priority rule (pattern) should win
227        assert_eq!(mapping.get_mcp_type("GET", "/api/users"), McpType::Tool);
228        // Lower priority rule should apply when pattern doesn't match
229        assert_eq!(mapping.get_mcp_type("GET", "/users"), McpType::Resource);
230    }
231
232    #[test]
233    fn test_route_rule_matches() {
234        let rule = RouteRule::new(McpType::Tool)
235            .methods(["POST", "PUT"])
236            .pattern(r"/users/\d+")
237            .unwrap();
238
239        assert!(rule.matches("POST", "/users/123"));
240        assert!(rule.matches("PUT", "/users/456"));
241        assert!(!rule.matches("GET", "/users/123")); // Wrong method
242        assert!(!rule.matches("POST", "/users/abc")); // Doesn't match pattern
243    }
244}