1mod catalog;
4mod client;
5mod config;
6
7use std::{
8 collections::{BTreeMap, btree_map::Entry},
9 sync::{
10 Arc,
11 atomic::{AtomicBool, Ordering},
12 },
13 time::Duration,
14};
15
16use async_trait::async_trait;
17use catalog::{ProviderState, ToolEntry};
18use nanocodex_core::ToolDefinition;
19use nanocodex_tools::{
20 DynamicToolProvider, Tool, ToolContext, ToolExecution, ToolInput, ToolResult,
21};
22use rmcp::model::CallToolRequestParams;
23use serde::Deserialize;
24use serde_json::{Value, json};
25use tracing::{Instrument, info_span};
26
27pub use config::McpServer;
28
29const TOOL_SEARCH_NAME: &str = "tool_search";
30
31pub struct Mcp {
33 servers: Arc<[NamedServer]>,
34 state: Arc<ProviderState>,
35 search: Arc<McpSearch>,
36 started: AtomicBool,
37}
38
39struct NamedServer {
40 name: String,
41 config: McpServer,
42}
43
44#[derive(Default)]
46pub struct McpBuilder {
47 servers: BTreeMap<String, McpServer>,
48 duplicate: Option<String>,
49}
50
51#[derive(Debug, thiserror::Error)]
52pub enum McpBuildError {
53 #[error("at least one MCP server is required")]
54 Empty,
55 #[error("MCP server name must not be empty")]
56 EmptyName,
57 #[error("MCP server `{0}` is configured more than once")]
58 DuplicateServer(String),
59 #[error("MCP server `{server}` has an empty {field}")]
60 EmptyField { server: String, field: &'static str },
61 #[error("MCP server `{server}` has a zero {field}")]
62 ZeroTimeout { server: String, field: &'static str },
63 #[error("MCP server `{server}` does not support option `{option}` for its transport")]
64 UnsupportedOption {
65 server: String,
66 option: &'static str,
67 },
68}
69
70impl Mcp {
71 #[must_use]
72 pub fn builder() -> McpBuilder {
73 McpBuilder::default()
74 }
75}
76
77impl McpBuilder {
78 #[must_use]
80 pub fn server(mut self, name: impl Into<String>, server: McpServer) -> Self {
81 let name = name.into();
82 match self.servers.entry(name) {
83 Entry::Vacant(entry) => {
84 entry.insert(server);
85 }
86 Entry::Occupied(entry) => {
87 self.duplicate.get_or_insert_with(|| entry.key().clone());
88 }
89 }
90 self
91 }
92
93 pub fn build(self) -> Result<Mcp, McpBuildError> {
100 if self.servers.is_empty() {
101 return Err(McpBuildError::Empty);
102 }
103 if let Some(name) = self.duplicate {
104 return Err(McpBuildError::DuplicateServer(name));
105 }
106 let mut discovery_timeout = Duration::ZERO;
107 let mut named = Vec::with_capacity(self.servers.len());
108 for (name, server) in self.servers {
109 validate_server(&name, &server)?;
110 discovery_timeout = discovery_timeout.max(server.startup_timeout.saturating_mul(2));
111 named.push(NamedServer {
112 name,
113 config: server,
114 });
115 }
116 let servers: Arc<[NamedServer]> = named.into();
117 let state = Arc::new(ProviderState::new(servers.len(), discovery_timeout));
118 let search = Arc::new(McpSearch {
119 state: Arc::clone(&state),
120 description: search_description(&servers),
121 });
122 Ok(Mcp {
123 servers,
124 state,
125 search,
126 started: AtomicBool::new(false),
127 })
128 }
129}
130
131#[async_trait]
132impl DynamicToolProvider for Mcp {
133 fn start(&self) {
134 if self.started.swap(true, Ordering::AcqRel) {
135 return;
136 }
137 for server in &*self.servers {
138 let name = server.name.clone();
139 let config = server.config.clone();
140 let state = Arc::clone(&self.state);
141 let span = info_span!(
142 target: "nanocodex_mcp",
143 parent: None,
144 "mcp.server_start",
145 otel.kind = "client",
146 otel.status_code = tracing::field::Empty,
147 mcp.server = %name,
148 status = tracing::field::Empty,
149 tool.count = tracing::field::Empty,
150 );
151 drop(tokio::spawn(
152 async move {
153 let result = client::connect(&config).await.map(|connected| {
154 connected
155 .tools
156 .into_iter()
157 .map(|tool| {
158 ToolEntry::new(
159 &name,
160 &tool,
161 Arc::clone(&connected.client),
162 config.tool_timeout,
163 )
164 })
165 .collect::<Vec<_>>()
166 });
167 let current = tracing::Span::current();
168 current.record(
169 "status",
170 if result.is_ok() {
171 "completed"
172 } else {
173 "failed"
174 },
175 );
176 current.record(
177 "otel.status_code",
178 if result.is_ok() { "OK" } else { "ERROR" },
179 );
180 if let Ok(tools) = &result {
181 current.record("tool.count", tools.len());
182 }
183 state.complete_server(&name, result);
184 }
185 .instrument(span),
186 ));
187 }
188 }
189
190 fn direct_tools(&self) -> Vec<Arc<dyn Tool>> {
191 vec![Arc::clone(&self.search) as Arc<dyn Tool>]
192 }
193
194 fn available_definitions(&self) -> Vec<ToolDefinition> {
195 self.state.available_definitions()
196 }
197
198 async fn execute(
199 &self,
200 name: &str,
201 input: Value,
202 _context: ToolContext<'_>,
203 ) -> Option<ToolExecution> {
204 let entry = self.state.active_entry(name)?;
205 let Value::Object(arguments) = input else {
206 return Some(ToolExecution::error(format!(
207 "MCP tool {name} requires an object argument"
208 )));
209 };
210 let argument_bytes = serde_json::to_vec(&arguments).map_or(0, |encoded| encoded.len());
211 let argument_keys = arguments
212 .keys()
213 .map(String::as_str)
214 .collect::<Vec<_>>()
215 .join(",");
216 let argument_count = arguments.len();
217 let params =
218 CallToolRequestParams::new(entry.remote_name.clone()).with_arguments(arguments);
219 let span = info_span!(
220 target: "nanocodex_mcp",
221 "mcp.tool_call",
222 otel.kind = "client",
223 otel.status_code = tracing::field::Empty,
224 mcp.server = %entry.server_name,
225 mcp.tool = %entry.remote_name,
226 mcp.arguments.bytes = argument_bytes,
227 mcp.arguments.keys = argument_keys,
228 mcp.arguments.count = argument_count,
229 status = tracing::field::Empty,
230 );
231 let result = match tokio::time::timeout(
232 entry.timeout,
233 entry.client.call_tool(params).instrument(span.clone()),
234 )
235 .await
236 {
237 Ok(Ok(result)) => result,
238 Ok(Err(error)) => {
239 span.record("status", "failed");
240 span.record("otel.status_code", "ERROR");
241 return Some(ToolExecution::error(format!(
242 "MCP tool {}/{} failed: {error}",
243 entry.server_name, entry.remote_name
244 )));
245 }
246 Err(_) => {
247 span.record("status", "timeout");
248 span.record("otel.status_code", "ERROR");
249 return Some(ToolExecution::error(format!(
250 "MCP tool {}/{} exceeded {:.1} seconds",
251 entry.server_name,
252 entry.remote_name,
253 entry.timeout.as_secs_f64()
254 )));
255 }
256 };
257 let success = !result.is_error.unwrap_or(false);
258 span.record("status", if success { "completed" } else { "failed" });
259 span.record("otel.status_code", if success { "OK" } else { "ERROR" });
260 let value = match serde_json::to_value(result) {
261 Ok(value) => value,
262 Err(error) => {
263 span.record("status", "failed");
264 span.record("otel.status_code", "ERROR");
265 return Some(ToolExecution::error(format!(
266 "failed to encode MCP tool result: {error}"
267 )));
268 }
269 };
270 Some(
271 ToolExecution::from_json(value, success).with_metadata(json!({
272 "mcp_server": entry.server_name,
273 "mcp_tool": entry.remote_name,
274 })),
275 )
276 }
277}
278
279struct McpSearch {
280 state: Arc<ProviderState>,
281 description: String,
282}
283
284#[derive(Deserialize)]
285#[serde(deny_unknown_fields)]
286struct SearchInput {
287 query: String,
288 #[serde(default)]
289 limit: Option<usize>,
290}
291
292#[async_trait]
293impl Tool for McpSearch {
294 fn name(&self) -> &'static str {
295 TOOL_SEARCH_NAME
296 }
297
298 fn definition(&self) -> ToolDefinition {
299 ToolDefinition::function(
300 TOOL_SEARCH_NAME,
301 self.description.clone(),
302 json!({
303 "type": "object",
304 "properties": {
305 "query": {
306 "type": "string",
307 "description": "Search query for deferred MCP tools."
308 },
309 "limit": {
310 "type": "integer",
311 "minimum": 1,
312 "maximum": 32,
313 "description": "Maximum number of tools to return. Defaults to 8."
314 }
315 },
316 "required": ["query"],
317 "additionalProperties": false
318 }),
319 )
320 }
321
322 async fn execute(&self, input: ToolInput, _context: ToolContext<'_>) -> ToolResult {
323 let input = input.decode_json::<SearchInput>()?;
324 Ok(match self.state.search(&input.query, input.limit).await {
325 Ok(result) => ToolExecution::json(&result),
326 Err(error) => ToolExecution::error(error),
327 })
328 }
329}
330
331fn validate_server(name: &str, server: &McpServer) -> Result<(), McpBuildError> {
332 if name.trim().is_empty() {
333 return Err(McpBuildError::EmptyName);
334 }
335 if let Some(option) = server.unsupported_option {
336 return Err(McpBuildError::UnsupportedOption {
337 server: name.to_owned(),
338 option,
339 });
340 }
341 let (field, value) = match &server.transport {
342 config::McpTransport::Stdio { command, .. } => ("command", command.as_str()),
343 config::McpTransport::StreamableHttp { url, .. } => ("URL", url.as_str()),
344 };
345 if value.trim().is_empty() {
346 return Err(McpBuildError::EmptyField {
347 server: name.to_owned(),
348 field,
349 });
350 }
351 for (field, timeout) in [
352 ("startup timeout", server.startup_timeout),
353 ("tool timeout", server.tool_timeout),
354 ] {
355 if timeout.is_zero() {
356 return Err(McpBuildError::ZeroTimeout {
357 server: name.to_owned(),
358 field,
359 });
360 }
361 }
362 Ok(())
363}
364
365fn search_description(servers: &[NamedServer]) -> String {
366 let sources = servers
367 .iter()
368 .map(|server| match server.config.description.as_deref() {
369 Some(description) => format!("- {}: {}", server.name, description.trim()),
370 None => format!("- {}", server.name),
371 })
372 .collect::<Vec<_>>()
373 .join("\n");
374 format!(
375 "# MCP tool discovery\n\nSearches deferred MCP tool metadata with BM25 and activates matching tools for Code Mode. MCP handshakes and tools/list run in the background when the agent starts. Search before using an MCP tool; returned names can be called as `tools[name](arguments)` in the same or a later exec cell.\n\nConfigured sources:\n{sources}"
376 )
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use futures_util::future::join_all;
383 use nanocodex_core::MODEL;
384 use nanocodex_tools::{DEFAULT_TOOL_OUTPUT_TOKENS, ToolOutputBody};
385 use serde_json::value::to_raw_value;
386
387 #[test]
388 fn validates_empty_and_duplicate_servers() {
389 assert!(matches!(Mcp::builder().build(), Err(McpBuildError::Empty)));
390 assert!(matches!(
391 Mcp::builder()
392 .server("docs", McpServer::http("https://example.test/mcp"))
393 .server("docs", McpServer::stdio("node"))
394 .build(),
395 Err(McpBuildError::DuplicateServer(name)) if name == "docs"
396 ));
397 assert!(matches!(
398 Mcp::builder()
399 .server(
400 "local",
401 McpServer::stdio("node").bearer_token("not-applicable")
402 )
403 .build(),
404 Err(McpBuildError::UnsupportedOption {
405 server,
406 option: "bearer_token"
407 }) if server == "local"
408 ));
409 }
410
411 #[test]
412 fn search_definition_describes_background_discovery() {
413 let mcp = Mcp::builder()
414 .server(
415 "docs",
416 McpServer::http("https://example.test/mcp")
417 .description("Search product documentation."),
418 )
419 .build()
420 .unwrap();
421 assert!(
422 mcp.search
423 .definition()
424 .description()
425 .contains("tools/list run in the background")
426 );
427 }
428
429 #[tokio::test]
430 async fn stdio_handshake_search_and_call_share_the_background_client() {
431 let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
432 .join("tests/fixtures/stdio-server.mjs");
433 let mcp = Mcp::builder()
434 .server(
435 "fixture",
436 McpServer::stdio("node").arg(fixture.to_string_lossy()),
437 )
438 .build()
439 .unwrap();
440 mcp.start();
441 let context = ToolContext {
442 model: MODEL,
443 session_id: "test-session",
444 call_id: "search-call",
445 history: &[],
446 output_token_budget: DEFAULT_TOOL_OUTPUT_TOKENS,
447 };
448 let search = mcp
449 .search
450 .execute(
451 ToolInput::Function(to_raw_value(&json!({ "query": "echo message" })).unwrap()),
452 context,
453 )
454 .await
455 .unwrap();
456 assert!(search.success);
457 assert!(matches!(
458 &search.output,
459 ToolOutputBody::Text(output) if output.contains("mcp__fixture__echo")
460 ));
461 assert!(
462 mcp.available_definitions()
463 .iter()
464 .any(|definition| definition.name() == "mcp__fixture__echo")
465 );
466
467 let execution = mcp
468 .execute(
469 "mcp__fixture__echo",
470 json!({ "message": "hello" }),
471 ToolContext {
472 call_id: "tool-call",
473 ..context
474 },
475 )
476 .await
477 .unwrap();
478 assert!(execution.success);
479 assert!(matches!(
480 execution.output,
481 ToolOutputBody::Text(output) if output.contains("fixture:hello")
482 ));
483 }
484
485 #[tokio::test]
486 async fn concurrent_server_startup_and_remote_calls_are_bounded_and_reusable() {
487 const SERVERS: usize = 8;
488 const CALLS: usize = 256;
489
490 let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
491 .join("tests/fixtures/stdio-server.mjs");
492 let mut builder = Mcp::builder();
493 for index in 0..SERVERS {
494 builder = builder.server(
495 format!("fixture_{index}"),
496 McpServer::stdio("node").arg(fixture.to_string_lossy()),
497 );
498 }
499 let mcp = builder.build().unwrap();
500 mcp.start();
501 let context = ToolContext {
502 model: MODEL,
503 session_id: "stress-session",
504 call_id: "stress-call",
505 history: &[],
506 output_token_budget: DEFAULT_TOOL_OUTPUT_TOKENS,
507 };
508 let search = mcp
509 .search
510 .execute(
511 ToolInput::Function(
512 to_raw_value(&json!({ "query": "echo message", "limit": 32 })).unwrap(),
513 ),
514 context,
515 )
516 .await
517 .unwrap();
518 assert!(search.success);
519 let names = mcp
520 .available_definitions()
521 .into_iter()
522 .map(|definition| definition.name().to_owned())
523 .collect::<Vec<_>>();
524 assert_eq!(names.len(), SERVERS);
525
526 let calls = (0..CALLS).map(|index| {
527 mcp.execute(
528 &names[index % names.len()],
529 json!({ "message": index.to_string() }),
530 context,
531 )
532 });
533 let results = join_all(calls).await;
534 assert!(
535 results
536 .into_iter()
537 .all(|result| { result.is_some_and(|execution| execution.success) })
538 );
539 }
540
541 #[tokio::test]
542 #[ignore = "manual repeated-search stress benchmark"]
543 async fn stress_repeated_tool_search() {
544 let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
545 .join("tests/fixtures/stdio-server.mjs");
546 let mcp = Mcp::builder()
547 .server(
548 "fixture",
549 McpServer::stdio("node").arg(fixture.to_string_lossy()),
550 )
551 .build()
552 .unwrap();
553 mcp.start();
554 let context = ToolContext {
555 model: MODEL,
556 session_id: "stress-session",
557 call_id: "stress-search",
558 history: &[],
559 output_token_budget: DEFAULT_TOOL_OUTPUT_TOKENS,
560 };
561 let started = std::time::Instant::now();
562 for _ in 0..10_000 {
563 let result = mcp
564 .search
565 .execute(
566 ToolInput::Function(to_raw_value(&json!({ "query": "echo message" })).unwrap()),
567 context,
568 )
569 .await
570 .unwrap();
571 assert!(result.success);
572 }
573 eprintln!("10k repeated searches: {:?}", started.elapsed());
574 }
575}