mcp_discovery/
lib.rs

1//! A lightweight CLI tool for discovering and documenting MCP Server capabilities.
2
3pub mod error;
4mod handler;
5mod render_template;
6mod schema;
7mod std_output;
8mod templates;
9mod types;
10mod utils;
11
12use rust_mcp_sdk::error::McpSdkError;
13use rust_mcp_sdk::mcp_client::McpClientOptions;
14use rust_mcp_sdk::{mcp_icon, ToMcpClientHandler};
15use serde_json::{to_value, Map, Value};
16pub use templates::OutputTemplate;
17pub use types::{
18    DiscoveryCommand, LogLevel, McpCapabilities, McpServerInfo, McpToolMeta, ParamTypes,
19    PrintOptions, Template, WriteOptions,
20};
21
22use crate::types::McpTaskSupport;
23use colored::Colorize;
24use error::{DiscoveryError, DiscoveryResult};
25use handler::MyClientHandler;
26use render_template::{detect_render_markers, render_template};
27use rust_mcp_sdk::schema::{
28    ClientCapabilities, ClientElicitation, ClientRoots, ClientSampling, ClientTaskElicitation,
29    ClientTaskSampling, ClientTasks, Implementation, InitializeRequestParams,
30    PaginatedRequestParams, Prompt, ProtocolVersion, Resource, ResourceTemplate,
31};
32use rust_mcp_sdk::{
33    error::SdkResult,
34    mcp_client::{client_runtime, ClientRuntime},
35    McpClient, StdioTransport, TransportOptions,
36};
37use schema::tool_params;
38use std::io::stdout;
39use std::sync::Arc;
40use std_output::{print_header, print_list, print_summary};
41
42/// Core struct representing the discovery mechanism for the MCP server.
43pub struct McpDiscovery {
44    /// Discovery action and its options
45    options: DiscoveryCommand,
46    /// Collected server capabilities and metadata
47    pub server_info: Option<McpServerInfo>,
48}
49
50impl McpDiscovery {
51    pub fn new(options: DiscoveryCommand) -> Self {
52        Self {
53            options,
54            server_info: None,
55        }
56    }
57
58    /// Entry point to execute the discovery workflow based on the command.
59    pub async fn start(&mut self) -> DiscoveryResult<()> {
60        // launch mcp server and discover capabilities
61
62        self.discover().await?;
63
64        match &self.options {
65            DiscoveryCommand::Create(create_options) => {
66                self.create_document(create_options).await?;
67            }
68            DiscoveryCommand::Update(update_options) => {
69                self.update_document(update_options).await?;
70            }
71            DiscoveryCommand::Print(print_options) => {
72                self.print_server_capabilities(print_options).await?;
73            }
74        };
75        Ok(())
76    }
77
78    /// Prints MCP server capabilities using a specific template or default view.
79    pub async fn print_server_capabilities(
80        &self,
81        print_options: &PrintOptions,
82    ) -> DiscoveryResult<()> {
83        let server_info = self
84            .server_info
85            .as_ref()
86            .ok_or(DiscoveryError::NotDiscovered)?;
87
88        let template = print_options.match_template()?;
89
90        match template {
91            OutputTemplate::None => {
92                self.print_server_details()?;
93            }
94            _ => {
95                let content = template.render_template(server_info)?;
96                println!("{content}");
97            }
98        }
99
100        Ok(())
101    }
102
103    /// Creates a new file using a specified template and discovered server info.
104    pub async fn create_document(&self, create_options: &WriteOptions) -> DiscoveryResult<()> {
105        tracing::trace!("Creating '{}' ", create_options.filename.to_string_lossy());
106
107        let server_info = self
108            .server_info
109            .as_ref()
110            .ok_or(DiscoveryError::NotDiscovered)?;
111
112        let template = create_options.match_template()?;
113
114        let content = template.render_template(server_info)?;
115
116        tokio::fs::write(&create_options.filename, content).await?;
117
118        tracing::info!(
119            "File '{}' was created successfully.",
120            create_options.filename.to_string_lossy(),
121        );
122        tracing::info!(
123            "Full path: {}",
124            create_options
125                .filename
126                .canonicalize()
127                .map(|f| f.to_string_lossy().into_owned())
128                .unwrap_or_else(|_| create_options.filename.to_string_lossy().into_owned())
129        );
130
131        Ok(())
132    }
133
134    /// Updates an existing file by replacing only templated sections.
135    pub async fn update_document(&self, update_options: &WriteOptions) -> DiscoveryResult<()> {
136        tracing::trace!("Updating '{}' ", update_options.filename.to_string_lossy());
137
138        let server_info = self
139            .server_info
140            .as_ref()
141            .ok_or(DiscoveryError::NotDiscovered)?;
142
143        update_options.validate()?;
144
145        let template_markers = detect_render_markers(update_options, server_info)?;
146        let mut content_lines: Vec<String> = template_markers
147            .content
148            .lines()
149            .map(|s| s.to_owned())
150            .collect();
151
152        for location in template_markers.render_locations.iter().rev() {
153            let new_lines: Vec<String> = location
154                .rendered_template
155                .lines()
156                .map(|s| s.to_owned())
157                .collect();
158
159            content_lines.splice(
160                location.render_location.0..location.render_location.1 - 1,
161                new_lines,
162            );
163        }
164
165        let updated_content = content_lines.join(&template_markers.line_ending);
166
167        std::fs::write(&update_options.filename, updated_content)?;
168        tracing::info!(
169            "File '{}' was updated successfully.",
170            update_options.filename.to_string_lossy()
171        );
172        Ok(())
173    }
174
175    /// Print a brief summary of the discovered server information.
176    fn print_summary(&self) -> DiscoveryResult<usize> {
177        let server_info = self
178            .server_info
179            .as_ref()
180            .ok_or(DiscoveryError::NotDiscovered)?;
181        Ok(print_summary(&mut stdout(), server_info)?)
182    }
183
184    /// Prints summary and then detailed info about tools, prompts, resources, and templates from server.
185    fn print_server_details(&self) -> DiscoveryResult<()> {
186        let table_size = self.print_summary()?;
187
188        let server_info = self
189            .server_info
190            .as_ref()
191            .ok_or(DiscoveryError::NotDiscovered)?;
192
193        if let Some(tools) = &server_info.tools {
194            if !tools.is_empty() {
195                print_header(
196                    &mut stdout(),
197                    &format!("{}({})", "Tools".bold(), tools.len()),
198                    table_size,
199                )?;
200                let mut tool_list: Vec<_> = tools
201                    .iter()
202                    .map(|item| {
203                        (
204                            item.name.clone(),
205                            item.description.clone().unwrap_or_default(),
206                        )
207                    })
208                    .collect();
209                tool_list.sort_by(|a, b| a.0.cmp(&b.0));
210                print_list(stdout(), tool_list)?;
211            }
212        }
213
214        if let Some(prompts) = &server_info.prompts {
215            if !prompts.is_empty() {
216                print_header(
217                    &mut stdout(),
218                    &format!("{}({})", "Prompts".bold(), prompts.len()),
219                    table_size,
220                )?;
221                print_list(
222                    stdout(),
223                    prompts
224                        .iter()
225                        .map(|item| {
226                            (
227                                item.name.clone(),
228                                item.description.clone().unwrap_or_default(),
229                            )
230                        })
231                        .collect(),
232                )?;
233            }
234        }
235
236        if let Some(resources) = &server_info.resources {
237            if !resources.is_empty() {
238                print_header(
239                    &mut stdout(),
240                    &format!("{}({})", "Resources".bold(), resources.len()),
241                    table_size,
242                )?;
243                print_list(
244                    stdout(),
245                    resources
246                        .iter()
247                        .map(|item| {
248                            (
249                                item.name.clone(),
250                                format!(
251                                    "{}{}{}",
252                                    item.uri,
253                                    item.mime_type
254                                        .as_ref()
255                                        .map_or("".to_string(), |mime_type| format!(
256                                            " ({mime_type})"
257                                        ))
258                                        .dimmed(),
259                                    item.description.as_ref().map_or(
260                                        "".to_string(),
261                                        |description| format!("\n{}", description.dimmed())
262                                    )
263                                ),
264                            )
265                        })
266                        .collect(),
267                )?;
268            }
269        }
270
271        if let Some(resource_templates) = &server_info.resource_templates {
272            if !resource_templates.is_empty() {
273                print_header(
274                    &mut stdout(),
275                    &format!(
276                        "{}({})",
277                        "Resource Templates".bold(),
278                        resource_templates.len()
279                    ),
280                    table_size,
281                )?;
282                print_list(
283                    stdout(),
284                    resource_templates
285                        .iter()
286                        .map(|item| {
287                            (
288                                item.name.clone(),
289                                format!(
290                                    "{}{}{}",
291                                    item.uri_template,
292                                    item.mime_type
293                                        .as_ref()
294                                        .map_or("".to_string(), |mime_type| format!(
295                                            " ({mime_type})"
296                                        ))
297                                        .dimmed(),
298                                    item.description.as_ref().map_or(
299                                        "".to_string(),
300                                        |description| format!("\n{}", description.dimmed())
301                                    )
302                                ),
303                            )
304                        })
305                        .collect(),
306                )?;
307            }
308        }
309
310        Ok(())
311    }
312
313    /// Retrieves tools metadata from the MCP server.
314    pub async fn tools(
315        &self,
316        client: Arc<ClientRuntime>,
317    ) -> DiscoveryResult<Option<Vec<McpToolMeta>>> {
318        if !client.server_has_tools().unwrap_or(false) {
319            return Ok(None);
320        }
321
322        tracing::trace!("retrieving tools...");
323
324        let tools_result = client
325            .request_tool_list(Some(PaginatedRequestParams::default()))
326            .await?
327            .tools;
328
329        let mut tools: Vec<_> = tools_result
330            .iter()
331            .map(|tool| {
332                let root_schema: serde_json::Value =
333                    to_value(&tool.input_schema).unwrap_or_else(|_| Value::Object(Map::new()));
334                let params = tool_params(&tool.input_schema.properties, &root_schema);
335
336                Ok::<McpToolMeta, DiscoveryError>(McpToolMeta {
337                    name: tool.name.to_owned(),
338                    title: tool.title.to_owned(),
339                    icons: tool.icons.to_owned(),
340                    execution: tool.execution.to_owned(),
341                    annotations: tool.annotations.to_owned(),
342                    description: tool.description.to_owned(),
343                    params,
344                    input_schema: tool.input_schema.clone(),
345                    meta: tool.meta.to_owned(),
346                })
347            })
348            .filter_map(Result::ok)
349            .collect();
350        tools.sort_by(|a, b| a.name.cmp(&b.name));
351        Ok(Some(tools))
352    }
353
354    async fn prompts(&self, client: Arc<ClientRuntime>) -> DiscoveryResult<Option<Vec<Prompt>>> {
355        if !client.server_has_prompts().unwrap_or(false) {
356            return Ok(None);
357        }
358        tracing::trace!("retrieving prompts...");
359
360        let prompts: Vec<Prompt> = client
361            .request_prompt_list(Some(PaginatedRequestParams::default()))
362            .await?
363            .prompts;
364
365        Ok(Some(prompts))
366    }
367
368    /// Retrieves resources from the server.
369    async fn resources(
370        &self,
371        client: Arc<ClientRuntime>,
372    ) -> DiscoveryResult<Option<Vec<Resource>>> {
373        if !client.server_has_resources().unwrap_or(false) {
374            return Ok(None);
375        }
376
377        tracing::trace!("retrieving resources...");
378
379        let resources: Vec<Resource> = client
380            .request_resource_list(Some(PaginatedRequestParams::default()))
381            .await?
382            .resources;
383
384        Ok(Some(resources))
385    }
386
387    /// Retrieves resource templates from the server.
388    async fn resource_templates(
389        &self,
390        client: Arc<ClientRuntime>,
391    ) -> DiscoveryResult<Option<Vec<ResourceTemplate>>> {
392        if !client.server_has_resources().unwrap_or(false) {
393            return Ok(None);
394        }
395
396        tracing::trace!("retrieving resource templates...");
397
398        let result = client
399            .request_resource_template_list(Some(PaginatedRequestParams::default()))
400            .await;
401        match result {
402            Ok(data) => Ok(Some(data.resource_templates)),
403            Err(err) => {
404                tracing::trace!("Unable to retrieve resource templates : {}", err);
405                Ok(None)
406            }
407        }
408    }
409
410    /// Discovers all MCP server capabilities and stores them internally.
411    pub async fn discover(&mut self) -> DiscoveryResult<&McpServerInfo> {
412        let client = self.try_launch_mcp_server().await?;
413
414        let server_version = client
415            .server_version()
416            .ok_or(DiscoveryError::ServerNotInitialized)?;
417
418        tracing::trace!(
419            "Server: {} v{}",
420            server_version.name,
421            server_version.version,
422        );
423
424        let capabilities: McpCapabilities = McpCapabilities {
425            tools: client
426                .server_has_tools()
427                .ok_or(DiscoveryError::ServerNotInitialized)?,
428            prompts: client
429                .server_has_prompts()
430                .ok_or(DiscoveryError::ServerNotInitialized)?,
431            resources: client
432                .server_has_resources()
433                .ok_or(DiscoveryError::ServerNotInitialized)?,
434            logging: client
435                .server_supports_logging()
436                .ok_or(DiscoveryError::ServerNotInitialized)?,
437            completions: client
438                .server_info()
439                .ok_or(DiscoveryError::ServerNotInitialized)?
440                .capabilities
441                .completions
442                .is_some(),
443            experimental: client
444                .server_has_experimental()
445                .ok_or(DiscoveryError::ServerNotInitialized)?,
446            task: McpTaskSupport {
447                tool_call_task: client
448                    .server_capabilities()
449                    .ok_or(DiscoveryError::ServerNotInitialized)?
450                    .can_run_task_augmented_tools(),
451                list_task: client
452                    .server_capabilities()
453                    .ok_or(DiscoveryError::ServerNotInitialized)?
454                    .can_list_tasks(),
455                cancel_task: client
456                    .server_capabilities()
457                    .ok_or(DiscoveryError::ServerNotInitialized)?
458                    .can_cancel_tasks(),
459            },
460        };
461
462        tracing::trace!("Capabilities: {}", capabilities);
463
464        let tools = self.tools(Arc::clone(&client)).await?;
465        let prompts = self.prompts(Arc::clone(&client)).await?;
466        let resources = self.resources(Arc::clone(&client)).await?;
467        let resource_templates = self.resource_templates(Arc::clone(&client)).await?;
468
469        let server_info = McpServerInfo {
470            name: server_version.name,
471            version: server_version.version,
472            title: server_version.title,
473            description: server_version.description,
474            website_url: server_version.website_url,
475            capabilities,
476            tools,
477            prompts,
478            resources,
479            resource_templates,
480        };
481
482        self.server_info = Some(server_info);
483
484        Ok(self.server_info.as_ref().unwrap())
485    }
486
487    // Attempt server launch with multiple protocol versions when the latest protocol is not supported.
488    async fn try_launch_mcp_server(&self) -> SdkResult<Arc<ClientRuntime>> {
489        let protocol_versions = [
490            ProtocolVersion::V2025_11_25,
491            ProtocolVersion::V2025_06_18,
492            ProtocolVersion::V2025_03_26,
493        ];
494        for version in protocol_versions {
495            let current_version = format!("with protocol version: {}", version.to_string().bold(),);
496            println!("{}", current_version.bright_green());
497
498            match self.launch_mcp_server(version).await {
499                Ok(client) => return Ok(client),
500                Err(McpSdkError::Protocol { kind: _ }) => {}
501                Err(err) => return Err(err),
502            }
503        }
504        Err(McpSdkError::Internal {
505            description: "Failed to launch the server.".into(),
506        })
507    }
508
509    /// Launches the MCP server as a subprocess and initializes the client.
510    async fn launch_mcp_server(
511        &self,
512        protocol_version: ProtocolVersion,
513    ) -> SdkResult<Arc<ClientRuntime>> {
514        let client_details: InitializeRequestParams = InitializeRequestParams {
515            capabilities: ClientCapabilities{
516                elicitation: Some(ClientElicitation{ form: Some(Map::new()), url: Some(Map::new()) }),
517                experimental: None,
518                roots: Some(ClientRoots{ list_changed:Some(true) }),
519                sampling: Some(ClientSampling{ context: Some(Map::new()), tools: Some(Map::new()) }),
520                tasks: Some(ClientTasks{ cancel: Some(Map::new()), list:Some(Map::new()), requests: Some(rust_mcp_sdk::schema::ClientTaskRequest { elicitation: Some(ClientTaskElicitation { create: Some(Map::new()) }), sampling:Some(ClientTaskSampling { create_message: Some(Map::new()) }) }) })
521            },
522            client_info: Implementation {
523                title: Some("MCP Discovery - By Rust MCP Stack".to_string()),
524                name: env!("CARGO_PKG_NAME").to_string(),
525                version: env!("CARGO_PKG_VERSION").to_string(),
526                description: Some("A lightweight CLI tool built in Rust for discovering and documenting MCP server capabilities.".into()),
527                icons: vec![
528                    mcp_icon!(
529                        src = "https://rust-mcp-stack.github.io/mcp-discovery/_media/mcp-discovery.png",
530                        mime_type = "image/png",
531                        sizes = ["110x110"]
532                    ),
533                ],
534                website_url: Some("https://rust-mcp-stack.github.io/mcp-discovery".into())
535            },
536            protocol_version: protocol_version.into(),
537            meta: None
538        };
539
540        tracing::trace!(
541            "Client : {} v{}",
542            client_details.client_info.name,
543            client_details.client_info.version
544        );
545
546        let (mcp_command, mcp_args) = self.options.mcp_launch_command().split_at(1);
547
548        tracing::trace!(
549            "launching command : {} {}",
550            mcp_command.first().map(String::as_ref).unwrap_or(""),
551            mcp_args.join(" ")
552        );
553
554        let transport = StdioTransport::create_with_server_launch(
555            mcp_command.first().unwrap(),
556            mcp_args.into(),
557            None,
558            TransportOptions::default(),
559        )?;
560
561        let handler = MyClientHandler {};
562
563        let client = client_runtime::create_client(McpClientOptions {
564            client_details,
565            transport,
566            handler: handler.to_mcp_client_handler(),
567            task_store: None,
568            server_task_store: None,
569        });
570
571        tracing::trace!("Launching MCP server ...");
572
573        client.clone().start().await?;
574
575        tracing::trace!("MCP server started successfully.");
576
577        Ok(client)
578    }
579}