1use 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#[derive(Clone)]
99pub struct CompositeLayer {
100 composites: Arc<Vec<CompositeToolConfig>>,
101}
102
103impl CompositeLayer {
104 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#[derive(Clone)]
123pub struct CompositeService<S> {
124 inner: S,
125 composites: Arc<Vec<CompositeToolConfig>>,
126}
127
128impl<S> CompositeService<S> {
129 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 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 }
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 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 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 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 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(_) => {} 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}