Skip to main content

mcp_proxy/
composite.rs

1//! Composite tool middleware for fan-out to multiple backend tools.
2//!
3//! Composite tools are virtual tools that do not exist on any single backend.
4//! When called, they fan out the request to multiple backend tools concurrently,
5//! aggregating all results into a single response. This is useful for
6//! cross-cutting operations like "search everything" or "health-check all
7//! backends."
8//!
9//! # How it works
10//!
11//! The [`CompositeService`] intercepts two request types:
12//!
13//! - **`ListTools`** -- appends the composite tool definitions to the response
14//!   so clients discover them alongside regular backend tools.
15//! - **`CallTool`** -- if the tool name matches a composite, the same arguments
16//!   are forwarded to every target tool concurrently using `tokio::JoinSet`.
17//!   Results from all targets are collected into a single `CallToolResult`
18//!   whose `content` is the concatenation of all individual results. If any
19//!   target fails, the aggregated result's `is_error` flag is set to `true`,
20//!   but successful results are still included.
21//!
22//! All other request types pass through unchanged.
23//!
24//! # Strategy
25//!
26//! The `strategy` field controls execution order. Currently one strategy
27//! is supported:
28//!
29//! - **`parallel`** (default) -- all target tools execute concurrently via
30//!   `tokio::JoinSet`. Results are returned in completion order.
31//!
32//! # Configuration
33//!
34//! Composite tools are defined at the top level in TOML, referencing
35//! namespaced tool names from any backend:
36//!
37//! ```toml
38//! [[composite_tools]]
39//! name = "search_all"
40//! description = "Search across all knowledge sources"
41//! tools = ["github/search", "jira/search", "docs/search"]
42//! strategy = "parallel"
43//! ```
44//!
45//! Validation enforces that composite tool names are non-empty, unique,
46//! and reference at least one target tool.
47//!
48//! # Middleware stack position
49//!
50//! Composite tools are the outermost middleware in the request-processing
51//! stack, applied after aliasing. This means composite tool names are not
52//! subject to alias rewriting, but the target tools they reference are
53//! resolved through the full middleware chain (including aliases, filters,
54//! and validation). The ordering in `proxy.rs`:
55//!
56//! 1. Request validation ([`crate::validation`])
57//! 2. Capability filtering ([`crate::filter`])
58//! 3. Search-mode filtering ([`crate::filter`])
59//! 4. Tool aliasing ([`crate::alias`])
60//! 5. **Composite tools** (this module)
61
62use std::convert::Infallible;
63use std::future::Future;
64use std::pin::Pin;
65use std::sync::Arc;
66use std::task::{Context, Poll};
67
68use tokio::task::JoinSet;
69use tower::{Layer, Service};
70use tower_mcp::protocol::{
71    CallToolParams, CallToolResult, McpRequest, McpResponse, ToolDefinition,
72};
73use tower_mcp::router::{RouterRequest, RouterResponse};
74
75use crate::config::CompositeToolConfig;
76
77/// Tower layer that produces a [`CompositeService`].
78///
79/// # Example
80///
81/// ```rust
82/// use tower::ServiceBuilder;
83/// # let proxy = tower_mcp::McpRouter::new();
84/// use mcp_proxy::composite::CompositeLayer;
85/// use mcp_proxy::config::CompositeToolConfig;
86///
87/// let composites = vec![CompositeToolConfig {
88///     name: "search_all".into(),
89///     description: "Search everything".into(),
90///     tools: vec!["github/search".into(), "docs/search".into()],
91///     strategy: Default::default(),
92/// }];
93///
94/// let service = ServiceBuilder::new()
95///     .layer(CompositeLayer::new(composites))
96///     .service(proxy);
97/// ```
98#[derive(Clone)]
99pub struct CompositeLayer {
100    composites: Arc<Vec<CompositeToolConfig>>,
101}
102
103impl CompositeLayer {
104    /// Create a new composite layer with the given tool definitions.
105    pub fn new(composites: Vec<CompositeToolConfig>) -> Self {
106        Self {
107            composites: Arc::new(composites),
108        }
109    }
110}
111
112impl<S> Layer<S> for CompositeLayer {
113    type Service = CompositeService<S>;
114
115    fn layer(&self, inner: S) -> Self::Service {
116        CompositeService::new(inner, (*self.composites).clone())
117    }
118}
119
120/// Tower service that intercepts `ListTools` and `CallTool` requests
121/// to support composite tool fan-out.
122#[derive(Clone)]
123pub struct CompositeService<S> {
124    inner: S,
125    composites: Arc<Vec<CompositeToolConfig>>,
126}
127
128impl<S> CompositeService<S> {
129    /// Create a new composite service wrapping `inner` with the given composite tool configs.
130    pub fn new(inner: S, composites: Vec<CompositeToolConfig>) -> Self {
131        Self {
132            inner,
133            composites: Arc::new(composites),
134        }
135    }
136}
137
138impl<S> Service<RouterRequest> for CompositeService<S>
139where
140    S: Service<RouterRequest, Response = RouterResponse, Error = Infallible>
141        + Clone
142        + Send
143        + 'static,
144    S::Future: Send,
145{
146    type Response = RouterResponse;
147    type Error = Infallible;
148    type Future = Pin<Box<dyn Future<Output = Result<RouterResponse, Infallible>> + Send>>;
149
150    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
151        self.inner.poll_ready(cx)
152    }
153
154    fn call(&mut self, req: RouterRequest) -> Self::Future {
155        let composites = Arc::clone(&self.composites);
156
157        // Check if this is a CallTool for a composite tool
158        if let McpRequest::CallTool(ref params) = req.inner
159            && let Some(composite) = composites.iter().find(|c| c.name == params.name)
160        {
161            let id = req.id.clone();
162            let extensions = req.extensions.clone();
163            let tool_names = composite.tools.clone();
164            let arguments = params.arguments.clone();
165            let input_responses = params.input_responses.clone();
166            let request_state = params.request_state.clone();
167            let meta = params.meta.clone();
168            let task = params.task.clone();
169            let inner = self.inner.clone();
170
171            return Box::pin(async move {
172                let mut join_set = JoinSet::new();
173
174                for tool_name in tool_names {
175                    let mut svc = inner.clone();
176                    let tool_req = RouterRequest {
177                        id: id.clone(),
178                        inner: McpRequest::CallTool(CallToolParams {
179                            name: tool_name,
180                            arguments: arguments.clone(),
181                            input_responses: input_responses.clone(),
182                            request_state: request_state.clone(),
183                            meta: meta.clone(),
184                            task: task.clone(),
185                        }),
186                        extensions: extensions.clone(),
187                    };
188                    join_set.spawn(async move { svc.call(tool_req).await });
189                }
190
191                let mut all_content = Vec::new();
192                let mut any_error = false;
193
194                while let Some(result) = join_set.join_next().await {
195                    match result {
196                        Ok(Ok(resp)) => match resp.inner {
197                            Ok(McpResponse::CallTool(call_result)) => {
198                                if call_result.is_error {
199                                    any_error = true;
200                                }
201                                all_content.extend(call_result.content);
202                            }
203                            Err(json_rpc_err) => {
204                                any_error = true;
205                                all_content.push(tower_mcp::protocol::Content::text(format!(
206                                    "Error: {}",
207                                    json_rpc_err.message
208                                )));
209                            }
210                            Ok(other) => {
211                                any_error = true;
212                                all_content.push(tower_mcp::protocol::Content::text(format!(
213                                    "Unexpected response type: {:?}",
214                                    other
215                                )));
216                            }
217                        },
218                        Ok(Err(_infallible)) => {
219                            // Infallible error -- cannot happen
220                        }
221                        Err(join_err) => {
222                            any_error = true;
223                            all_content.push(tower_mcp::protocol::Content::text(format!(
224                                "Task failed: {}",
225                                join_err
226                            )));
227                        }
228                    }
229                }
230
231                let result = CallToolResult {
232                    content: all_content,
233                    is_error: any_error,
234                    structured_content: None,
235                    meta: None,
236                };
237
238                Ok(RouterResponse {
239                    id,
240                    inner: Ok(McpResponse::CallTool(result)),
241                })
242            });
243        }
244
245        // For ListTools, append composite tool definitions
246        if matches!(req.inner, McpRequest::ListTools(_)) {
247            let fut = self.inner.call(req);
248
249            return Box::pin(async move {
250                let mut result = fut.await;
251
252                let Ok(ref mut resp) = result;
253                if let Ok(McpResponse::ListTools(ref mut list_result)) = resp.inner {
254                    for composite in composites.iter() {
255                        list_result.tools.push(ToolDefinition {
256                            name: composite.name.clone(),
257                            title: None,
258                            description: Some(composite.description.clone()),
259                            input_schema: serde_json::json!({"type": "object"}),
260                            output_schema: None,
261                            icons: None,
262                            annotations: None,
263                            execution: None,
264                            meta: None,
265                        });
266                    }
267                }
268
269                result
270            });
271        }
272
273        // All other requests pass through unchanged
274        let fut = self.inner.call(req);
275        Box::pin(fut)
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use tower_mcp::protocol::{McpRequest, McpResponse};
282
283    use super::CompositeService;
284    use crate::config::{CompositeStrategy, CompositeToolConfig};
285    use crate::test_util::{ErrorMockService, MockService, call_service};
286
287    fn test_composites() -> Vec<CompositeToolConfig> {
288        vec![CompositeToolConfig {
289            name: "search_all".to_string(),
290            description: "Search across all sources".to_string(),
291            tools: vec!["github/search".to_string(), "docs/search".to_string()],
292            strategy: CompositeStrategy::Parallel,
293        }]
294    }
295
296    #[tokio::test]
297    async fn test_composite_appears_in_list_tools() {
298        let mock = MockService::with_tools(&["github/search", "docs/search", "db/query"]);
299        let mut svc = CompositeService::new(mock, test_composites());
300
301        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
302        match resp.inner.unwrap() {
303            McpResponse::ListTools(result) => {
304                let names: Vec<&str> = result.tools.iter().map(|t| t.name.as_str()).collect();
305                assert!(names.contains(&"github/search"));
306                assert!(names.contains(&"docs/search"));
307                assert!(names.contains(&"db/query"));
308                assert!(
309                    names.contains(&"search_all"),
310                    "composite tool should appear"
311                );
312                // Verify description
313                let composite_tool = result
314                    .tools
315                    .iter()
316                    .find(|t| t.name == "search_all")
317                    .unwrap();
318                assert_eq!(
319                    composite_tool.description.as_deref(),
320                    Some("Search across all sources")
321                );
322            }
323            other => panic!("expected ListTools, got: {:?}", other),
324        }
325    }
326
327    #[tokio::test]
328    async fn test_composite_fan_out_aggregates_results() {
329        let mock = MockService::with_tools(&["github/search", "docs/search"]);
330        let mut svc = CompositeService::new(mock, test_composites());
331
332        let resp = call_service(
333            &mut svc,
334            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
335                name: "search_all".to_string(),
336                arguments: serde_json::json!({"q": "test"}),
337                input_responses: None,
338                request_state: None,
339                meta: None,
340                task: None,
341            }),
342        )
343        .await;
344
345        match resp.inner.unwrap() {
346            McpResponse::CallTool(result) => {
347                assert_eq!(result.content.len(), 2, "should aggregate both results");
348                let texts: Vec<String> = result
349                    .content
350                    .iter()
351                    .map(|c| c.as_text().unwrap().to_string())
352                    .collect();
353                assert!(texts.contains(&"called: github/search".to_string()));
354                assert!(texts.contains(&"called: docs/search".to_string()));
355                assert!(!result.is_error, "no errors expected");
356            }
357            other => panic!("expected CallTool, got: {:?}", other),
358        }
359    }
360
361    #[tokio::test]
362    async fn test_non_composite_call_passes_through() {
363        let mock = MockService::with_tools(&["db/query"]);
364        let mut svc = CompositeService::new(mock, test_composites());
365
366        let resp = call_service(
367            &mut svc,
368            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
369                name: "db/query".to_string(),
370                arguments: serde_json::json!({}),
371                input_responses: None,
372                request_state: None,
373                meta: None,
374                task: None,
375            }),
376        )
377        .await;
378
379        match resp.inner.unwrap() {
380            McpResponse::CallTool(result) => {
381                assert_eq!(result.all_text(), "called: db/query");
382            }
383            other => panic!("expected CallTool, got: {:?}", other),
384        }
385    }
386
387    #[tokio::test]
388    async fn test_partial_failure_returns_partial_results() {
389        // Use ErrorMockService -- all calls will fail, producing error content
390        let mock = ErrorMockService;
391        let mut svc = CompositeService::new(mock, test_composites());
392
393        let resp = call_service(
394            &mut svc,
395            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
396                name: "search_all".to_string(),
397                arguments: serde_json::json!({}),
398                input_responses: None,
399                request_state: None,
400                meta: None,
401                task: None,
402            }),
403        )
404        .await;
405
406        match resp.inner.unwrap() {
407            McpResponse::CallTool(result) => {
408                assert_eq!(
409                    result.content.len(),
410                    2,
411                    "should have error content for both tools"
412                );
413                assert!(result.is_error, "should be marked as error");
414                for content in &result.content {
415                    let text = content.as_text().unwrap();
416                    assert!(
417                        text.contains("Error:"),
418                        "content should describe error: {text}"
419                    );
420                }
421            }
422            other => panic!("expected CallTool, got: {:?}", other),
423        }
424    }
425
426    #[tokio::test]
427    async fn test_non_tool_requests_pass_through() {
428        let mock = MockService::with_tools(&[]);
429        let mut svc = CompositeService::new(mock, test_composites());
430
431        let resp = call_service(&mut svc, McpRequest::Ping).await;
432        match resp.inner.unwrap() {
433            McpResponse::Pong(_) => {} // expected
434            other => panic!("expected Pong, got: {:?}", other),
435        }
436    }
437
438    #[tokio::test]
439    async fn test_empty_composites_passes_through() {
440        let mock = MockService::with_tools(&["tool1"]);
441        let mut svc = CompositeService::new(mock, vec![]);
442
443        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
444        match resp.inner.unwrap() {
445            McpResponse::ListTools(result) => {
446                assert_eq!(result.tools.len(), 1);
447                assert_eq!(result.tools[0].name, "tool1");
448            }
449            other => panic!("expected ListTools, got: {:?}", other),
450        }
451    }
452}