use rmcp::{
ErrorData as McpError, ServerHandler, ServiceExt,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::*,
tool, tool_handler, tool_router,
transport::stdio
};
use serde::Deserialize;
const BANNER_WIDTH: usize = 80;
const MAX_TEXT_LEN: usize = BANNER_WIDTH - 4 - 2;
const INNER_WIDTH: usize = BANNER_WIDTH - 4;
fn format_banner(text: &str) -> Result<String, McpError>
{
let text_len = text.len();
if text_len > MAX_TEXT_LEN
{
return Err(McpError::invalid_params(
format!(
"text is {text_len} characters, maximum is {MAX_TEXT_LEN} \
(must leave at least 1 space of padding on each side)"
),
None
));
}
let left = (INNER_WIDTH - text_len) / 2;
let right = INNER_WIDTH - text_len - left;
let bar = "/".repeat(BANNER_WIDTH);
let middle = format!(
"//{left}{text}{right}//",
left = " ".repeat(left),
right = " ".repeat(right),
);
Ok(format!("{bar}\n{middle}\n{bar}"))
}
#[derive(Clone)]
struct BannerServer
{
tool_router: ToolRouter<Self>
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct BannerRequest
{
#[schemars(description = "The text to center in the banner. Must be 74 \
characters or fewer.")]
text: String
}
#[tool_router]
impl BannerServer
{
pub fn new() -> Self
{
Self {
tool_router: Self::tool_router()
}
}
#[tool(
name = "banner",
description = "Format text into a perfectly centered 80-column \
Rust comment banner. Returns a 3-line banner: a line of 80 \
slashes, the text centered between // delimiters with space \
padding, and another line of 80 slashes. Text must be 74 \
characters or fewer."
)]
fn banner(
&self,
Parameters(req): Parameters<BannerRequest>
) -> Result<CallToolResult, McpError>
{
let banner = format_banner(&req.text)?;
Ok(CallToolResult::success(vec![Content::text(banner)]))
}
}
#[tool_handler]
impl ServerHandler for BannerServer
{
fn get_info(&self) -> ServerInfo
{
ServerInfo {
protocol_version: ProtocolVersion::V_2024_11_05,
capabilities: ServerCapabilities::builder().enable_tools().build(),
server_info: Implementation {
name: env!("CARGO_PKG_NAME").into(),
version: env!("CARGO_PKG_VERSION").into(),
..Default::default()
},
instructions: Some(
"Banner formatting tool. Call the `banner` tool with a \
`text` parameter (≤74 characters) to get a perfectly \
centered 80-column Rust comment banner."
.into()
)
}
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()>
{
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_ansi(true)
.with_max_level(tracing::Level::INFO)
.init();
tracing::info!("starting banners-mcp server (stdio transport)");
let server = BannerServer::new();
let service = server
.serve(stdio())
.await
.inspect_err(|e| tracing::error!("serving error: {e}"))?;
service.waiting().await?;
tracing::info!("banners-mcp server shut down");
Ok(())
}
#[cfg(test)]
mod tests
{
use rmcp::{ClientHandler, ServiceExt, model::CallToolRequestParams};
use super::*;
#[derive(Default, Clone)]
struct TestClient;
impl ClientHandler for TestClient {}
type Client = rmcp::service::RunningService<rmcp::RoleClient, TestClient>;
fn first_text(result: &CallToolResult) -> &str
{
match &result.content[0].raw
{
RawContent::Text(text) => &text.text,
other => panic!("expected text content, got {other:?}")
}
}
async fn setup() -> Client
{
let server = BannerServer::new();
let (server_transport, client_transport) = tokio::io::duplex(4096);
tokio::spawn(async move {
let service = server.serve(server_transport).await.unwrap();
service.waiting().await.unwrap();
});
TestClient
.serve(client_transport)
.await
.expect("client failed to connect")
}
async fn call_banner(
client: &Client,
text: &str
) -> Result<CallToolResult, rmcp::ServiceError>
{
client
.call_tool(CallToolRequestParams {
meta: None,
name: "banner".into(),
arguments: Some(
serde_json::json!({"text": text})
.as_object()
.unwrap()
.clone()
),
task: None
})
.await
}
#[tokio::test]
async fn tools_are_listed()
{
let client = setup().await;
let tools = client.list_all_tools().await.unwrap();
assert_eq!(tools.len(), 1, "expected exactly 1 tool");
assert_eq!(tools[0].name, "banner");
assert!(
tools[0].description.is_some(),
"banner tool has no description"
);
assert!(
!tools[0].input_schema.is_empty(),
"banner tool has empty input schema"
);
}
#[tokio::test]
async fn banner_even_padding()
{
let client = setup().await;
let result = call_banner(&client, "Banner text here.").await.unwrap();
let text = first_text(&result);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3, "expected 3 lines");
assert_eq!(lines[0].len(), 80);
assert_eq!(lines[2].len(), 80);
assert_eq!(lines[0], "/".repeat(80));
assert_eq!(lines[2], "/".repeat(80));
assert_eq!(lines[1].len(), 80, "middle line should be 80 chars");
assert!(lines[1].starts_with("//"));
assert!(lines[1].ends_with("//"));
assert!(lines[1].contains("Banner text here."));
}
#[tokio::test]
async fn banner_odd_padding()
{
let client = setup().await;
let result = call_banner(&client, "Hello").await.unwrap();
let text = first_text(&result);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[1].len(), 80);
let middle = lines[1];
assert_eq!(&middle[..2], "//");
assert_eq!(&middle[78..], "//");
let inner = &middle[2..78];
let left_spaces = inner.len() - inner.trim_start().len();
let right_spaces = inner.len() - inner.trim_end().len();
assert_eq!(left_spaces, 35, "expected 35 left spaces");
assert_eq!(right_spaces, 36, "expected 36 right spaces");
}
#[tokio::test]
async fn banner_short_text()
{
let client = setup().await;
let result = call_banner(&client, "X").await.unwrap();
let text = first_text(&result);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].len(), 80);
assert_eq!(lines[1].len(), 80);
assert_eq!(lines[2].len(), 80);
assert!(lines[1].contains("X"));
}
#[tokio::test]
async fn banner_text_too_long()
{
let client = setup().await;
let long_text = "x".repeat(75);
let result = call_banner(&client, &long_text).await;
assert!(
result.is_err(),
"text exceeding 74 chars should return an error"
);
}
#[tokio::test]
async fn banner_empty_text()
{
let client = setup().await;
let result = call_banner(&client, "").await.unwrap();
let text = first_text(&result);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "/".repeat(80));
assert_eq!(lines[2], "/".repeat(80));
assert_eq!(lines[1].len(), 80);
assert_eq!(lines[1], format!("//{spaces}//", spaces = " ".repeat(76)));
}
#[tokio::test]
async fn banner_spec_example()
{
let result = format_banner("Banner text here.").unwrap();
assert_eq!(
result,
"////////////////////////////////////////////////////////////////////////////////\n\
// Banner text here. //\n\
////////////////////////////////////////////////////////////////////////////////"
);
}
#[tokio::test]
async fn banner_max_length_text()
{
let text = "x".repeat(74);
let result = format_banner(&text).unwrap();
let lines: Vec<&str> = result.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[1].len(), 80);
assert_eq!(lines[1], format!("// {text} //", text = "x".repeat(74)));
}
#[tokio::test]
async fn format_banner_direct()
{
let result = format_banner("Test").unwrap();
let lines: Vec<&str> = result.lines().collect();
let inner = &lines[1][2..78];
assert_eq!(inner.trim(), "Test");
let left = inner.len() - inner.trim_start().len();
let right = inner.len() - inner.trim_end().len();
assert_eq!(left, 36);
assert_eq!(right, 36);
}
}