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,ignore
82/// use tower::ServiceBuilder;
83/// use mcp_proxy::composite::CompositeLayer;
84/// use mcp_proxy::config::CompositeToolConfig;
85///
86/// let composites = vec![CompositeToolConfig {
87///     name: "search_all".into(),
88///     description: "Search everything".into(),
89///     tools: vec!["github/search".into(), "docs/search".into()],
90///     strategy: Default::default(),
91/// }];
92///
93/// let service = ServiceBuilder::new()
94///     .layer(CompositeLayer::new(composites))
95///     .service(proxy);
96/// ```
97#[derive(Clone)]
98pub struct CompositeLayer {
99    composites: Arc<Vec<CompositeToolConfig>>,
100}
101
102impl CompositeLayer {
103    /// Create a new composite layer with the given tool definitions.
104    pub fn new(composites: Vec<CompositeToolConfig>) -> Self {
105        Self {
106            composites: Arc::new(composites),
107        }
108    }
109}
110
111impl<S> Layer<S> for CompositeLayer {
112    type Service = CompositeService<S>;
113
114    fn layer(&self, inner: S) -> Self::Service {
115        CompositeService::new(inner, (*self.composites).clone())
116    }
117}
118
119/// Tower service that intercepts `ListTools` and `CallTool` requests
120/// to support composite tool fan-out.
121#[derive(Clone)]
122pub struct CompositeService<S> {
123    inner: S,
124    composites: Arc<Vec<CompositeToolConfig>>,
125}
126
127impl<S> CompositeService<S> {
128    /// Create a new composite service wrapping `inner` with the given composite tool configs.
129    pub fn new(inner: S, composites: Vec<CompositeToolConfig>) -> Self {
130        Self {
131            inner,
132            composites: Arc::new(composites),
133        }
134    }
135}
136
137impl<S> Service<RouterRequest> for CompositeService<S>
138where
139    S: Service<RouterRequest, Response = RouterResponse, Error = Infallible>
140        + Clone
141        + Send
142        + 'static,
143    S::Future: Send,
144{
145    type Response = RouterResponse;
146    type Error = Infallible;
147    type Future = Pin<Box<dyn Future<Output = Result<RouterResponse, Infallible>> + Send>>;
148
149    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
150        self.inner.poll_ready(cx)
151    }
152
153    fn call(&mut self, req: RouterRequest) -> Self::Future {
154        let composites = Arc::clone(&self.composites);
155
156        // Check if this is a CallTool for a composite tool
157        if let McpRequest::CallTool(ref params) = req.inner
158            && let Some(composite) = composites.iter().find(|c| c.name == params.name)
159        {
160            let id = req.id.clone();
161            let extensions = req.extensions.clone();
162            let tool_names = composite.tools.clone();
163            let arguments = params.arguments.clone();
164            let input_responses = params.input_responses.clone();
165            let request_state = params.request_state.clone();
166            let meta = params.meta.clone();
167            let task = params.task.clone();
168            let inner = self.inner.clone();
169
170            return Box::pin(async move {
171                let mut join_set = JoinSet::new();
172
173                for tool_name in tool_names {
174                    let mut svc = inner.clone();
175                    let tool_req = RouterRequest {
176                        id: id.clone(),
177                        inner: McpRequest::CallTool(CallToolParams {
178                            name: tool_name,
179                            arguments: arguments.clone(),
180                            input_responses: input_responses.clone(),
181                            request_state: request_state.clone(),
182                            meta: meta.clone(),
183                            task: task.clone(),
184                        }),
185                        extensions: extensions.clone(),
186                    };
187                    join_set.spawn(async move { svc.call(tool_req).await });
188                }
189
190                let mut all_content = Vec::new();
191                let mut any_error = false;
192
193                while let Some(result) = join_set.join_next().await {
194                    match result {
195                        Ok(Ok(resp)) => match resp.inner {
196                            Ok(McpResponse::CallTool(call_result)) => {
197                                if call_result.is_error {
198                                    any_error = true;
199                                }
200                                all_content.extend(call_result.content);
201                            }
202                            Err(json_rpc_err) => {
203                                any_error = true;
204                                all_content.push(tower_mcp::protocol::Content::text(format!(
205                                    "Error: {}",
206                                    json_rpc_err.message
207                                )));
208                            }
209                            Ok(other) => {
210                                any_error = true;
211                                all_content.push(tower_mcp::protocol::Content::text(format!(
212                                    "Unexpected response type: {:?}",
213                                    other
214                                )));
215                            }
216                        },
217                        Ok(Err(_infallible)) => {
218                            // Infallible error -- cannot happen
219                        }
220                        Err(join_err) => {
221                            any_error = true;
222                            all_content.push(tower_mcp::protocol::Content::text(format!(
223                                "Task failed: {}",
224                                join_err
225                            )));
226                        }
227                    }
228                }
229
230                let result = CallToolResult {
231                    content: all_content,
232                    is_error: any_error,
233                    structured_content: None,
234                    meta: None,
235                };
236
237                Ok(RouterResponse {
238                    id,
239                    inner: Ok(McpResponse::CallTool(result)),
240                })
241            });
242        }
243
244        // For ListTools, append composite tool definitions
245        if matches!(req.inner, McpRequest::ListTools(_)) {
246            let fut = self.inner.call(req);
247
248            return Box::pin(async move {
249                let mut result = fut.await;
250
251                let Ok(ref mut resp) = result;
252                if let Ok(McpResponse::ListTools(ref mut list_result)) = resp.inner {
253                    for composite in composites.iter() {
254                        list_result.tools.push(ToolDefinition {
255                            name: composite.name.clone(),
256                            title: None,
257                            description: Some(composite.description.clone()),
258                            input_schema: serde_json::json!({"type": "object"}),
259                            output_schema: None,
260                            icons: None,
261                            annotations: None,
262                            execution: None,
263                            meta: None,
264                        });
265                    }
266                }
267
268                result
269            });
270        }
271
272        // All other requests pass through unchanged
273        let fut = self.inner.call(req);
274        Box::pin(fut)
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use tower_mcp::protocol::{McpRequest, McpResponse};
281
282    use super::CompositeService;
283    use crate::config::{CompositeStrategy, CompositeToolConfig};
284    use crate::test_util::{ErrorMockService, MockService, call_service};
285
286    fn test_composites() -> Vec<CompositeToolConfig> {
287        vec![CompositeToolConfig {
288            name: "search_all".to_string(),
289            description: "Search across all sources".to_string(),
290            tools: vec!["github/search".to_string(), "docs/search".to_string()],
291            strategy: CompositeStrategy::Parallel,
292        }]
293    }
294
295    #[tokio::test]
296    async fn test_composite_appears_in_list_tools() {
297        let mock = MockService::with_tools(&["github/search", "docs/search", "db/query"]);
298        let mut svc = CompositeService::new(mock, test_composites());
299
300        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
301        match resp.inner.unwrap() {
302            McpResponse::ListTools(result) => {
303                let names: Vec<&str> = result.tools.iter().map(|t| t.name.as_str()).collect();
304                assert!(names.contains(&"github/search"));
305                assert!(names.contains(&"docs/search"));
306                assert!(names.contains(&"db/query"));
307                assert!(
308                    names.contains(&"search_all"),
309                    "composite tool should appear"
310                );
311                // Verify description
312                let composite_tool = result
313                    .tools
314                    .iter()
315                    .find(|t| t.name == "search_all")
316                    .unwrap();
317                assert_eq!(
318                    composite_tool.description.as_deref(),
319                    Some("Search across all sources")
320                );
321            }
322            other => panic!("expected ListTools, got: {:?}", other),
323        }
324    }
325
326    #[tokio::test]
327    async fn test_composite_fan_out_aggregates_results() {
328        let mock = MockService::with_tools(&["github/search", "docs/search"]);
329        let mut svc = CompositeService::new(mock, test_composites());
330
331        let resp = call_service(
332            &mut svc,
333            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
334                name: "search_all".to_string(),
335                arguments: serde_json::json!({"q": "test"}),
336                input_responses: None,
337                request_state: None,
338                meta: None,
339                task: None,
340            }),
341        )
342        .await;
343
344        match resp.inner.unwrap() {
345            McpResponse::CallTool(result) => {
346                assert_eq!(result.content.len(), 2, "should aggregate both results");
347                let texts: Vec<String> = result
348                    .content
349                    .iter()
350                    .map(|c| c.as_text().unwrap().to_string())
351                    .collect();
352                assert!(texts.contains(&"called: github/search".to_string()));
353                assert!(texts.contains(&"called: docs/search".to_string()));
354                assert!(!result.is_error, "no errors expected");
355            }
356            other => panic!("expected CallTool, got: {:?}", other),
357        }
358    }
359
360    #[tokio::test]
361    async fn test_non_composite_call_passes_through() {
362        let mock = MockService::with_tools(&["db/query"]);
363        let mut svc = CompositeService::new(mock, test_composites());
364
365        let resp = call_service(
366            &mut svc,
367            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
368                name: "db/query".to_string(),
369                arguments: serde_json::json!({}),
370                input_responses: None,
371                request_state: None,
372                meta: None,
373                task: None,
374            }),
375        )
376        .await;
377
378        match resp.inner.unwrap() {
379            McpResponse::CallTool(result) => {
380                assert_eq!(result.all_text(), "called: db/query");
381            }
382            other => panic!("expected CallTool, got: {:?}", other),
383        }
384    }
385
386    #[tokio::test]
387    async fn test_partial_failure_returns_partial_results() {
388        // Use ErrorMockService -- all calls will fail, producing error content
389        let mock = ErrorMockService;
390        let mut svc = CompositeService::new(mock, test_composites());
391
392        let resp = call_service(
393            &mut svc,
394            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
395                name: "search_all".to_string(),
396                arguments: serde_json::json!({}),
397                input_responses: None,
398                request_state: None,
399                meta: None,
400                task: None,
401            }),
402        )
403        .await;
404
405        match resp.inner.unwrap() {
406            McpResponse::CallTool(result) => {
407                assert_eq!(
408                    result.content.len(),
409                    2,
410                    "should have error content for both tools"
411                );
412                assert!(result.is_error, "should be marked as error");
413                for content in &result.content {
414                    let text = content.as_text().unwrap();
415                    assert!(
416                        text.contains("Error:"),
417                        "content should describe error: {text}"
418                    );
419                }
420            }
421            other => panic!("expected CallTool, got: {:?}", other),
422        }
423    }
424
425    #[tokio::test]
426    async fn test_non_tool_requests_pass_through() {
427        let mock = MockService::with_tools(&[]);
428        let mut svc = CompositeService::new(mock, test_composites());
429
430        let resp = call_service(&mut svc, McpRequest::Ping).await;
431        match resp.inner.unwrap() {
432            McpResponse::Pong(_) => {} // expected
433            other => panic!("expected Pong, got: {:?}", other),
434        }
435    }
436
437    #[tokio::test]
438    async fn test_empty_composites_passes_through() {
439        let mock = MockService::with_tools(&["tool1"]);
440        let mut svc = CompositeService::new(mock, vec![]);
441
442        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
443        match resp.inner.unwrap() {
444            McpResponse::ListTools(result) => {
445                assert_eq!(result.tools.len(), 1);
446                assert_eq!(result.tools[0].name, "tool1");
447            }
448            other => panic!("expected ListTools, got: {:?}", other),
449        }
450    }
451}