Skip to main content

mcp_proxy/
alias.rs

1//! Tool aliasing middleware for the proxy.
2//!
3//! Rewrites tool names in list responses and call requests based on
4//! per-backend alias configuration. This lets operators expose backend tools
5//! under different names without modifying the backends themselves.
6//!
7//! # How it works
8//!
9//! Aliasing maintains a bidirectional mapping between original and aliased
10//! names (stored in [`AliasMap`]):
11//!
12//! - **Forward mapping** (original -> alias) -- applied to `ListTools`,
13//!   `ListResources`, and `ListPrompts` responses so clients see the
14//!   aliased names.
15//! - **Reverse mapping** (alias -> original) -- applied to `CallTool`,
16//!   `ReadResource`, and `GetPrompt` requests so the backend receives
17//!   the original name it expects.
18//!
19//! Names that have no alias configured pass through unchanged in both
20//! directions.
21//!
22//! # Configuration
23//!
24//! Aliases are configured per-backend in TOML. The `from` field is the
25//! backend-local tool name (without the namespace prefix); the `to` field
26//! is the new name to expose:
27//!
28//! ```toml
29//! [[backends]]
30//! name = "files"
31//! transport = "stdio"
32//! command = "file-server"
33//!
34//! [[backends.aliases]]
35//! from = "read_file"
36//! to = "read"
37//!
38//! [[backends.aliases]]
39//! from = "write_file"
40//! to = "write"
41//! ```
42//!
43//! With this config, `files/read_file` appears to clients as `files/read`,
44//! and calling `files/read` is transparently forwarded to the backend as
45//! `files/read_file`.
46//!
47//! # Middleware stack position
48//!
49//! Aliasing runs after capability filtering and search-mode filtering, so
50//! filters operate on original names and aliases are applied last. The
51//! ordering in `proxy.rs`:
52//!
53//! 1. Request validation ([`crate::validation`])
54//! 2. Capability filtering ([`crate::filter`])
55//! 3. Search-mode filtering ([`crate::filter`])
56//! 4. **Tool aliasing** (this module)
57//! 5. Composite tools ([`crate::composite`])
58
59use std::collections::HashMap;
60use std::convert::Infallible;
61use std::future::Future;
62use std::pin::Pin;
63use std::sync::Arc;
64use std::task::{Context, Poll};
65
66use tower::{Layer, Service};
67use tower_mcp::router::{RouterRequest, RouterResponse};
68use tower_mcp_types::protocol::{McpRequest, McpResponse};
69
70/// Tower layer that produces an [`AliasService`].
71///
72/// # Example
73///
74/// ```rust
75/// use tower::ServiceBuilder;
76/// # let proxy = tower_mcp::McpRouter::new();
77/// use mcp_proxy::alias::{AliasLayer, AliasMap};
78///
79/// let aliases = AliasMap::new(vec![
80///     ("math/".into(), "add".into(), "sum".into()),
81/// ]).unwrap();
82///
83/// let service = ServiceBuilder::new()
84///     .layer(AliasLayer::new(aliases))
85///     .service(proxy);
86/// ```
87#[derive(Clone)]
88pub struct AliasLayer {
89    aliases: AliasMap,
90}
91
92impl AliasLayer {
93    /// Create a new alias layer with the given alias map.
94    pub fn new(aliases: AliasMap) -> Self {
95        Self { aliases }
96    }
97}
98
99impl<S> Layer<S> for AliasLayer {
100    type Service = AliasService<S>;
101
102    fn layer(&self, inner: S) -> Self::Service {
103        AliasService::new(inner, self.aliases.clone())
104    }
105}
106
107/// Resolved alias mappings for all backends.
108#[derive(Clone)]
109pub struct AliasMap {
110    /// Maps "namespace/original" -> "namespace/alias" (for list responses)
111    pub forward: HashMap<String, String>,
112    /// Maps "namespace/alias" -> "namespace/original" (for call requests)
113    reverse: HashMap<String, String>,
114}
115
116impl AliasMap {
117    /// Build an alias map from `(namespace, from, to)` triples. Returns `None` if empty.
118    pub fn new(mappings: Vec<(String, String, String)>) -> Option<Self> {
119        if mappings.is_empty() {
120            return None;
121        }
122        let mut forward = HashMap::new();
123        let mut reverse = HashMap::new();
124        for (namespace, from, to) in mappings {
125            let original = format!("{}{}", namespace, from);
126            let aliased = format!("{}{}", namespace, to);
127            forward.insert(original.clone(), aliased.clone());
128            reverse.insert(aliased, original);
129        }
130        Some(Self { forward, reverse })
131    }
132}
133
134/// Tower service that rewrites tool names based on alias configuration.
135#[derive(Clone)]
136pub struct AliasService<S> {
137    inner: S,
138    aliases: Arc<AliasMap>,
139}
140
141impl<S> AliasService<S> {
142    /// Create a new alias service wrapping `inner` with the given alias map.
143    pub fn new(inner: S, aliases: AliasMap) -> Self {
144        Self {
145            inner,
146            aliases: Arc::new(aliases),
147        }
148    }
149}
150
151impl<S> Service<RouterRequest> for AliasService<S>
152where
153    S: Service<RouterRequest, Response = RouterResponse, Error = Infallible>
154        + Clone
155        + Send
156        + 'static,
157    S::Future: Send,
158{
159    type Response = RouterResponse;
160    type Error = Infallible;
161    type Future = Pin<Box<dyn Future<Output = Result<RouterResponse, Infallible>> + Send>>;
162
163    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
164        self.inner.poll_ready(cx)
165    }
166
167    fn call(&mut self, mut req: RouterRequest) -> Self::Future {
168        let aliases = Arc::clone(&self.aliases);
169
170        // Reverse-map aliased names back to originals in requests
171        match &mut req.inner {
172            McpRequest::CallTool(params) => {
173                if let Some(original) = aliases.reverse.get(&params.name) {
174                    params.name = original.clone();
175                }
176            }
177            McpRequest::ReadResource(params) => {
178                if let Some(original) = aliases.reverse.get(&params.uri) {
179                    params.uri = original.clone();
180                }
181            }
182            McpRequest::GetPrompt(params) => {
183                if let Some(original) = aliases.reverse.get(&params.name) {
184                    params.name = original.clone();
185                }
186            }
187            _ => {}
188        }
189
190        let fut = self.inner.call(req);
191
192        Box::pin(async move {
193            let mut result = fut.await;
194
195            // Forward-map original names to aliases in responses
196            let Ok(ref mut resp) = result;
197            if let Ok(mcp_resp) = &mut resp.inner {
198                match mcp_resp {
199                    McpResponse::ListTools(r) => {
200                        for tool in &mut r.tools {
201                            if let Some(aliased) = aliases.forward.get(&tool.name) {
202                                tool.name = aliased.clone();
203                            }
204                        }
205                    }
206                    McpResponse::ListResources(r) => {
207                        for res in &mut r.resources {
208                            if let Some(aliased) = aliases.forward.get(&res.uri) {
209                                res.uri = aliased.clone();
210                            }
211                        }
212                    }
213                    McpResponse::ListPrompts(r) => {
214                        for prompt in &mut r.prompts {
215                            if let Some(aliased) = aliases.forward.get(&prompt.name) {
216                                prompt.name = aliased.clone();
217                            }
218                        }
219                    }
220                    _ => {}
221                }
222            }
223
224            result
225        })
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use tower_mcp::protocol::{McpRequest, McpResponse};
232
233    use super::{AliasMap, AliasService};
234    use crate::test_util::{MockService, call_service};
235
236    fn test_aliases() -> AliasMap {
237        AliasMap::new(vec![
238            ("files/".into(), "read_file".into(), "read".into()),
239            ("files/".into(), "write_file".into(), "write".into()),
240        ])
241        .unwrap()
242    }
243
244    #[test]
245    fn test_alias_map_empty_returns_none() {
246        assert!(AliasMap::new(vec![]).is_none());
247    }
248
249    #[test]
250    fn test_alias_map_forward_and_reverse() {
251        let aliases = test_aliases();
252        assert_eq!(
253            aliases.forward.get("files/read_file").unwrap(),
254            "files/read"
255        );
256        assert_eq!(aliases.forward.len(), 2);
257    }
258
259    #[tokio::test]
260    async fn test_alias_rewrites_list_tools() {
261        let mock = MockService::with_tools(&["files/read_file", "files/write_file", "db/query"]);
262        let mut svc = AliasService::new(mock, test_aliases());
263
264        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
265        match resp.inner.unwrap() {
266            McpResponse::ListTools(result) => {
267                let names: Vec<&str> = result.tools.iter().map(|t| t.name.as_str()).collect();
268                assert!(names.contains(&"files/read"));
269                assert!(names.contains(&"files/write"));
270                assert!(names.contains(&"db/query")); // unchanged
271            }
272            other => panic!("expected ListTools, got: {:?}", other),
273        }
274    }
275
276    #[tokio::test]
277    async fn test_alias_reverse_maps_call_tool() {
278        let mock = MockService::with_tools(&["files/read_file"]);
279        let mut svc = AliasService::new(mock, test_aliases());
280
281        let resp = call_service(
282            &mut svc,
283            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
284                name: "files/read".to_string(),
285                arguments: serde_json::json!({}),
286                input_responses: None,
287                request_state: None,
288                meta: None,
289                task: None,
290            }),
291        )
292        .await;
293
294        match resp.inner.unwrap() {
295            McpResponse::CallTool(result) => {
296                assert_eq!(result.all_text(), "called: files/read_file");
297            }
298            other => panic!("expected CallTool, got: {:?}", other),
299        }
300    }
301
302    #[tokio::test]
303    async fn test_alias_passthrough_non_aliased() {
304        let mock = MockService::with_tools(&["db/query"]);
305        let mut svc = AliasService::new(mock, test_aliases());
306
307        let resp = call_service(
308            &mut svc,
309            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
310                name: "db/query".to_string(),
311                arguments: serde_json::json!({}),
312                input_responses: None,
313                request_state: None,
314                meta: None,
315                task: None,
316            }),
317        )
318        .await;
319
320        match resp.inner.unwrap() {
321            McpResponse::CallTool(result) => {
322                assert_eq!(result.all_text(), "called: db/query");
323            }
324            other => panic!("expected CallTool, got: {:?}", other),
325        }
326    }
327}