use std::io;
use std::sync::Arc;
use sim_codec_mcp::McpCodecLib;
use sim_kernel::{
AbiVersion, Args, Callable, CapabilityName, CodecId, Cx, Error, Export, Expr, Lib, LibManifest,
LibTarget, Linker, LoadCx, Object, ObjectCompat, Result, Symbol, Value, Version,
};
use sim_run_core::{Bootloader, cli_main_entrypoint_symbol};
pub fn configure_mcp_bootloader(loader: Bootloader) -> Bootloader {
loader
.host_lib("codec/mcp", || Box::new(McpCodecLib::new(CodecId(1))))
.host_verb(MCP_SERVE_VERB, "lib/mcp-serve", || {
Box::new(McpServeLib::new())
})
}
pub fn mcp_bootloader() -> Bootloader {
configure_mcp_bootloader(Bootloader::standard())
}
use crate::stdio::{StdioOptions, mcp_stdio_capability, run_stdio};
use crate::{McpProfile, McpRouter, McpSession, install_mcp_lib};
pub const MCP_SERVE_VERB: &str = "mcp";
pub fn mcp_serve_entrypoint_symbol() -> Symbol {
cli_main_entrypoint_symbol(MCP_SERVE_VERB)
}
#[derive(Clone, Debug, Default)]
pub struct McpServeLib;
impl McpServeLib {
pub fn new() -> Self {
Self
}
}
impl Lib for McpServeLib {
fn manifest(&self) -> LibManifest {
LibManifest {
id: Symbol::qualified("lib", "mcp-serve"),
version: Version(env!("CARGO_PKG_VERSION").to_owned()),
abi: AbiVersion { major: 0, minor: 1 },
target: LibTarget::HostRegistered,
requires: Vec::new(),
capabilities: Vec::new(),
exports: vec![Export::Function {
symbol: mcp_serve_entrypoint_symbol(),
function_id: None,
}],
}
}
fn load(&self, cx: &mut LoadCx, linker: &mut Linker<'_>) -> Result<()> {
let entrypoint = cx.factory().opaque(Arc::new(McpServeEntrypoint))?;
linker.function_value(mcp_serve_entrypoint_symbol(), entrypoint)?;
Ok(())
}
}
#[derive(Clone)]
struct McpServeEntrypoint;
impl Object for McpServeEntrypoint {
fn display(&self, _cx: &mut Cx) -> Result<String> {
Ok("cli/main/mcp".to_owned())
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
impl ObjectCompat for McpServeEntrypoint {
fn as_callable(&self) -> Option<&dyn Callable> {
Some(self)
}
}
impl Callable for McpServeEntrypoint {
fn call(&self, cx: &mut Cx, args: Args) -> Result<Value> {
let opts = options_from_envelope(cx, args.values().first())?;
match &opts.transport {
Transport::Stdio => {}
Transport::Http { .. } => {
return Err(Error::Eval(
"sim-mcp-server --http is disabled; use --stdio".to_owned(),
));
}
}
install_mcp_lib(cx)?;
let mut session =
McpSession::new("stdio", opts.profile).with_granted_capability(mcp_stdio_capability());
for capability in opts.capabilities {
session = session.with_granted_capability(capability);
}
let mut router = McpRouter::new(session);
run_stdio(
cx,
&mut router,
io::stdin().lock(),
io::stdout(),
io::stderr(),
StdioOptions {
log_stderr: opts.log_stderr,
},
)?;
cx.factory().bool(true)
}
}
fn options_from_envelope(cx: &mut Cx, envelope: Option<&Value>) -> Result<CliOptions> {
let Some(envelope) = envelope else {
return CliOptions::parse_from(std::iter::empty());
};
let args = envelope_args(cx, envelope)?;
CliOptions::parse_from(args.into_iter().skip(1))
}
fn envelope_args(cx: &mut Cx, envelope: &Value) -> Result<Vec<String>> {
let Some(table) = envelope.object().as_table_impl() else {
return Err(Error::Eval("CLI envelope is not a table".to_owned()));
};
let value = table.get(cx, Symbol::new("args"))?;
let Some(list) = value.object().as_list() else {
return Err(Error::Eval(
"CLI envelope field args is not a list".to_owned(),
));
};
list.to_vec(cx, Some(64))?
.into_iter()
.map(|value| match value.object().as_expr(cx)? {
Expr::String(text) => Ok(text),
other => Err(Error::Eval(format!(
"CLI payload argument is not a string: {other:?}"
))),
})
.collect()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CliOptions {
pub transport: Transport,
pub profile: McpProfile,
pub capabilities: Vec<CapabilityName>,
pub log_stderr: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Transport {
Stdio,
Http {
address: String,
route: String,
},
}
impl CliOptions {
pub fn parse_from(args: impl IntoIterator<Item = String>) -> Result<Self> {
let mut transport = None;
let mut profile = McpProfile::all();
let mut capabilities = Vec::new();
let mut route = "/mcp".to_owned();
let mut log_stderr = false;
let mut iter = args.into_iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--stdio" => set_transport(&mut transport, Transport::Stdio)?,
"--http" => {
let address = next_arg(&mut iter, "--http expects host:port")?;
set_transport(
&mut transport,
Transport::Http {
address,
route: route.clone(),
},
)?;
}
"--route" => {
route = next_arg(&mut iter, "--route expects a path")?;
if let Some(Transport::Http {
route: http_route, ..
}) = &mut transport
{
*http_route = route.clone();
}
}
"--profile" => {
let name = next_arg(&mut iter, "--profile expects a name")?;
profile = parse_profile(&name)?;
}
"--allow-tool" => {
profile = profile.with_allowed_name(next_arg(
&mut iter,
"--allow-tool expects a name or glob",
)?);
}
"--deny-tool" => {
profile = profile.with_denied_name(next_arg(
&mut iter,
"--deny-tool expects a name or glob",
)?);
}
"--cap" => {
capabilities.push(CapabilityName::new(next_arg(
&mut iter,
"--cap expects a capability name",
)?));
}
"--no-default-tools" => {
profile = profile.with_denied_name("*");
}
"--log-stderr" => log_stderr = true,
other => {
return Err(Error::Eval(format!(
"unknown sim-mcp-server option {other}"
)));
}
}
}
Ok(Self {
transport: transport.unwrap_or(Transport::Stdio),
profile,
capabilities,
log_stderr,
})
}
}
fn set_transport(slot: &mut Option<Transport>, transport: Transport) -> Result<()> {
if slot.is_some() {
return Err(Error::Eval(
"sim-mcp-server accepts one transport option".to_owned(),
));
}
*slot = Some(transport);
Ok(())
}
fn parse_profile(name: &str) -> Result<McpProfile> {
match name {
"default" => Ok(McpProfile::all()),
other => Err(Error::Eval(format!("unknown MCP profile {other}"))),
}
}
fn next_arg(iter: &mut impl Iterator<Item = String>, message: &'static str) -> Result<String> {
iter.next().ok_or_else(|| Error::Eval(message.to_owned()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serve_lib_exports_cli_main_mcp() {
let manifest = McpServeLib::new().manifest();
assert!(manifest.exports.iter().any(|export| matches!(
export,
Export::Function { symbol, .. } if symbol == &mcp_serve_entrypoint_symbol()
)));
}
#[test]
fn parses_stdio_profile_caps_and_filters() {
let opts = CliOptions::parse_from([
"--stdio".to_owned(),
"--profile".to_owned(),
"default".to_owned(),
"--allow-tool".to_owned(),
"core.*".to_owned(),
"--deny-tool".to_owned(),
"*.danger*".to_owned(),
"--cap".to_owned(),
"mcp.tools.call".to_owned(),
"--log-stderr".to_owned(),
])
.unwrap();
assert_eq!(opts.transport, Transport::Stdio);
assert_eq!(
opts.capabilities,
vec![CapabilityName::new("mcp.tools.call")]
);
assert!(opts.log_stderr);
assert!(opts.profile.allows_name("core.echo"));
assert!(!opts.profile.allows_name("core.dangerous"));
}
#[test]
fn duplicate_transport_is_rejected() {
let err =
CliOptions::parse_from(["--stdio".to_owned(), "--http".to_owned(), "x:1".to_owned()])
.unwrap_err();
assert!(format!("{err}").contains("one transport"));
}
}