mcp_execution_introspector/lib.rs
1//! MCP server introspection using rmcp official SDK.
2//!
3//! This crate provides functionality to discover MCP server capabilities, tools,
4//! resources, and prompts using the official rmcp SDK. It enables automatic
5//! extraction of tool schemas for code generation.
6//!
7//! # Architecture
8//!
9//! The introspector connects to MCP servers via stdio transport and uses rmcp's
10//! `ServiceExt` trait to query server capabilities. Discovered information is
11//! stored locally for subsequent code generation phases.
12//!
13//! # Examples
14//!
15//! ```no_run
16//! use mcp_execution_introspector::Introspector;
17//! use mcp_execution_core::{ServerId, ServerConfig};
18//!
19//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
20//! let mut introspector = Introspector::new();
21//!
22//! // Connect to github server
23//! let server_id = ServerId::new("github");
24//! let config = ServerConfig::builder()
25//! .command("github-server".to_string())
26//! .build();
27//!
28//! let info = introspector
29//! .discover_server(server_id, &config)
30//! .await?;
31//!
32//! println!("Server: {} v{}", info.name, info.version);
33//! println!("Tools found: {}", info.tools.len());
34//!
35//! for tool in &info.tools {
36//! println!(" - {}: {}", tool.name, tool.description);
37//! }
38//! # Ok(())
39//! # }
40//! ```
41
42#![deny(unsafe_code)]
43#![warn(missing_docs, missing_debug_implementations)]
44
45use mcp_execution_core::{Error, Result, ServerConfig, ServerId, ToolName, validate_server_config};
46use rmcp::ServiceExt;
47use rmcp::transport::{ConfigureCommandExt, TokioChildProcess};
48use serde::{Deserialize, Serialize};
49use std::collections::HashMap;
50
51/// Information about an MCP server.
52///
53/// Contains metadata about the server including its name, version,
54/// available tools, and supported capabilities.
55///
56/// # Examples
57///
58/// ```
59/// use mcp_execution_introspector::{ServerInfo, ServerCapabilities};
60/// use mcp_execution_core::ServerId;
61///
62/// let info = ServerInfo {
63/// id: ServerId::new("example"),
64/// name: "Example Server".to_string(),
65/// version: "1.0.0".to_string(),
66/// tools: vec![],
67/// capabilities: ServerCapabilities {
68/// supports_tools: true,
69/// supports_resources: false,
70/// supports_prompts: false,
71/// },
72/// };
73///
74/// assert_eq!(info.name, "Example Server");
75/// ```
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ServerInfo {
78 /// Unique server identifier
79 pub id: ServerId,
80 /// Human-readable server name
81 pub name: String,
82 /// Server version string
83 pub version: String,
84 /// List of available tools
85 pub tools: Vec<ToolInfo>,
86 /// Server capabilities
87 pub capabilities: ServerCapabilities,
88}
89
90/// Information about an MCP tool.
91///
92/// Contains the tool's name, description, and JSON schema for input validation.
93///
94/// # Examples
95///
96/// ```
97/// use mcp_execution_introspector::ToolInfo;
98/// use mcp_execution_core::ToolName;
99/// use serde_json::json;
100///
101/// let tool = ToolInfo {
102/// name: ToolName::new("send_message"),
103/// description: "Sends a message to a chat".to_string(),
104/// input_schema: json!({
105/// "type": "object",
106/// "properties": {
107/// "chat_id": {"type": "string"},
108/// "text": {"type": "string"}
109/// },
110/// "required": ["chat_id", "text"]
111/// }),
112/// output_schema: None,
113/// };
114///
115/// assert_eq!(tool.name.as_str(), "send_message");
116/// ```
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct ToolInfo {
119 /// Tool name
120 pub name: ToolName,
121 /// Human-readable description of what the tool does
122 pub description: String,
123 /// JSON Schema for tool input parameters
124 pub input_schema: serde_json::Value,
125 /// Optional JSON Schema for tool output (if provided by server)
126 pub output_schema: Option<serde_json::Value>,
127}
128
129/// Server capabilities.
130///
131/// Indicates which MCP features the server supports.
132///
133/// # Examples
134///
135/// ```
136/// use mcp_execution_introspector::ServerCapabilities;
137///
138/// let caps = ServerCapabilities {
139/// supports_tools: true,
140/// supports_resources: true,
141/// supports_prompts: false,
142/// };
143///
144/// assert!(caps.supports_tools);
145/// assert!(!caps.supports_prompts);
146/// ```
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct ServerCapabilities {
149 /// Server supports tool execution
150 pub supports_tools: bool,
151 /// Server supports resource access
152 pub supports_resources: bool,
153 /// Server supports prompts
154 pub supports_prompts: bool,
155}
156
157/// MCP server introspector.
158///
159/// Discovers and caches information about MCP servers using the official
160/// rmcp SDK. Multiple servers can be discovered and their information
161/// retrieved later for code generation.
162///
163/// # Thread Safety
164///
165/// This type is `Send` and `Sync`, allowing it to be used across thread
166/// boundaries safely.
167///
168/// # Examples
169///
170/// ```no_run
171/// use mcp_execution_introspector::Introspector;
172/// use mcp_execution_core::{ServerId, ServerConfig};
173///
174/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
175/// let mut introspector = Introspector::new();
176///
177/// // Discover multiple servers
178/// let server1 = ServerId::new("server1");
179/// let config1 = ServerConfig::builder()
180/// .command("server1-cmd".to_string())
181/// .build();
182/// introspector.discover_server(server1.clone(), &config1).await?;
183///
184/// let server2 = ServerId::new("server2");
185/// let config2 = ServerConfig::builder()
186/// .command("server2-cmd".to_string())
187/// .build();
188/// introspector.discover_server(server2.clone(), &config2).await?;
189///
190/// // Retrieve information
191/// if let Some(info) = introspector.get_server(&server1) {
192/// println!("Server 1 has {} tools", info.tools.len());
193/// }
194///
195/// // List all servers
196/// let all_servers = introspector.list_servers();
197/// println!("Total servers discovered: {}", all_servers.len());
198/// # Ok(())
199/// # }
200/// ```
201#[derive(Debug)]
202pub struct Introspector {
203 servers: HashMap<ServerId, ServerInfo>,
204}
205
206impl Introspector {
207 /// Creates a new introspector.
208 ///
209 /// # Examples
210 ///
211 /// ```
212 /// use mcp_execution_introspector::Introspector;
213 ///
214 /// let introspector = Introspector::new();
215 /// assert_eq!(introspector.list_servers().len(), 0);
216 /// ```
217 #[must_use]
218 pub fn new() -> Self {
219 Self {
220 servers: HashMap::new(),
221 }
222 }
223
224 /// Connects to an MCP server via stdio and discovers its capabilities.
225 ///
226 /// This method:
227 /// 1. Validates the server configuration for security
228 /// 2. Spawns the server process using stdio transport
229 /// 3. Connects via rmcp client
230 /// 4. Queries server information using `ServiceExt::list_all_tools`
231 /// 5. Extracts tools and capabilities
232 /// 6. Caches the information for later retrieval
233 ///
234 /// # Errors
235 ///
236 /// Returns error if:
237 /// - Server configuration contains security violations
238 /// - The server process cannot be spawned
239 /// - Connection to the server fails
240 /// - Server does not respond to capability queries
241 /// - Server response is malformed
242 ///
243 /// # Examples
244 ///
245 /// ```no_run
246 /// use mcp_execution_introspector::Introspector;
247 /// use mcp_execution_core::{ServerId, ServerConfig};
248 ///
249 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
250 /// let mut introspector = Introspector::new();
251 /// let server_id = ServerId::new("github");
252 /// let config = ServerConfig::builder()
253 /// .command("github-server".to_string())
254 /// .build();
255 ///
256 /// let info = introspector
257 /// .discover_server(server_id, &config)
258 /// .await?;
259 ///
260 /// println!("Found {} tools", info.tools.len());
261 /// # Ok(())
262 /// # }
263 /// ```
264 pub async fn discover_server(
265 &mut self,
266 server_id: ServerId,
267 config: &ServerConfig,
268 ) -> Result<ServerInfo> {
269 tracing::info!("Discovering MCP server: {}", server_id);
270
271 // Validate server config for security (prevents command injection)
272 validate_server_config(config)?;
273
274 // Connect via stdio using rmcp with full configuration
275 let transport = TokioChildProcess::new(
276 tokio::process::Command::new(&config.command).configure(|cmd| {
277 cmd.args(&config.args);
278 cmd.envs(&config.env);
279 if let Some(cwd) = &config.cwd {
280 cmd.current_dir(cwd);
281 }
282 }),
283 )
284 .map_err(|e| Error::ConnectionFailed {
285 server: server_id.to_string(),
286 source: Box::new(e),
287 })?;
288
289 // Create client using serve pattern
290 let client =
291 ().serve(transport)
292 .await
293 .map_err(|e| Error::ConnectionFailed {
294 server: server_id.to_string(),
295 source: Box::new(e),
296 })?;
297
298 // List all tools from server
299 let tool_list = client
300 .list_all_tools()
301 .await
302 .map_err(|e| Error::ConnectionFailed {
303 server: server_id.to_string(),
304 source: Box::new(e),
305 })?;
306
307 tracing::debug!(
308 "Server {} responded with {} tools",
309 server_id,
310 tool_list.len()
311 );
312
313 // Extract tools
314 let tools = tool_list
315 .into_iter()
316 .map(|tool| {
317 tracing::trace!("Found tool: {}", tool.name);
318 ToolInfo {
319 name: ToolName::new(tool.name),
320 description: tool.description.unwrap_or_default().to_string(),
321 input_schema: serde_json::Value::Object((*tool.input_schema).clone()),
322 output_schema: None, // rmcp doesn't provide output schema
323 }
324 })
325 .collect::<Vec<_>>();
326
327 // Try to get resources capability
328 let has_resources = client.list_all_resources().await.is_ok();
329
330 let capabilities = ServerCapabilities {
331 supports_tools: !tools.is_empty(),
332 supports_resources: has_resources,
333 supports_prompts: false, // Would need to check prompts similarly
334 };
335
336 let info = ServerInfo {
337 id: server_id.clone(),
338 name: config.command.clone(), // Use command as name
339 version: "unknown".to_string(), // MCP doesn't expose version via ServiceExt
340 tools,
341 capabilities,
342 };
343
344 self.servers.insert(server_id, info.clone());
345
346 tracing::info!("Successfully discovered {} tools", info.tools.len());
347
348 Ok(info)
349 }
350
351 /// Gets information about a previously discovered server.
352 ///
353 /// Returns `None` if the server has not been discovered yet.
354 ///
355 /// # Examples
356 ///
357 /// ```no_run
358 /// use mcp_execution_introspector::Introspector;
359 /// use mcp_execution_core::{ServerId, ServerConfig};
360 ///
361 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
362 /// let mut introspector = Introspector::new();
363 /// let server_id = ServerId::new("test");
364 ///
365 /// // Not discovered yet
366 /// assert!(introspector.get_server(&server_id).is_none());
367 ///
368 /// // Discover it
369 /// let config = ServerConfig::builder()
370 /// .command("test-cmd".to_string())
371 /// .build();
372 /// introspector.discover_server(server_id.clone(), &config).await?;
373 ///
374 /// // Now available
375 /// assert!(introspector.get_server(&server_id).is_some());
376 /// # Ok(())
377 /// # }
378 /// ```
379 #[must_use]
380 pub fn get_server(&self, server_id: &ServerId) -> Option<&ServerInfo> {
381 self.servers.get(server_id)
382 }
383
384 /// Lists all discovered servers.
385 ///
386 /// Returns a vector of references to server information in no
387 /// particular order.
388 ///
389 /// # Examples
390 ///
391 /// ```
392 /// use mcp_execution_introspector::Introspector;
393 ///
394 /// let introspector = Introspector::new();
395 /// let servers = introspector.list_servers();
396 /// assert_eq!(servers.len(), 0);
397 /// ```
398 #[must_use]
399 pub fn list_servers(&self) -> Vec<&ServerInfo> {
400 self.servers.values().collect()
401 }
402
403 /// Returns the number of discovered servers.
404 ///
405 /// # Examples
406 ///
407 /// ```
408 /// use mcp_execution_introspector::Introspector;
409 ///
410 /// let introspector = Introspector::new();
411 /// assert_eq!(introspector.server_count(), 0);
412 /// ```
413 #[must_use]
414 pub fn server_count(&self) -> usize {
415 self.servers.len()
416 }
417
418 /// Removes a server from the cache.
419 ///
420 /// Returns `true` if the server was present and removed.
421 ///
422 /// # Examples
423 ///
424 /// ```no_run
425 /// use mcp_execution_introspector::Introspector;
426 /// use mcp_execution_core::{ServerId, ServerConfig};
427 ///
428 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
429 /// let mut introspector = Introspector::new();
430 /// let server_id = ServerId::new("test");
431 /// let config = ServerConfig::builder()
432 /// .command("test-cmd".to_string())
433 /// .build();
434 ///
435 /// introspector.discover_server(server_id.clone(), &config).await?;
436 /// assert_eq!(introspector.server_count(), 1);
437 ///
438 /// let removed = introspector.remove_server(&server_id);
439 /// assert!(removed);
440 /// assert_eq!(introspector.server_count(), 0);
441 /// # Ok(())
442 /// # }
443 /// ```
444 pub fn remove_server(&mut self, server_id: &ServerId) -> bool {
445 self.servers.remove(server_id).is_some()
446 }
447
448 /// Clears all discovered servers from the cache.
449 ///
450 /// # Examples
451 ///
452 /// ```no_run
453 /// use mcp_execution_introspector::Introspector;
454 /// use mcp_execution_core::{ServerId, ServerConfig};
455 ///
456 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
457 /// let mut introspector = Introspector::new();
458 ///
459 /// let config1 = ServerConfig::builder().command("cmd1".to_string()).build();
460 /// let config2 = ServerConfig::builder().command("cmd2".to_string()).build();
461 ///
462 /// introspector.discover_server(ServerId::new("s1"), &config1).await?;
463 /// introspector.discover_server(ServerId::new("s2"), &config2).await?;
464 /// assert_eq!(introspector.server_count(), 2);
465 ///
466 /// introspector.clear();
467 /// assert_eq!(introspector.server_count(), 0);
468 /// # Ok(())
469 /// # }
470 /// ```
471 pub fn clear(&mut self) {
472 self.servers.clear();
473 }
474}
475
476impl Default for Introspector {
477 fn default() -> Self {
478 Self::new()
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485
486 #[test]
487 fn test_introspector_new() {
488 let introspector = Introspector::new();
489 assert_eq!(introspector.list_servers().len(), 0);
490 assert_eq!(introspector.server_count(), 0);
491 }
492
493 #[test]
494 fn test_introspector_default() {
495 let introspector = Introspector::default();
496 assert_eq!(introspector.server_count(), 0);
497 }
498
499 #[test]
500 fn test_server_info_debug() {
501 let info = ServerInfo {
502 id: ServerId::new("test"),
503 name: "Test Server".to_string(),
504 version: "1.0.0".to_string(),
505 tools: vec![],
506 capabilities: ServerCapabilities {
507 supports_tools: true,
508 supports_resources: false,
509 supports_prompts: false,
510 },
511 };
512 let debug_str = format!("{info:?}");
513 assert!(debug_str.contains("Test Server"));
514 assert!(debug_str.contains("1.0.0"));
515 }
516
517 #[test]
518 fn test_tool_info_creation() {
519 let tool = ToolInfo {
520 name: ToolName::new("test_tool"),
521 description: "A test tool".to_string(),
522 input_schema: serde_json::json!({"type": "object"}),
523 output_schema: None,
524 };
525
526 assert_eq!(tool.name.as_str(), "test_tool");
527 assert_eq!(tool.description, "A test tool");
528 assert!(tool.output_schema.is_none());
529 }
530
531 #[test]
532 fn test_server_capabilities() {
533 let caps = ServerCapabilities {
534 supports_tools: true,
535 supports_resources: true,
536 supports_prompts: false,
537 };
538
539 assert!(caps.supports_tools);
540 assert!(caps.supports_resources);
541 assert!(!caps.supports_prompts);
542 }
543
544 #[test]
545 fn test_get_server_not_found() {
546 let introspector = Introspector::new();
547 let server_id = ServerId::new("nonexistent");
548 assert!(introspector.get_server(&server_id).is_none());
549 }
550
551 #[test]
552 fn test_clear() {
553 let mut introspector = Introspector::new();
554
555 // Add some fake server data
556 let info = ServerInfo {
557 id: ServerId::new("test"),
558 name: "Test".to_string(),
559 version: "1.0.0".to_string(),
560 tools: vec![],
561 capabilities: ServerCapabilities {
562 supports_tools: true,
563 supports_resources: false,
564 supports_prompts: false,
565 },
566 };
567
568 introspector.servers.insert(ServerId::new("test"), info);
569 assert_eq!(introspector.server_count(), 1);
570
571 introspector.clear();
572 assert_eq!(introspector.server_count(), 0);
573 }
574
575 #[test]
576 fn test_remove_server() {
577 let mut introspector = Introspector::new();
578 let server_id = ServerId::new("test");
579
580 // Add fake server data
581 let info = ServerInfo {
582 id: server_id.clone(),
583 name: "Test".to_string(),
584 version: "1.0.0".to_string(),
585 tools: vec![],
586 capabilities: ServerCapabilities {
587 supports_tools: true,
588 supports_resources: false,
589 supports_prompts: false,
590 },
591 };
592
593 introspector.servers.insert(server_id.clone(), info);
594 assert_eq!(introspector.server_count(), 1);
595
596 // Remove existing server
597 assert!(introspector.remove_server(&server_id));
598 assert_eq!(introspector.server_count(), 0);
599
600 // Remove non-existent server
601 assert!(!introspector.remove_server(&server_id));
602 }
603
604 #[test]
605 fn test_list_servers() {
606 let mut introspector = Introspector::new();
607
608 // Empty list
609 assert_eq!(introspector.list_servers().len(), 0);
610
611 // Add servers
612 let info1 = ServerInfo {
613 id: ServerId::new("server1"),
614 name: "Server 1".to_string(),
615 version: "1.0.0".to_string(),
616 tools: vec![],
617 capabilities: ServerCapabilities {
618 supports_tools: true,
619 supports_resources: false,
620 supports_prompts: false,
621 },
622 };
623
624 let info2 = ServerInfo {
625 id: ServerId::new("server2"),
626 name: "Server 2".to_string(),
627 version: "2.0.0".to_string(),
628 tools: vec![],
629 capabilities: ServerCapabilities {
630 supports_tools: false,
631 supports_resources: true,
632 supports_prompts: false,
633 },
634 };
635
636 introspector.servers.insert(ServerId::new("server1"), info1);
637 introspector.servers.insert(ServerId::new("server2"), info2);
638
639 let servers = introspector.list_servers();
640 assert_eq!(servers.len(), 2);
641 }
642
643 #[test]
644 fn test_serialization() {
645 let tool = ToolInfo {
646 name: ToolName::new("test_tool"),
647 description: "Test".to_string(),
648 input_schema: serde_json::json!({"type": "object"}),
649 output_schema: Some(serde_json::json!({"type": "string"})),
650 };
651
652 // Serialize to JSON
653 let json = serde_json::to_string(&tool).unwrap();
654 assert!(json.contains("test_tool"));
655 assert!(json.contains("Test"));
656
657 // Deserialize back
658 let tool2: ToolInfo = serde_json::from_str(&json).unwrap();
659 assert_eq!(tool2.name.as_str(), "test_tool");
660 assert_eq!(tool2.description, "Test");
661 }
662}