#![allow(missing_docs)]
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use librebar::mcp::ServiceExt;
use rmcp::{
ErrorData as McpError, RoleServer, ServerHandler,
model::{
CallToolRequestParams, CallToolResult, Content, ListToolsResult, PaginatedRequestParams,
ServerCapabilities, ServerInfo, Tool,
},
service::RequestContext,
};
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::io::{BufRead, BufReader, Write};
use std::process::{Command as StdCommand, Stdio};
use std::sync::Arc;
#[derive(Debug, Deserialize, Serialize)]
#[serde(default)]
struct Config {
log_level: librebar::config::LogLevel,
greeting: String,
}
impl Default for Config {
fn default() -> Self {
Self {
log_level: librebar::config::LogLevel::Info,
greeting: "hello".to_string(),
}
}
}
#[derive(Parser)]
#[command(
name = "mcp-server",
about = "Example MCP server exposing a single `greet` tool over stdio"
)]
struct Cli {
#[command(flatten)]
common: librebar::cli::CommonArgs,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
Run,
Info,
Call {
#[arg(long, default_value = "world")]
name: String,
},
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
let cli = Cli::parse();
cli.common.apply_color();
cli.common.apply_chdir()?;
let app = librebar::init("mcp-server")
.with_version(env!("CARGO_PKG_VERSION"))
.with_cli(cli.common)
.config::<Config>()
.logging()
.start()?;
match cli.command.unwrap_or(Command::Info) {
Command::Run => run_server(&app).await,
Command::Info => {
print_info(&app);
Ok(())
}
Command::Call { name } => round_trip_call(&name),
}
}
async fn run_server(app: &librebar::App<Config>) -> Result<()> {
let server = GreetServer {
greeting: app.config().greeting.clone(),
};
tracing::info!("mcp-server starting on stdio");
let service = server.serve(librebar::mcp::transport_stdio()).await?;
service.waiting().await?;
tracing::info!("mcp-server client disconnected; exiting");
Ok(())
}
fn print_info(app: &librebar::App<Config>) {
let config = app.config();
println!("app: {} v{}", app.app_name(), app.version());
println!("sources: {:?}", app.config_sources());
println!("greeting: {}", config.greeting);
println!("tools: greet");
println!(
"log dir: {:?}",
librebar::logging::platform_log_dir(app.app_name())
);
println!();
println!("Run with `run` to serve on stdio (expects a connected MCP client).");
}
fn round_trip_call(name: &str) -> Result<()> {
let exe = std::env::current_exe().context("locating current executable")?;
eprintln!("round-trip: spawning `{} run`", exe.display());
let mut child = StdCommand::new(&exe)
.arg("run")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.context("spawning mcp-server subprocess")?;
let mut stdin = child.stdin.take().context("child stdin missing")?;
let stdout = child.stdout.take().context("child stdout missing")?;
let mut reader = BufReader::new(stdout);
let initialize = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": { "name": "mcp-server-example-call", "version": "0.0.0" }
}
});
let initialized = serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
});
let call = serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "greet",
"arguments": { "name": name }
}
});
writeln!(stdin, "{initialize}")?;
writeln!(stdin, "{initialized}")?;
writeln!(stdin, "{call}")?;
stdin.flush()?;
let mut result_text = None;
let mut line = String::new();
while result_text.is_none() {
line.clear();
let n = reader
.read_line(&mut line)
.context("reading response from mcp-server")?;
if n == 0 {
break;
}
let resp: serde_json::Value = serde_json::from_str(line.trim())
.with_context(|| format!("parsing response: {}", line.trim()))?;
match resp.get("id").and_then(|v| v.as_i64()) {
Some(1) => eprintln!("round-trip: initialize acknowledged"),
Some(2) => {
result_text = resp
.pointer("/result/content/0/text")
.and_then(|v| v.as_str())
.map(String::from);
}
_ => {}
}
}
drop(stdin);
let status = child.wait().context("waiting on mcp-server subprocess")?;
if !status.success() {
bail!("mcp-server subprocess exited with status {status}");
}
match result_text {
Some(text) => {
println!("{text}");
Ok(())
}
None => bail!("no result payload returned from tools/call"),
}
}
#[derive(Clone)]
struct GreetServer {
greeting: String,
}
impl ServerHandler for GreetServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_instructions("A minimal librebar example exposing a single `greet` tool.")
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
let schema = serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Who to greet",
}
},
"required": ["name"],
});
let schema_obj = schema.as_object().cloned().unwrap_or_default();
let tool = Tool::new(
Cow::Borrowed("greet"),
Cow::Borrowed("Greet someone by name with the configured prefix"),
Arc::new(schema_obj),
);
Ok(ListToolsResult::with_all_items(vec![tool]))
}
async fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
if request.name != "greet" {
return Err(McpError::invalid_params(
format!("unknown tool: {}", request.name),
None,
));
}
let name = request
.arguments
.as_ref()
.and_then(|args| args.get("name"))
.and_then(|v| v.as_str())
.ok_or_else(|| McpError::invalid_params("missing required arg: name", None))?;
let output = format!("{}, {}!", self.greeting, name);
tracing::info!(name = %name, "greet tool invoked");
Ok(CallToolResult::success(vec![Content::text(output)]))
}
}