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}