Skip to main content

nanocodex_mcp/
lib.rs

1//! Background-handshaken MCP tools for Nanocodex Code Mode.
2
3mod 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
31/// A configured family of MCP servers installed into [`nanocodex_tools::Tools`].
32pub 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/// Builder for an MCP provider.
45#[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    /// Adds a named stdio or Streamable HTTP MCP server.
79    #[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    /// Validates configuration without connecting; handshakes begin with the agent driver.
94    ///
95    /// # Errors
96    ///
97    /// Returns an error when no servers are configured, a name is empty or
98    /// duplicated, a required transport field is empty, or a timeout is zero.
99    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}