Skip to main content

mcp_proxy/
mirror.rs

1//! Traffic mirroring / shadowing middleware.
2//!
3//! Sends a copy of traffic to a secondary backend (fire-and-forget, response
4//! discarded). Useful for testing new backend versions, benchmarking, or
5//! audit recording.
6//!
7//! # Configuration
8//!
9//! ```toml
10//! [[backends]]
11//! name = "api"
12//! transport = "http"
13//! url = "http://api.internal:8080"
14//!
15//! [[backends]]
16//! name = "api-v2"
17//! transport = "http"
18//! url = "http://api-v2.internal:8080"
19//! mirror_of = "api"        # mirror traffic from "api" backend
20//! mirror_percent = 10      # mirror 10% of requests
21//! ```
22//!
23//! # How it works
24//!
25//! 1. Request arrives targeting `api/search`
26//! 2. Primary response is returned from the `api` backend as normal
27//! 3. A copy of the request is rewritten to `api-v2/search` and sent
28//!    fire-and-forget to the `api-v2` backend
29//! 4. The mirror response is discarded; errors are logged but don't
30//!    affect the primary response
31
32use std::collections::HashMap;
33use std::convert::Infallible;
34use std::future::Future;
35use std::pin::Pin;
36use std::sync::Arc;
37use std::sync::atomic::{AtomicU64, Ordering};
38use std::task::{Context, Poll};
39
40use tower::{Layer, Service};
41use tower_mcp::router::{Extensions, RouterRequest, RouterResponse};
42use tower_mcp_types::protocol::{CallToolParams, GetPromptParams, McpRequest, ReadResourceParams};
43
44/// Tower layer that produces a [`MirrorService`].
45#[derive(Clone)]
46pub struct MirrorLayer {
47    mirrors: HashMap<String, (String, u32)>,
48    separator: String,
49}
50
51impl MirrorLayer {
52    /// Create a new mirror layer.
53    ///
54    /// `mirrors` maps source backend names to `(mirror_name, percent)`.
55    pub fn new(mirrors: HashMap<String, (String, u32)>, separator: impl Into<String>) -> Self {
56        Self {
57            mirrors,
58            separator: separator.into(),
59        }
60    }
61}
62
63impl<S> Layer<S> for MirrorLayer {
64    type Service = MirrorService<S>;
65
66    fn layer(&self, inner: S) -> Self::Service {
67        MirrorService::new(inner, self.mirrors.clone(), &self.separator)
68    }
69}
70
71/// Mapping from a source backend namespace to its mirror configuration.
72#[derive(Debug, Clone)]
73struct MirrorMapping {
74    /// Source namespace prefix (e.g. "api/").
75    source_prefix: String,
76    /// Mirror namespace prefix (e.g. "api-v2/").
77    mirror_prefix: String,
78    /// Percentage of requests to mirror (1-100).
79    percent: u32,
80    /// Atomic counter for deterministic percentage-based sampling.
81    counter: Arc<AtomicU64>,
82}
83
84/// Traffic mirroring middleware.
85///
86/// Wraps the proxy service and sends copies of matching requests to
87/// mirror backends. The primary response is always returned; mirror
88/// responses are discarded.
89#[derive(Clone)]
90pub struct MirrorService<S> {
91    inner: S,
92    mappings: Arc<Vec<MirrorMapping>>,
93}
94
95impl<S> MirrorService<S> {
96    /// Create a new mirror service.
97    ///
98    /// `mirrors` maps source backend names to `(mirror_name, percent)`.
99    /// The `separator` is used to construct namespace prefixes.
100    pub fn new(inner: S, mirrors: HashMap<String, (String, u32)>, separator: &str) -> Self {
101        let mappings = mirrors
102            .into_iter()
103            .map(|(source, (mirror, percent))| MirrorMapping {
104                source_prefix: format!("{source}{separator}"),
105                mirror_prefix: format!("{mirror}{separator}"),
106                percent: percent.clamp(1, 100),
107                counter: Arc::new(AtomicU64::new(0)),
108            })
109            .collect();
110
111        Self {
112            inner,
113            mappings: Arc::new(mappings),
114        }
115    }
116}
117
118/// Check if a request name starts with a namespace prefix and return the
119/// matching mirror mapping.
120fn find_mirror<'a>(name: &str, mappings: &'a [MirrorMapping]) -> Option<&'a MirrorMapping> {
121    mappings.iter().find(|m| name.starts_with(&m.source_prefix))
122}
123
124/// Rewrite a namespaced name from source to mirror prefix.
125fn rewrite_name(name: &str, source_prefix: &str, mirror_prefix: &str) -> String {
126    let suffix = &name[source_prefix.len()..];
127    format!("{mirror_prefix}{suffix}")
128}
129
130/// Clone a request with its name rewritten to the mirror namespace.
131fn clone_for_mirror(
132    req: &RouterRequest,
133    source_prefix: &str,
134    mirror_prefix: &str,
135) -> Option<RouterRequest> {
136    let new_inner = match &req.inner {
137        McpRequest::CallTool(params) if params.name.starts_with(source_prefix) => {
138            McpRequest::CallTool(CallToolParams {
139                name: rewrite_name(&params.name, source_prefix, mirror_prefix),
140                arguments: params.arguments.clone(),
141                input_responses: params.input_responses.clone(),
142                request_state: params.request_state.clone(),
143                meta: params.meta.clone(),
144                task: params.task.clone(),
145            })
146        }
147        McpRequest::ReadResource(params) if params.uri.starts_with(source_prefix) => {
148            McpRequest::ReadResource(ReadResourceParams {
149                uri: rewrite_name(&params.uri, source_prefix, mirror_prefix),
150                input_responses: params.input_responses.clone(),
151                request_state: params.request_state.clone(),
152                meta: params.meta.clone(),
153            })
154        }
155        McpRequest::GetPrompt(params) if params.name.starts_with(source_prefix) => {
156            McpRequest::GetPrompt(GetPromptParams {
157                name: rewrite_name(&params.name, source_prefix, mirror_prefix),
158                arguments: params.arguments.clone(),
159                input_responses: params.input_responses.clone(),
160                request_state: params.request_state.clone(),
161                meta: params.meta.clone(),
162            })
163        }
164        // List requests and other types aren't mirrored
165        _ => return None,
166    };
167
168    Some(RouterRequest {
169        id: req.id.clone(),
170        inner: new_inner,
171        extensions: Extensions::new(),
172    })
173}
174
175/// Check if the sampling counter says this request should be mirrored.
176fn should_mirror(mapping: &MirrorMapping) -> bool {
177    if mapping.percent >= 100 {
178        return true;
179    }
180    let count = mapping.counter.fetch_add(1, Ordering::Relaxed);
181    (count % 100) < mapping.percent as u64
182}
183
184/// Extract the request name for namespace matching.
185fn request_name(req: &McpRequest) -> Option<&str> {
186    match req {
187        McpRequest::CallTool(params) => Some(&params.name),
188        McpRequest::ReadResource(params) => Some(&params.uri),
189        McpRequest::GetPrompt(params) => Some(&params.name),
190        _ => None,
191    }
192}
193
194impl<S> Service<RouterRequest> for MirrorService<S>
195where
196    S: Service<RouterRequest, Response = RouterResponse, Error = Infallible>
197        + Clone
198        + Send
199        + 'static,
200    S::Future: Send,
201{
202    type Response = RouterResponse;
203    type Error = Infallible;
204    type Future = Pin<Box<dyn Future<Output = Result<RouterResponse, Infallible>> + Send>>;
205
206    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
207        self.inner.poll_ready(cx)
208    }
209
210    fn call(&mut self, req: RouterRequest) -> Self::Future {
211        // Check if this request should be mirrored
212        let mirror_req = request_name(&req.inner)
213            .and_then(|name| find_mirror(name, &self.mappings))
214            .filter(|mapping| should_mirror(mapping))
215            .and_then(|mapping| {
216                clone_for_mirror(&req, &mapping.source_prefix, &mapping.mirror_prefix)
217            });
218
219        // Send the primary request
220        let primary_fut = self.inner.call(req);
221
222        // If mirroring, clone the service and spawn a fire-and-forget task
223        let mut mirror_svc = if mirror_req.is_some() {
224            Some(self.inner.clone())
225        } else {
226            None
227        };
228
229        Box::pin(async move {
230            // Spawn mirror request as a fire-and-forget task
231            if let Some(mirror) = mirror_req
232                && let Some(ref mut svc) = mirror_svc
233            {
234                let mut svc = svc.clone();
235                tokio::spawn(async move {
236                    match svc.call(mirror).await {
237                        Ok(resp) => {
238                            if resp.inner.is_err() {
239                                tracing::debug!("Mirror request returned error (discarded)");
240                            }
241                        }
242                        Err(e) => match e {},
243                    }
244                });
245            }
246
247            primary_fut.await
248        })
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::test_util::{MockService, call_service};
256    use tower_mcp::protocol::RequestId;
257    use tower_mcp::router::Extensions;
258    use tower_mcp_types::protocol::McpRequest;
259
260    fn make_mirrors(source: &str, mirror: &str, percent: u32) -> HashMap<String, (String, u32)> {
261        let mut m = HashMap::new();
262        m.insert(source.to_string(), (mirror.to_string(), percent));
263        m
264    }
265
266    #[test]
267    fn test_rewrite_name() {
268        assert_eq!(
269            rewrite_name("api/search", "api/", "api-v2/"),
270            "api-v2/search"
271        );
272        assert_eq!(
273            rewrite_name("api/nested/tool", "api/", "mirror/"),
274            "mirror/nested/tool"
275        );
276    }
277
278    #[test]
279    fn test_find_mirror_match() {
280        let mappings = vec![MirrorMapping {
281            source_prefix: "api/".to_string(),
282            mirror_prefix: "api-v2/".to_string(),
283            percent: 100,
284            counter: Arc::new(AtomicU64::new(0)),
285        }];
286        assert!(find_mirror("api/search", &mappings).is_some());
287        assert!(find_mirror("other/search", &mappings).is_none());
288    }
289
290    #[test]
291    fn test_should_mirror_100_percent() {
292        let mapping = MirrorMapping {
293            source_prefix: "api/".to_string(),
294            mirror_prefix: "api-v2/".to_string(),
295            percent: 100,
296            counter: Arc::new(AtomicU64::new(0)),
297        };
298        // All requests should be mirrored
299        for _ in 0..10 {
300            assert!(should_mirror(&mapping));
301        }
302    }
303
304    #[test]
305    fn test_should_mirror_percentage() {
306        let mapping = MirrorMapping {
307            source_prefix: "api/".to_string(),
308            mirror_prefix: "api-v2/".to_string(),
309            percent: 10,
310            counter: Arc::new(AtomicU64::new(0)),
311        };
312        // Over 100 requests, exactly 10 should be mirrored
313        let mirrored: u32 = (0..100).filter(|_| should_mirror(&mapping)).count() as u32;
314        assert_eq!(mirrored, 10);
315    }
316
317    #[test]
318    fn test_clone_for_mirror_call_tool() {
319        let req = RouterRequest {
320            id: RequestId::Number(1),
321            inner: McpRequest::CallTool(CallToolParams {
322                name: "api/search".to_string(),
323                arguments: serde_json::json!({"q": "test"}),
324                input_responses: Some(Default::default()),
325                request_state: Some("continuation-1".to_string()),
326                meta: None,
327                task: None,
328            }),
329            extensions: Extensions::new(),
330        };
331
332        let mirrored = clone_for_mirror(&req, "api/", "api-v2/").unwrap();
333        match &mirrored.inner {
334            McpRequest::CallTool(params) => {
335                assert_eq!(params.name, "api-v2/search");
336                assert_eq!(params.arguments, serde_json::json!({"q": "test"}));
337                assert!(params.input_responses.is_some());
338                assert_eq!(params.request_state.as_deref(), Some("continuation-1"));
339            }
340            _ => panic!("expected CallTool"),
341        }
342    }
343
344    #[test]
345    fn test_clone_for_mirror_read_resource() {
346        let req = RouterRequest {
347            id: RequestId::Number(1),
348            inner: McpRequest::ReadResource(ReadResourceParams {
349                uri: "api/docs/readme".to_string(),
350                input_responses: None,
351                request_state: None,
352                meta: None,
353            }),
354            extensions: Extensions::new(),
355        };
356
357        let mirrored = clone_for_mirror(&req, "api/", "mirror/").unwrap();
358        match &mirrored.inner {
359            McpRequest::ReadResource(params) => {
360                assert_eq!(params.uri, "mirror/docs/readme");
361            }
362            _ => panic!("expected ReadResource"),
363        }
364    }
365
366    #[test]
367    fn test_clone_for_mirror_list_tools_returns_none() {
368        let req = RouterRequest {
369            id: RequestId::Number(1),
370            inner: McpRequest::ListTools(Default::default()),
371            extensions: Extensions::new(),
372        };
373        assert!(clone_for_mirror(&req, "api/", "mirror/").is_none());
374    }
375
376    #[tokio::test]
377    async fn test_mirror_service_passes_through() {
378        let mock = MockService::with_tools(&["api/search", "api-v2/search"]);
379        let mirrors = make_mirrors("api", "api-v2", 100);
380        let mut svc = MirrorService::new(mock, mirrors, "/");
381
382        let resp = call_service(
383            &mut svc,
384            McpRequest::CallTool(CallToolParams {
385                name: "api/search".to_string(),
386                arguments: serde_json::json!({}),
387                input_responses: None,
388                request_state: None,
389                meta: None,
390                task: None,
391            }),
392        )
393        .await;
394
395        // Primary response should be returned
396        assert!(resp.inner.is_ok());
397    }
398
399    #[tokio::test]
400    async fn test_mirror_service_non_mirrored_passes_through() {
401        let mock = MockService::with_tools(&["other/tool"]);
402        let mirrors = make_mirrors("api", "api-v2", 100);
403        let mut svc = MirrorService::new(mock, mirrors, "/");
404
405        let resp = call_service(
406            &mut svc,
407            McpRequest::CallTool(CallToolParams {
408                name: "other/tool".to_string(),
409                arguments: serde_json::json!({}),
410                input_responses: None,
411                request_state: None,
412                meta: None,
413                task: None,
414            }),
415        )
416        .await;
417
418        assert!(resp.inner.is_ok());
419    }
420
421    #[tokio::test]
422    async fn test_mirror_service_list_tools_not_mirrored() {
423        let mock = MockService::with_tools(&["api/search"]);
424        let mirrors = make_mirrors("api", "api-v2", 100);
425        let mut svc = MirrorService::new(mock, mirrors, "/");
426
427        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
428        assert!(resp.inner.is_ok());
429    }
430}