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