Skip to main content

tools_kit/
mcp.rs

1//! MCP client construction and independently owned server lifecycle.
2//!
3//! [`McpClientConfig`](crate::mcp::McpClientConfig) selects stdio or Streamable HTTP plus naming and interaction
4//! policy. Construction is passive; [`crate::Toolset::mcp`] connects. The optional
5//! `mcp-server` feature adds a server whose consuming shutdown settles serving
6//! work while the caller retains separate toolset shutdown authority.
7//!
8//! # Invoke an explicitly configured service
9//!
10//! This example requires a running Streamable HTTP server at the configured URL,
11//! with an `echo` tool accepting `{"text":"hello"}` and returning that text.
12//! MCP invocation arguments must be JSON objects. Supply any
13//! required authentication through the explicit header map. Namespaces affect public
14//! selection; they do not rename the native tool. Never infer a server endpoint from
15//! a Toolset. A request timeout bounds calls, not a whole application scenario.
16//!
17//! ```no_run
18//! use std::time::Duration;
19//! use tools_kit::{Toolset, ToolsetBackend, ToolHandlerBackend, ToolError};
20//! use tools_kit::declaration::ToolName;
21//! use tools_kit::execution::{ToolContent, ToolHandlerInput, ToolHandlerOutput, ToolInvocation};
22//! use tools_kit::mcp::{McpClientConfig, McpConnectionConfig};
23//! # #[tokio::main(flavor = "current_thread")]
24//! # async fn main() -> Result<(), ToolError> {
25//! let config = McpClientConfig::new(McpConnectionConfig::StreamableHttp {
26//!     endpoint: "http://127.0.0.1:8000/mcp".parse().unwrap(),
27//!     headers: Default::default(),
28//! }).with_request_timeout(Duration::from_secs(30))?;
29//! let tools = Toolset::mcp(config).await?;
30//! let work = async {
31//!     let name = ToolName::new("echo")?;
32//!     let inventory = tools.list_tools().await?;
33//!     assert!(inventory.iter().any(|info| info.name == name));
34//!     let mut handler = tools.tool_handler(name).await?;
35//!     let output = handler.step(ToolHandlerInput::Invoke(ToolInvocation {
36//!         call_id: Some("owned-call".into()), arguments: serde_json::json!({"text":"hello"}),
37//!     })).await?;
38//!     let ToolHandlerOutput::Completed(result) = output else {
39//!         return Err(ToolError::Protocol { message: "configured echo unexpectedly interacted".into() });
40//!     };
41//!     assert!(!result.is_error);
42//!     assert!(matches!(result.content.as_slice(), [ToolContent::Text { text, .. }] if text == "hello"));
43//!     Ok::<(), ToolError>(())
44//! }.await;
45//! let cleanup = tools.shutdown().await;
46//! if let Err(error) = &cleanup {
47//!     eprintln!("Cleanup failed: {error}");
48//! }
49//! work?;
50//! cleanup?;
51//! # Ok(())
52//! # }
53//! ```
54//!
55//! With `mcp-server`, the `McpServer` type's complete example starts a separately
56//! owned loopback server, invokes it through a client, then settles the client,
57//! server, and underlying Toolset in that order. Without that feature only client
58//! construction is available. A server does not own the caller's underlying source.
59
60#[cfg(feature = "mcp-server")]
61use std::net::SocketAddr;
62use std::{collections::BTreeMap, fmt, path::PathBuf, time::Duration};
63
64use serde::{Deserialize, Serialize};
65use url::Url;
66
67use crate::{ToolError, name::ToolName};
68
69#[cfg(feature = "mcp-server")]
70pub use crate::sources::mcp::server::McpServer;
71
72/// Connection to one MCP server.
73#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(tag = "transport", rename_all = "snake_case", deny_unknown_fields)]
75pub enum McpConnectionConfig {
76    /// Spawns an MCP server and communicates over standard input/output.
77    Stdio {
78        /// Executable to spawn.
79        program: String,
80        /// Arguments passed to the executable.
81        args: Vec<String>,
82        /// Optional working directory for the child process.
83        cwd: Option<PathBuf>,
84        /// Explicit environment entries supplied to the child process.
85        env: BTreeMap<String, String>,
86    },
87    /// Connects to a Streamable HTTP MCP endpoint.
88    StreamableHttp {
89        /// Absolute MCP endpoint URL.
90        endpoint: Url,
91        /// Request headers, including caller-owned authentication when needed.
92        headers: BTreeMap<String, String>,
93    },
94}
95
96impl fmt::Debug for McpConnectionConfig {
97    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
98        match self {
99            Self::Stdio {
100                program,
101                args,
102                cwd,
103                env,
104            } => formatter
105                .debug_struct("McpConnectionConfig::Stdio")
106                .field("program", program)
107                .field("argument_count", &args.len())
108                .field("has_working_directory", &cwd.is_some())
109                .field("environment_count", &env.len())
110                .finish(),
111            Self::StreamableHttp { endpoint, headers } => formatter
112                .debug_struct("McpConnectionConfig::StreamableHttp")
113                .field("endpoint_scheme", &endpoint.scheme())
114                .field("endpoint_host", &endpoint.host_str())
115                .field("endpoint_port", &endpoint.port_or_known_default())
116                .field("header_count", &headers.len())
117                .finish(),
118        }
119    }
120}
121
122/// Validated MCP client Toolset configuration.
123/// Configuration construction does not connect or spawn a process; [`crate::Toolset::mcp`]
124/// performs that work. Discovered names may be namespaced without exposing native routing.
125///
126/// # Examples
127///
128/// ```
129/// use tools_kit::mcp::{McpClientConfig, McpConnectionConfig};
130/// let connection = McpConnectionConfig::StreamableHttp {
131///     endpoint: "http://127.0.0.1:8000/mcp".parse().unwrap(),
132///     headers: Default::default(),
133/// };
134/// let config = McpClientConfig::new(connection);
135/// let value = serde_json::to_value(&config).unwrap();
136/// assert_eq!(value["request_timeout_secs"], 30);
137/// assert_eq!(value["form_interactions"], false);
138/// ```
139///
140#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct McpClientConfig {
143    connection: McpConnectionConfig,
144    namespace: Option<ToolName>,
145    request_timeout_secs: u64,
146    form_interactions: bool,
147    validate_forms: bool,
148}
149
150impl McpClientConfig {
151    /// Creates a client configuration with a 30-second request timeout.
152    ///
153    /// See [`crate::mcp::McpClientConfig`] for a complete example.
154    ///
155    #[must_use]
156    pub fn new(connection: McpConnectionConfig) -> Self {
157        Self {
158            connection,
159            namespace: None,
160            request_timeout_secs: 30,
161            form_interactions: false,
162            validate_forms: false,
163        }
164    }
165
166    /// Applies a caller-visible namespace to discovered names.
167    ///
168    /// # Errors
169    ///
170    /// Returns [`ToolError::InvalidConfiguration`] if the namespace is not a valid [`ToolName`].
171    ///
172    /// # Examples
173    ///
174    /// ```
175    /// use tools_kit::mcp::{McpClientConfig, McpConnectionConfig};
176    /// let connection = McpConnectionConfig::StreamableHttp {
177    ///     endpoint: "http://127.0.0.1:8000/mcp".parse().unwrap(),
178    ///     headers: Default::default(),
179    /// };
180    /// let config = McpClientConfig::new(connection);
181    /// let config = config.with_namespace("search")?;
182    /// assert_eq!(serde_json::to_value(config).unwrap()["namespace"], "search");
183    ///
184    /// # Ok::<(), tools_kit::ToolError>(())
185    /// ```
186    ///
187    pub fn with_namespace<N>(mut self, namespace: N) -> Result<Self, ToolError>
188    where
189        N: TryInto<ToolName>,
190        N::Error: fmt::Display,
191    {
192        self.namespace =
193            Some(
194                namespace
195                    .try_into()
196                    .map_err(|error| ToolError::InvalidConfiguration {
197                        message: error.to_string(),
198                    })?,
199            );
200        Ok(self)
201    }
202
203    /// Sets the nonzero per-request timeout.
204    ///
205    /// # Errors
206    ///
207    /// Returns [`ToolError::InvalidConfiguration`] for zero. Positive subsecond values become one second; other values use whole seconds.
208    ///
209    /// # Examples
210    ///
211    /// ```
212    /// use tools_kit::mcp::{McpClientConfig, McpConnectionConfig};
213    /// let connection = McpConnectionConfig::StreamableHttp {
214    ///     endpoint: "http://127.0.0.1:8000/mcp".parse().unwrap(),
215    ///     headers: Default::default(),
216    /// };
217    /// let config = McpClientConfig::new(connection);
218    /// assert!(config.clone().with_request_timeout(std::time::Duration::ZERO).is_err());
219    /// let config = config.with_request_timeout(std::time::Duration::from_secs(5))?;
220    /// assert_eq!(serde_json::to_value(config).unwrap()["request_timeout_secs"], 5);
221    ///
222    /// # Ok::<(), tools_kit::ToolError>(())
223    /// ```
224    ///
225    pub fn with_request_timeout(mut self, timeout: Duration) -> Result<Self, ToolError> {
226        if timeout.is_zero() {
227            return Err(ToolError::InvalidConfiguration {
228                message: "MCP request timeout must be greater than zero".to_owned(),
229            });
230        }
231        self.request_timeout_secs = timeout.as_secs().max(1);
232        Ok(self)
233    }
234
235    /// Enables form interactions and selects response schema validation.
236    /// Passing false still enables forms. Replies must remain JSON objects; only schema
237    /// constraints are relaxed.
238    ///
239    /// # Examples
240    ///
241    /// ```
242    /// use tools_kit::mcp::{McpClientConfig, McpConnectionConfig};
243    /// let connection = McpConnectionConfig::StreamableHttp {
244    ///     endpoint: "http://127.0.0.1:8000/mcp".parse().unwrap(),
245    ///     headers: Default::default(),
246    /// };
247    /// let config = McpClientConfig::new(connection);
248    /// let config = config.with_form_validation(false);
249    /// let value = serde_json::to_value(config).unwrap();
250    /// assert_eq!(value["form_interactions"], true);
251    /// assert_eq!(value["validate_forms"], false);
252    /// ```
253    ///
254    #[must_use]
255    pub fn with_form_validation(mut self, enabled: bool) -> Self {
256        self.form_interactions = true;
257        self.validate_forms = enabled;
258        self
259    }
260
261    pub(crate) fn connection(&self) -> &McpConnectionConfig {
262        &self.connection
263    }
264
265    pub(crate) fn namespace(&self) -> Option<&ToolName> {
266        self.namespace.as_ref()
267    }
268
269    pub(crate) fn request_timeout(&self) -> Duration {
270        Duration::from_secs(self.request_timeout_secs)
271    }
272
273    pub(crate) fn validates_forms(&self) -> bool {
274        self.validate_forms
275    }
276
277    pub(crate) fn supports_forms(&self) -> bool {
278        self.form_interactions
279    }
280}
281
282/// Policy for resolving interactions while serving a Toolset over MCP.
283#[cfg(feature = "mcp-server")]
284#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(rename_all = "snake_case")]
286pub enum McpServerInteractionPolicy {
287    /// Use MCP elicitation for form requests when the client advertises support.
288    ElicitFormIfClientSupports,
289    /// Reject an interaction-requesting call with an MCP request error.
290    #[default]
291    RejectWithToolError,
292}
293
294/// Validated independent MCP server configuration.
295/// Available with the `mcp-server` feature. Constructing this value does not bind a socket.
296///
297/// # Examples
298///
299/// ```
300/// use tools_kit::mcp::McpServerConfig;
301/// let config = McpServerConfig::new("127.0.0.1:8000".parse().unwrap(), "/mcp", "page")?;
302/// assert!(serde_json::to_value(&config).unwrap().is_object());
303///
304/// # Ok::<(), tools_kit::ToolError>(())
305/// ```
306///
307#[cfg(feature = "mcp-server")]
308#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
309#[serde(deny_unknown_fields)]
310pub struct McpServerConfig {
311    bind: SocketAddr,
312    endpoint_path: String,
313    cursor_prefix: String,
314    interaction_policy: McpServerInteractionPolicy,
315    shutdown_timeout_secs: u64,
316}
317
318#[cfg(feature = "mcp-server")]
319impl McpServerConfig {
320    /// Creates a validated loopback server configuration.
321    ///
322    /// # Errors
323    ///
324    /// Returns [`ToolError::InvalidConfiguration`] unless the address is loopback with a nonzero port, the endpoint starts with `/` and contains no query or fragment, and the cursor prefix is nonblank.
325    ///
326    /// # Examples
327    ///
328    /// ```
329    /// use tools_kit::mcp::McpServerConfig;
330    /// let config = McpServerConfig::new("127.0.0.1:8000".parse().unwrap(), "/mcp", "page")?;
331    /// let value = serde_json::to_value(&config).unwrap();
332    /// assert_eq!(value["shutdown_timeout_secs"], 5);
333    /// assert_eq!(value["interaction_policy"], "reject_with_tool_error");
334    ///
335    /// # Ok::<(), tools_kit::ToolError>(())
336    /// ```
337    ///
338    pub fn new(
339        bind: SocketAddr,
340        endpoint_path: impl Into<String>,
341        cursor_prefix: impl Into<String>,
342    ) -> Result<Self, ToolError> {
343        let config = Self {
344            bind,
345            endpoint_path: endpoint_path.into(),
346            cursor_prefix: cursor_prefix.into(),
347            interaction_policy: McpServerInteractionPolicy::default(),
348            shutdown_timeout_secs: 5,
349        };
350        config.validate()?;
351        Ok(config)
352    }
353
354    /// Selects how the server handles tool-requested caller interactions.
355    ///
356    /// # Examples
357    ///
358    /// ```
359    /// use tools_kit::mcp::McpServerConfig;
360    /// let config = McpServerConfig::new("127.0.0.1:8000".parse().unwrap(), "/mcp", "page")?;
361    /// let config = config.with_interaction_policy(
362    ///     tools_kit::mcp::McpServerInteractionPolicy::ElicitFormIfClientSupports,
363    /// );
364    /// let value = serde_json::to_value(&config).unwrap();
365    /// assert_eq!(value["shutdown_timeout_secs"], 5);
366    /// assert_eq!(value["interaction_policy"], "elicit_form_if_client_supports");
367    ///
368    /// # Ok::<(), tools_kit::ToolError>(())
369    /// ```
370    ///
371    #[must_use]
372    pub fn with_interaction_policy(mut self, policy: McpServerInteractionPolicy) -> Self {
373        self.interaction_policy = policy;
374        self
375    }
376
377    /// Sets the nonzero deadline for joining server shutdown.
378    ///
379    /// # Errors
380    ///
381    /// Returns [`ToolError::InvalidConfiguration`] for zero. Positive subsecond values become one second; other values use whole seconds. This bounds observation and never discards retained cleanup.
382    ///
383    /// # Examples
384    ///
385    /// ```
386    /// use tools_kit::mcp::McpServerConfig;
387    /// let config = McpServerConfig::new("127.0.0.1:8000".parse().unwrap(), "/mcp", "page")?;
388    /// assert!(config.clone().with_shutdown_timeout(std::time::Duration::ZERO).is_err());
389    /// let config = config.with_shutdown_timeout(std::time::Duration::from_secs(10))?;
390    /// let value = serde_json::to_value(&config).unwrap();
391    /// assert_eq!(value["shutdown_timeout_secs"], 10);
392    /// assert_eq!(value["interaction_policy"], "reject_with_tool_error");
393    ///
394    /// # Ok::<(), tools_kit::ToolError>(())
395    /// ```
396    ///
397    pub fn with_shutdown_timeout(mut self, timeout: Duration) -> Result<Self, ToolError> {
398        if timeout.is_zero() {
399            return Err(ToolError::InvalidConfiguration {
400                message: "MCP server shutdown timeout must be greater than zero".to_owned(),
401            });
402        }
403        self.shutdown_timeout_secs = timeout.as_secs().max(1);
404        Ok(self)
405    }
406
407    pub(crate) fn validate(&self) -> Result<(), ToolError> {
408        if !self.bind.ip().is_loopback() || self.bind.port() == 0 {
409            return Err(ToolError::InvalidConfiguration {
410                message: "MCP server requires a loopback address with a nonzero port".to_owned(),
411            });
412        }
413        if self.endpoint_path.is_empty()
414            || !self.endpoint_path.starts_with('/')
415            || self.endpoint_path.contains('?')
416            || self.endpoint_path.contains('#')
417        {
418            return Err(ToolError::InvalidConfiguration {
419                message: "MCP endpoint path must be absolute and contain no query or fragment"
420                    .to_owned(),
421            });
422        }
423        if self.cursor_prefix.trim().is_empty() {
424            return Err(ToolError::InvalidConfiguration {
425                message: "MCP pagination cursor prefix must not be empty".to_owned(),
426            });
427        }
428        Ok(())
429    }
430
431    pub(crate) fn bind(&self) -> SocketAddr {
432        self.bind
433    }
434
435    pub(crate) fn endpoint_path(&self) -> &str {
436        &self.endpoint_path
437    }
438
439    pub(crate) fn cursor_prefix(&self) -> &str {
440        &self.cursor_prefix
441    }
442
443    pub(crate) fn interaction_policy(&self) -> McpServerInteractionPolicy {
444        self.interaction_policy
445    }
446
447    pub(crate) fn shutdown_timeout(&self) -> Duration {
448        Duration::from_secs(self.shutdown_timeout_secs)
449    }
450}