use std::io;
use std::path::{Path, PathBuf};
use noyalib_mcp::ParseProfile;
use std::process::ExitCode;
use axum::Router;
use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
use rmcp::{ServerHandler, ServiceExt};
use tokio::net::TcpListener;
use tokio_util::sync::CancellationToken;
pub const DEFAULT_HOST: &str = "127.0.0.1";
pub const DEFAULT_PORT: u16 = 8000;
pub const DEFAULT_MAX_SESSIONS: usize = 64;
pub const STREAMABLE_HTTP_PATH: &str = "/mcp";
pub const SSE_PATH: &str = "/sse";
pub const MESSAGE_PATH: &str = "/messages/";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport {
Stdio,
StreamableHttp,
Sse,
}
impl Transport {
fn parse(name: &str) -> Option<Self> {
match name {
"stdio" => Some(Self::Stdio),
"streamable-http" => Some(Self::StreamableHttp),
"sse" => Some(Self::Sse),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Options {
pub transport: Transport,
pub host: String,
pub port: u16,
pub root: Option<PathBuf>,
pub profile: ParseProfile,
pub max_sessions: usize,
}
impl Default for Options {
fn default() -> Self {
Self {
transport: Transport::Stdio,
host: DEFAULT_HOST.to_owned(),
port: DEFAULT_PORT,
root: None,
profile: ParseProfile::default(),
max_sessions: DEFAULT_MAX_SESSIONS,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
Serve(Options),
Help,
Version,
}
#[must_use]
pub fn usage(name: &str) -> String {
format!(
"Usage: {name} [--transport <stdio|streamable-http|sse>] \
[--host <address>] [--port <number>] [--root <dir>] [--profile <strict|standard>] \
[--max-sessions <n>]\n\
\n\
Options:\n\
\x20 --transport <name> stdio (default), streamable-http, or sse\n\
\x20 --host <address> interface for the HTTP transports \
(default {DEFAULT_HOST})\n\
\x20 --port <number> port for the HTTP transports \
(default {DEFAULT_PORT})\n\
\x20 --root <dir> directory the file tools may read and write \
(default: the working directory, unless that is / or the home directory)\n\
\x20 --profile <name> rules every tool parses under: \
strict (default) or standard\n\
\x20 --max-sessions <n> sessions an HTTP transport holds at once \
(default {DEFAULT_MAX_SESSIONS})\n\
\x20 --version print the version and exit\n\
\x20 --help print this text and exit\n\
\n\
streamable-http serves {STREAMABLE_HTTP_PATH} (MCP 2025-11-25 and \
2026-07-28).\n\
sse serves {SSE_PATH} and {MESSAGE_PATH} (MCP 2024-11-05).\n\
Neither transport authenticates: keep them on the loopback \
interface or\n\
behind a gateway you trust."
)
}
pub fn parse<I>(args: I) -> Result<Command, String>
where
I: IntoIterator,
I::Item: Into<String>,
{
let mut options = Options::default();
let mut args = args.into_iter().map(Into::into);
while let Some(arg) = args.next() {
let (flag, inline) = split_flag(arg);
match flag.as_str() {
"--help" | "-h" => return Ok(Command::Help),
"--version" | "-V" => return Ok(Command::Version),
other => apply_flag(&mut options, other, inline, &mut args)?,
}
}
Ok(Command::Serve(options))
}
fn split_flag(arg: String) -> (String, Option<String>) {
match arg.split_once('=') {
Some((flag, value)) => (flag.to_owned(), Some(value.to_owned())),
None => (arg, None),
}
}
fn apply_flag(
options: &mut Options,
flag: &str,
inline: Option<String>,
rest: &mut dyn Iterator<Item = String>,
) -> Result<(), String> {
if !matches!(
flag,
"--transport" | "--host" | "--port" | "--root" | "--profile" | "--max-sessions"
) {
return Err(format!("unknown argument `{flag}`"));
}
let value = inline
.or_else(|| rest.next())
.ok_or_else(|| format!("{flag} needs a value"))?;
set_option(options, flag, value)
}
fn set_option(options: &mut Options, flag: &str, value: String) -> Result<(), String> {
match flag {
"--root" => options.root = Some(PathBuf::from(value)),
"--host" => options.host = value,
_ => set_parsed(options, flag, &value)?,
}
Ok(())
}
fn set_parsed(options: &mut Options, flag: &str, value: &str) -> Result<(), String> {
match flag {
"--transport" => options.transport = parse_transport(value)?,
"--port" => options.port = parse_port(value)?,
"--max-sessions" => options.max_sessions = parse_max_sessions(value)?,
_ => options.profile = parse_profile(value)?,
}
Ok(())
}
fn parse_profile(name: &str) -> Result<ParseProfile, String> {
ParseProfile::from_name(name)
.ok_or_else(|| format!("unknown profile `{name}`; choose strict or standard"))
}
fn parse_transport(name: &str) -> Result<Transport, String> {
Transport::parse(name)
.ok_or_else(|| format!("unknown transport `{name}`; choose stdio, streamable-http or sse"))
}
fn parse_max_sessions(text: &str) -> Result<usize, String> {
text.parse()
.ok()
.filter(|n| *n > 0)
.ok_or_else(|| format!("`{text}` is not a positive number of sessions"))
}
fn parse_port(text: &str) -> Result<u16, String> {
text.parse()
.map_err(|_| format!("`{text}` is not a port number"))
}
fn resolve_root(root: Option<&Path>) -> Result<PathBuf, String> {
let chosen = match root {
Some(r) => r.to_path_buf(),
None => std::env::current_dir().unwrap_or(PathBuf::from(".")),
};
let canonical = chosen
.canonicalize()
.map_err(|e| format!("--root {}: {e}", chosen.display()))?;
if canonical.is_dir() {
Ok(std::path::absolute(&chosen).unwrap_or(canonical))
} else {
Err(format!("--root {}: not a directory", chosen.display()))
}
}
fn refuse_broad_default(root: &Path, home: Option<&Path>) -> Result<(), String> {
let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let is_home = home
.and_then(|h| h.canonicalize().ok())
.is_some_and(|h| h == canonical);
if canonical.parent().is_none() || is_home {
return Err(format!(
"refusing to serve {} without --root: it is the filesystem root \
or the home directory; pass --root <dir> to choose the directory \
the file tools may use",
root.display()
));
}
Ok(())
}
fn chosen_root(options: &Options) -> Result<PathBuf, String> {
let root = resolve_root(options.root.as_deref())?;
if options.root.is_none() {
refuse_broad_default(&root, home_dir().as_deref())?;
}
Ok(root)
}
fn home_dir() -> Option<PathBuf> {
["HOME", "USERPROFILE"]
.iter()
.find_map(|key| std::env::var_os(key).filter(|v| !v.is_empty()))
.map(PathBuf::from)
}
fn options_to_serve<I>(name: &str, version: &str, args: I) -> Result<Options, ExitCode>
where
I: IntoIterator,
I::Item: Into<String>,
{
match parse(args) {
Ok(Command::Serve(options)) => Ok(options),
Ok(Command::Help) => {
println!("{}", usage(name));
Err(ExitCode::SUCCESS)
}
Ok(Command::Version) => {
println!("{name} {version}");
Err(ExitCode::SUCCESS)
}
Err(message) => {
eprintln!("{name}: {message}\n\n{}", usage(name));
Err(ExitCode::from(2))
}
}
}
pub fn run<H, F, I>(name: &str, version: &str, args: I, factory: F) -> ExitCode
where
H: ServerHandler,
F: Fn(PathBuf, ParseProfile) -> H + Send + Sync + 'static,
I: IntoIterator,
I::Item: Into<String>,
{
let options = match options_to_serve(name, version, args) {
Ok(options) => options,
Err(code) => return code,
};
let root = match chosen_root(&options) {
Ok(root) => root,
Err(message) => {
eprintln!("{name}: {message}\n\n{}", usage(name));
return ExitCode::from(2);
}
};
let profile = options.profile;
let make = move || factory(root.clone(), profile);
let runtime = match tokio::runtime::Runtime::new() {
Ok(runtime) => runtime,
Err(e) => {
eprintln!("{name}: cannot start the async runtime: {e}");
return ExitCode::FAILURE;
}
};
match runtime.block_on(serve(&options, make)) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("{name}: {e}");
ExitCode::FAILURE
}
}
}
pub async fn serve<H, F>(options: &Options, factory: F) -> io::Result<()>
where
H: ServerHandler,
F: Fn() -> H + Send + Sync + 'static,
{
match options.transport {
Transport::Stdio => serve_stdio(factory()).await,
Transport::StreamableHttp => {
let listener = bind(options).await?;
announce(&listener, STREAMABLE_HTTP_PATH)?;
serve_streamable_http(listener, options, factory).await
}
Transport::Sse => {
let listener = bind(options).await?;
announce(&listener, SSE_PATH)?;
serve_sse(listener, options, factory).await
}
}
}
async fn serve_stdio<H: ServerHandler>(handler: H) -> io::Result<()> {
use rmcp::service::ServerInitializeError as Init;
let running = match handler.serve(rmcp::transport::stdio()).await {
Ok(running) => running,
Err(Init::ConnectionClosed(_) | Init::ExpectedInitializeRequest(_)) => {
return Ok(());
}
Err(e) => return Err(io::Error::other(format!("stdio session: {e}"))),
};
let _ = running
.waiting()
.await
.map_err(|e| io::Error::other(format!("stdio session: {e}")))?;
Ok(())
}
async fn bind(options: &Options) -> io::Result<TcpListener> {
TcpListener::bind((options.host.as_str(), options.port))
.await
.map_err(|e| {
io::Error::new(
e.kind(),
format!("cannot listen on {}:{}: {e}", options.host, options.port),
)
})
}
fn announce(listener: &TcpListener, path: &str) -> io::Result<()> {
let addr = listener.local_addr()?;
eprintln!("listening on http://{addr}{path}");
Ok(())
}
async fn interrupted() {
if tokio::signal::ctrl_c().await.is_err() {
std::future::pending::<()>().await;
}
}
async fn serve_streamable_http<H, F>(
listener: TcpListener,
options: &Options,
factory: F,
) -> io::Result<()>
where
H: ServerHandler,
F: Fn() -> H + Send + Sync + 'static,
{
let ct = CancellationToken::new();
let mut config =
StreamableHttpServerConfig::default().with_cancellation_token(ct.child_token());
config = match guard::allowed_hosts(&options.host) {
Some(hosts) => config.with_allowed_hosts(hosts),
None => config.disable_allowed_hosts(),
}
.with_allowed_origins(guard::allowed_origins(&options.host));
let service = StreamableHttpService::new(
move || Ok(factory()),
CappedSessions::new(options.max_sessions).into(),
config,
);
let router = Router::new().nest_service(STREAMABLE_HTTP_PATH, service);
axum::serve(listener, router)
.with_graceful_shutdown(async move {
interrupted().await;
ct.cancel();
})
.await
}
mod guard;
mod sessions;
mod sse;
use sessions::CappedSessions;
use sse::serve_sse;
#[cfg(test)]
mod tests;