diff --git a/Cargo.toml b/Cargo.toml index 0da1a8f..183bb11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,13 @@ rust-version = "1.90.0" authors = ["Roman Emreis "] repository = "https://github.com/RomanEmreis/neva" documentation = "https://docs.rs/neva" +keywords = ["modelcontextprotocol", "mcp", "sdk", "ai", "llm"] +categories = [ + "network-programming", + "asynchronous", + "api-bindings", + "web-programming" +] [workspace.dependencies] neva_macros = { path = "neva_macros", version = "0.3.0" } diff --git a/examples/elicitation/server/src/main.rs b/examples/elicitation/server/src/main.rs index 7cd88af..55df5ce 100644 --- a/examples/elicitation/server/src/main.rs +++ b/examples/elicitation/server/src/main.rs @@ -42,6 +42,8 @@ fn format_contact(c: Contact) -> String { #[tokio::main] async fn main() { App::new() - .with_options(|opt| opt.with_stdio()) + .with_options(|opt| opt + .with_name("Elicitation Example Server") + .with_stdio()) .run().await; } diff --git a/examples/http/src/main.rs b/examples/http/src/main.rs index 87f55c1..b34a932 100644 --- a/examples/http/src/main.rs +++ b/examples/http/src/main.rs @@ -26,6 +26,7 @@ async fn main() { App::new() .with_options(|opt| { opt.with_http(|http| http.bind("127.0.0.1:3000").with_endpoint("/mcp")) + .with_name("Streamable HTTP Example Server") .with_logging(handle) }) .run() diff --git a/examples/large_resources_server/src/main.rs b/examples/large_resources_server/src/main.rs index f6f75e6..3f5886d 100644 --- a/examples/large_resources_server/src/main.rs +++ b/examples/large_resources_server/src/main.rs @@ -46,7 +46,10 @@ fn get_res_info(uri: Uri, name: String) -> Resource { #[tokio::main] async fn main() { App::new() - .with_options(|opt| opt.with_default_http()) + .with_options(|opt| { + opt.with_name("Large resource example server") + .with_default_http() + }) .run() .await; } diff --git a/examples/logging/src/main.rs b/examples/logging/src/main.rs index 155c2b9..9661a9b 100644 --- a/examples/logging/src/main.rs +++ b/examples/logging/src/main.rs @@ -29,6 +29,7 @@ async fn main() { .with_options(|opt| { opt.with_stdio() .with_mcp_version("2024-11-05") + .with_name("Logging Example Server") .with_logging(handle) }) .run() diff --git a/examples/middlewares/src/main.rs b/examples/middlewares/src/main.rs index 73b5732..cdaeef8 100644 --- a/examples/middlewares/src/main.rs +++ b/examples/middlewares/src/main.rs @@ -67,7 +67,11 @@ async fn main() { .init(); App::new() - .with_options(|opt| opt.with_stdio().with_logging(handle)) + .with_options(|opt| { + opt.with_name("Middleware Example Server") + .with_stdio() + .with_logging(handle) + }) .wrap(logging_middleware) // Wraps all requests that pass through the server .wrap_tools(global_tool_middleware) // Wraps all tools/call requests that pass through the server .run() diff --git a/examples/pagination/src/main.rs b/examples/pagination/src/main.rs index dfa5443..67362c4 100644 --- a/examples/pagination/src/main.rs +++ b/examples/pagination/src/main.rs @@ -55,7 +55,10 @@ async fn filter_resources( #[tokio::main] async fn main() { App::new() - .with_options(|opt| opt.with_http(|http| http.bind("127.0.0.1:3000"))) + .with_options(|opt| { + opt.with_name("Pagination Example Server") + .with_http(|http| http.bind("127.0.0.1:3000")) + }) .add_singleton(ResourcesRepository::new()) .run() .await; diff --git a/examples/progress/src/main.rs b/examples/progress/src/main.rs index 36f09cb..3009be6 100644 --- a/examples/progress/src/main.rs +++ b/examples/progress/src/main.rs @@ -42,7 +42,11 @@ async fn main() { .init(); App::new() - .with_options(|opt| opt.with_tasks(|tasks| tasks.with_all()).with_default_http()) + .with_options(|opt| { + opt.with_name("Progress Example Server") + .with_tasks(|tasks| tasks.with_all()) + .with_default_http() + }) .run() .await; } diff --git a/examples/protected-server/src/main.rs b/examples/protected-server/src/main.rs index a6d199a..4a8c96b 100644 --- a/examples/protected-server/src/main.rs +++ b/examples/protected-server/src/main.rs @@ -46,15 +46,16 @@ async fn main() { App::new() .with_options(|opt| { - opt.with_http(|http| { - http.with_auth(|auth| { - auth.validate_exp(false) - .with_aud(["some aud"]) - .with_iss(["some issuer"]) - .set_decoding_key(secret.as_bytes()) + opt.with_name("Protected Server Example") + .with_http(|http| { + http.with_auth(|auth| { + auth.validate_exp(false) + .with_aud(["some aud"]) + .with_iss(["some issuer"]) + .set_decoding_key(secret.as_bytes()) + }) }) - }) - .with_logging(handle) + .with_logging(handle) }) .run() .await; diff --git a/examples/roots/server/src/main.rs b/examples/roots/server/src/main.rs index bdcf5a2..a892a6b 100644 --- a/examples/roots/server/src/main.rs +++ b/examples/roots/server/src/main.rs @@ -9,7 +9,9 @@ async fn roots_request(mut ctx: Context) -> Result { #[tokio::main] async fn main() { App::new() - .with_options(|opt| opt.with_default_http()) + .with_options(|opt| opt + .with_name("Roots Example Server") + .with_default_http()) .run() .await; } diff --git a/examples/sampling/server/src/main.rs b/examples/sampling/server/src/main.rs index b2008b4..e30ee5b 100644 --- a/examples/sampling/server/src/main.rs +++ b/examples/sampling/server/src/main.rs @@ -89,7 +89,9 @@ async fn main() { .set_decoding_key(secret.as_bytes())); App::new() - .with_options(|opt| opt.set_http(http)) + .with_options(|opt| opt + .with_name("Sampling Example Server") + .set_http(http)) .run() .await; } diff --git a/examples/tasks/server/src/main.rs b/examples/tasks/server/src/main.rs index 75985c2..e4265ce 100644 --- a/examples/tasks/server/src/main.rs +++ b/examples/tasks/server/src/main.rs @@ -39,6 +39,7 @@ fn main() { App::new() .with_options(|opt| opt + .with_name("Tasks Example Server") .with_default_http() .with_tasks(|t| t.with_all())) .run_blocking(); diff --git a/examples/updates/src/main.rs b/examples/updates/src/main.rs index bf00ae7..bc87b16 100644 --- a/examples/updates/src/main.rs +++ b/examples/updates/src/main.rs @@ -32,7 +32,8 @@ async fn get_resource(uri: Uri) -> ResourceContents { #[tokio::main] async fn main() { let mut app = App::new().with_options(|opt| { - opt.with_stdio() + opt.with_name("Updates Example Server") + .with_stdio() .with_resources(|res| res.with_subscribe().with_list_changed()) .with_mcp_version("2024-11-05") }); diff --git a/neva/Cargo.toml b/neva/Cargo.toml index 67f2aae..467fb72 100644 --- a/neva/Cargo.toml +++ b/neva/Cargo.toml @@ -9,8 +9,8 @@ authors.workspace = true license.workspace = true repository.workspace = true documentation.workspace = true -categories = ["web-programming::http-server", "network-programming", "asynchronous"] -keywords = ["modelcontextprotocol", "mcp", "sdk", "ai", "framework"] +keywords.workspace = true +categories.workspace = true [lints] workspace = true diff --git a/neva/src/app.rs b/neva/src/app.rs index 3bacf9c..3c30fc1 100644 --- a/neva/src/app.rs +++ b/neva/src/app.rs @@ -48,6 +48,7 @@ use {crate::types::notification::SetLevelRequestParams, tracing::Instrument}; mod collection; pub mod context; +mod greeter; pub(crate) mod handler; pub mod options; @@ -56,8 +57,10 @@ const DEFAULT_PAGE_SIZE: usize = 10; type RequestHandlers = HashMap>; /// Represents an MCP server application -#[derive(Default)] pub struct App { + /// Whether to print the startup greeting banner + greeting: bool, + /// MCP server options pub(super) options: McpOptions, @@ -76,10 +79,18 @@ impl Debug for App { } } +impl Default for App { + /// Creates a default [`App`] with all built-in handlers registered. + fn default() -> Self { + Self::new() + } +} + impl App { /// Initializes a new MCP app pub fn new() -> Self { let mut app = Self { + greeting: cfg!(debug_assertions), options: McpOptions::default(), handlers: HashMap::new(), #[cfg(feature = "di")] @@ -196,6 +207,35 @@ impl App { #[cfg(feature = "macros")] self.register_methods(); + // ORDERING CONSTRAINT: must execute after register_methods() so macro-registered + // tools/prompts are present; must execute before self.options.transport() consumes + // `proto` and before ServerRuntime::new() transitions collections to Runtime state + // (Collection::as_ref() panics if called in Runtime state). + if self.greeting { + let transport_label = self.options.transport_label(); + let tools: Vec = self.options.tools.as_ref().keys().cloned().collect(); + let prompts: Vec = self.options.prompts.as_ref().keys().cloned().collect(); + let resource_templates: Vec = self + .options + .resources_templates + .as_ref() + .keys() + .cloned() + .collect(); + + greeter::Greeter { + server_name: &self.options.implementation.name, + server_version: &self.options.implementation.version, + neva_version: env!("CARGO_PKG_VERSION"), + transport_label: &transport_label, + tools: &tools, + prompts: &prompts, + resource_templates: &resource_templates, + use_color: std::env::var_os("NO_COLOR").is_none(), + } + .print(); + } + #[cfg(feature = "tracing")] self.options .add_middleware(make_mw(Self::tracing_middleware)); @@ -239,6 +279,36 @@ impl App { } } + /// Enable the greeting banner on startup (forced on, even in release builds). + /// + /// # Example + /// ```no_run + /// use neva::App; + /// + /// # fn main() { + /// let app = App::new().with_greeting(); + /// # } + /// ``` + pub fn with_greeting(mut self) -> Self { + self.greeting = true; + self + } + + /// Suppress the greeting banner on startup. + /// + /// # Example + /// ```no_run + /// use neva::App; + /// + /// # fn main() { + /// let app = App::new().without_greeting(); + /// # } + /// ``` + pub fn without_greeting(mut self) -> Self { + self.greeting = false; + self + } + /// Configure MCP server options pub fn with_options(mut self, config: F) -> Self where @@ -939,8 +1009,21 @@ fn create_tracing_span(session_id: Option) -> tracing::Span { #[cfg(test)] mod tests { + use super::App; use crate::types::{MessageBatch, MessageEnvelope}; + #[test] + fn it_enables_greeting_with_with_greeting() { + let app = App::new().with_greeting(); + assert!(app.greeting); + } + + #[test] + fn it_disables_greeting_with_without_greeting() { + let app = App::new().without_greeting(); + assert!(!app.greeting); + } + #[test] fn batch_filtering_notifications_yield_no_response_slots() { use crate::types::notification::Notification; diff --git a/neva/src/app/greeter.rs b/neva/src/app/greeter.rs new file mode 100644 index 0000000..b86079b --- /dev/null +++ b/neva/src/app/greeter.rs @@ -0,0 +1,210 @@ +//! Greeting banner for the MCP server startup + +const CYAN: &str = "\x1b[36m"; +const GREEN: &str = "\x1b[32m"; +const YELLOW: &str = "\x1b[33m"; +const RESET: &str = "\x1b[0m"; + +/// Renders a startup greeting banner to stderr +#[derive(Debug)] +pub(super) struct Greeter<'a> { + pub(super) server_name: &'a str, + pub(super) server_version: &'a str, + /// Always `env!("CARGO_PKG_VERSION")` from the neva crate + pub(super) neva_version: &'a str, + pub(super) transport_label: &'a str, + pub(super) tools: &'a [String], + pub(super) prompts: &'a [String], + pub(super) resource_templates: &'a [String], + /// Set by caller: `std::env::var_os("NO_COLOR").is_none()` + pub(super) use_color: bool, +} + +impl<'a> Greeter<'a> { + /// Renders the full banner to a `String`. Used by `print()` and tests. + pub(super) fn render(&self) -> String { + // Build the box content lines + let server_line = format!("{} v{}", self.server_name, self.server_version); + let neva_line = format!("powered by neva v{}", self.neva_version); + let transport_line = format!("Transport: {}", self.transport_label); + + // Dynamic width based on longest content line (chars().count() handles non-ASCII) + let text_width = [ + server_line.chars().count(), + neva_line.chars().count(), + transport_line.chars().count(), + ] + .into_iter() + .max() + .unwrap_or(0); + + let inner_width = text_width + 4; // 2 leading + 2 trailing spaces + + let mut out = String::new(); + + // ╔══...══╗ + out.push('╔'); + out.push_str(&"═".repeat(inner_width)); + out.push_str("╗\n"); + + // Content lines: server name and neva version + for text in &[&server_line, &neva_line] { + let padding = inner_width - 2 - text.chars().count(); + out.push_str("║ "); + out.push_str(text); + out.push_str(&" ".repeat(padding)); + out.push_str("║\n"); + } + + // Blank separator line + out.push('║'); + out.push_str(&" ".repeat(inner_width)); + out.push_str("║\n"); + + // Transport line + let padding = inner_width - 2 - transport_line.chars().count(); + out.push_str("║ "); + out.push_str(&transport_line); + out.push_str(&" ".repeat(padding)); + out.push_str("║\n"); + + // ╚══...══╝ + out.push('╚'); + out.push_str(&"═".repeat(inner_width)); + out.push_str("╝\n"); + + // Capability sections (outside the box) + let sections: &[(&str, &str, &[String])] = &[ + (CYAN, "Tools", self.tools), + (GREEN, "Prompts", self.prompts), + (YELLOW, "Resource Templates", self.resource_templates), + ]; + + for (color, header, items) in sections { + if items.is_empty() { + continue; + } + out.push('\n'); + if self.use_color { + out.push_str(color); + out.push_str(header); + out.push_str(RESET); + } else { + out.push_str(header); + } + out.push('\n'); + for item in *items { + out.push_str(" \u{2022} "); + out.push_str(item); + out.push('\n'); + } + } + + out + } + + /// Writes the banner to stderr; write errors are silently discarded. + pub(super) fn print(&self) { + eprint!("{}", self.render()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_greeter<'a>( + server_name: &'a str, + tools: &'a [String], + prompts: &'a [String], + resource_templates: &'a [String], + use_color: bool, + ) -> Greeter<'a> { + Greeter { + server_name, + server_version: "1.0.0", + neva_version: "0.2.1", + transport_label: "stdio", + tools, + prompts, + resource_templates, + use_color, + } + } + + #[test] + fn it_renders_box_wide_enough_for_longest_line() { + // Server line will be the longest: " v1.0.0" + let long_name = "My Very Long Server Name That Is Long"; + let greeter = make_greeter(long_name, &[], &[], &[], false); + let output = greeter.render(); + + let server_line_len = format!("{} v1.0.0", long_name).chars().count(); + let inner_width = server_line_len + 4; + let expected_top = format!("╔{}╗", "═".repeat(inner_width)); + assert!( + output.contains(&expected_top), + "Expected top border of width {inner_width} not found in:\n{output}" + ); + } + + #[test] + fn it_omits_empty_sections() { + let tools = vec!["hello".to_string()]; + let greeter = make_greeter("Server", &tools, &[], &[], false); + let output = greeter.render(); + + assert!(output.contains("Tools"), "Tools section should be present"); + assert!( + !output.contains("Prompts"), + "Prompts section should be absent" + ); + assert!( + !output.contains("Resource Templates"), + "Resource Templates section should be absent" + ); + } + + #[test] + fn it_omits_ansi_when_use_color_false() { + let tools = vec!["hello".to_string()]; + let greeter = make_greeter("Server", &tools, &[], &[], false); + let output = greeter.render(); + assert!( + !output.contains("\x1b["), + "No ANSI codes expected when use_color=false, got:\n{output}" + ); + } + + #[test] + fn it_includes_ansi_when_use_color_true() { + let tools = vec!["hello".to_string()]; + let greeter = make_greeter("Server", &tools, &[], &[], true); + let output = greeter.render(); + assert!( + output.contains("\x1b[36m"), + "Expected cyan ANSI code for Tools header, got:\n{output}" + ); + } + + #[test] + fn it_renders_only_box_when_all_sections_empty() { + let greeter = make_greeter("Server", &[], &[], &[], false); + let output = greeter.render(); + + // Box must be present + assert!(output.contains("╔"), "Box top-left corner missing"); + assert!(output.contains("╝"), "Box bottom-right corner missing"); + + // No capability headers + assert!(!output.contains("Tools")); + assert!(!output.contains("Prompts")); + assert!(!output.contains("Resource Templates")); + + // No trailing blank line after the box close (output ends at ╝\n) + assert!( + output.trim_end().ends_with('╝'), + "Output should end with box bottom" + ); + } +} diff --git a/neva/src/app/options.rs b/neva/src/app/options.rs index 8fb35a1..2c6b4c5 100644 --- a/neva/src/app/options.rs +++ b/neva/src/app/options.rs @@ -390,6 +390,16 @@ impl McpOptions { transport.unwrap_or_default() } + /// Returns a display label for the currently configured transport + pub(super) fn transport_label(&self) -> String { + match &self.proto { + Some(TransportProto::StdIoServer(_)) => "stdio".to_owned(), + #[cfg(feature = "http-server")] + Some(TransportProto::HttpServer(http)) => http.url_label(), + _ => "(none)".to_owned(), + } + } + /// Returns a tool by its name #[inline] pub(crate) async fn get_tool(&self, name: &str) -> Option { @@ -838,4 +848,24 @@ mod tests { assert!(options.prompts_capability().is_none()); } + + #[test] + fn it_returns_stdio_label() { + let options = McpOptions::default().with_stdio(); + assert_eq!(options.transport_label(), "stdio"); + } + + #[test] + fn it_returns_none_label_when_no_transport() { + let options = McpOptions::default(); + assert_eq!(options.transport_label(), "(none)"); + } + + #[cfg(feature = "http-server")] + #[test] + fn it_returns_http_label_when_http_transport() { + let options = McpOptions::default().with_default_http(); + // Default HTTP: 127.0.0.1:3000/mcp + assert_eq!(options.transport_label(), "http://127.0.0.1:3000/mcp"); + } } diff --git a/neva/src/transport/http.rs b/neva/src/transport/http.rs index 79f1943..2c3d81f 100644 --- a/neva/src/transport/http.rs +++ b/neva/src/transport/http.rs @@ -303,6 +303,11 @@ impl HttpServer { self } + /// Returns the URL label used for display in the greeting banner + pub(crate) fn url_label(&self) -> String { + self.url.to_string() + } + fn runtime(&mut self) -> Result { let Some(sender_rx) = self.sender.rx.take() else { return Err(Error::new( diff --git a/neva_macros/Cargo.toml b/neva_macros/Cargo.toml index ddacb88..c1b78cd 100644 --- a/neva_macros/Cargo.toml +++ b/neva_macros/Cargo.toml @@ -9,8 +9,8 @@ authors.workspace = true license.workspace = true repository.workspace = true documentation.workspace = true -categories = ["web-programming::http-server", "web-programming::http-client", "network-programming", "asynchronous"] -keywords = ["modelcontextprotocol", "mcp", "sdk", "ai", "framework"] +keywords.workspace = true +categories.workspace = true [lib] proc-macro = true diff --git a/neva_macros/README.md b/neva_macros/README.md index f337b82..4d07d2b 100644 --- a/neva_macros/README.md +++ b/neva_macros/README.md @@ -1,2 +1,2 @@ # Neva Macros -Macros crate for Neva MCP SDK +Macros crate for [Neva MCP SDK](https://crates.io/crates/neva)