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}