sim-mcp-server 0.1.0

SIM workspace package for sim mcp server.
Documentation
use std::io::{BufReader, Cursor};
use std::sync::{
    Arc,
    atomic::{AtomicUsize, Ordering},
};

use sim_codec::encode_with_codec;
use sim_codec_mcp::{McpCodecLib, McpEnvelope, McpRequest, envelope_to_expr};
use sim_kernel::{
    AbiVersion, Args, Callable, Cx, DefaultFactory, EagerPolicy, EncodeOptions, Export, Expr, Lib,
    LibManifest, LibTarget, Linker, LoadCx, Object, ObjectCompat, Result, Symbol, Value, Version,
};
use sim_lib_mcp::stdio::{StdioOptions, run_stdio};
use sim_lib_mcp::{
    McpExportFacet, McpNativeCard, McpProfile, McpRouter, McpSession, mcp_tools_call_capability,
};

#[test]
fn stdio_ping_has_protocol_only_stdout() {
    let mut cx = test_cx();
    let mut router = McpRouter::fixture();
    let (stdout, stderr) = run_loop(
        &mut cx,
        &mut router,
        r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#,
    );

    assert_eq!(stderr, "");
    assert_eq!(stdout.lines().count(), 1);
    assert!(stdout.contains(r#""jsonrpc":"2.0""#));
    assert!(stdout.contains(r#""id":1"#));
    assert!(stdout.contains(r#""result""#));
    assert!(stdout.contains(r#""entries":[]"#));
}

#[test]
fn stdio_malformed_input_returns_parse_error() {
    let mut cx = test_cx();
    let mut router = McpRouter::fixture();
    let (stdout, stderr) = run_loop(&mut cx, &mut router, "{bad json");

    assert_eq!(stderr, "");
    assert_eq!(stdout.lines().count(), 1);
    assert!(stdout.contains(r#""code":-32700"#));
    assert!(stdout.contains(r#""message":"parse error""#));
}

#[test]
fn stdio_eof_exits_cleanly() {
    let mut cx = test_cx();
    let mut router = McpRouter::fixture();
    let (stdout, stderr) = run_loop(&mut cx, &mut router, "");

    assert_eq!(stdout, "");
    assert_eq!(stderr, "");
}

#[test]
fn stdio_initialize_tools_call_and_shutdown_work() {
    let mut cx = test_cx();
    let symbol = Symbol::qualified("fixture", "echo");
    let counter = install_counter(&mut cx, symbol.clone());
    let session = McpSession::new("stdio-test", McpProfile::all())
        .with_granted_capability(mcp_tools_call_capability())
        .with_native_cards(vec![
            McpNativeCard::new(symbol, "Fixture echo")
                .exported(McpExportFacet::tool().with_name("fixture.echo")),
        ]);
    let mut router = McpRouter::new(session);
    let input = [
        request_frame(&mut cx, 1, "initialize", Expr::Nil),
        request_frame(&mut cx, 2, "tools/list", Expr::Nil),
        request_frame(&mut cx, 3, "tools/call", tools_call_params()),
        request_frame(&mut cx, 4, "shutdown", Expr::Nil),
    ]
    .join("\n");
    let (stdout, stderr) = run_loop(&mut cx, &mut router, &input);

    assert_eq!(stderr, "");
    assert_eq!(stdout.lines().count(), 4);
    assert!(stdout.contains(r#""serverInfo""#));
    assert!(stdout.contains(r#""tools""#));
    assert!(stdout.contains(r#""isError""#));
    assert!(stdout.contains(r#""value":false"#));
    assert_eq!(counter.call_count(), 1);
}

fn run_loop(cx: &mut Cx, router: &mut McpRouter, input: &str) -> (String, String) {
    let reader = BufReader::new(Cursor::new(input.as_bytes()));
    let mut stdout = Vec::new();
    let mut stderr = Vec::new();
    run_stdio(
        cx,
        router,
        reader,
        &mut stdout,
        &mut stderr,
        StdioOptions { log_stderr: false },
    )
    .unwrap();
    (
        String::from_utf8(stdout).unwrap(),
        String::from_utf8(stderr).unwrap(),
    )
}

fn test_cx() -> Cx {
    let mut cx = Cx::new(Arc::new(EagerPolicy), Arc::new(DefaultFactory));
    let codec = McpCodecLib::new(cx.registry_mut().fresh_codec_id());
    cx.load_lib(&codec).unwrap();
    cx
}

fn request_frame(cx: &mut Cx, id: i64, method: &str, params: Expr) -> String {
    let expr = envelope_to_expr(&McpEnvelope::Request(McpRequest {
        id: Expr::Number(sim_kernel::NumberLiteral {
            domain: Symbol::qualified("numbers", "i64"),
            canonical: id.to_string(),
        }),
        method: method.to_owned(),
        params,
    }));
    encode_with_codec(
        cx,
        &Symbol::qualified("codec", "mcp"),
        &expr,
        EncodeOptions::default(),
    )
    .unwrap()
    .into_text()
    .unwrap()
}

fn tools_call_params() -> Expr {
    Expr::Map(vec![
        field("name", Expr::String("fixture.echo".to_owned())),
        field("arguments", Expr::List(vec![Expr::String("ok".to_owned())])),
    ])
}

use sim_value::build::entry as field;

fn install_counter(cx: &mut Cx, symbol: Symbol) -> Arc<CounterFunction> {
    let function = Arc::new(CounterFunction::new());
    cx.load_lib(&CounterLib {
        id: Symbol::qualified("mcp-stdio-test", symbol.to_string()),
        symbol,
        function: function.clone(),
    })
    .unwrap();
    function
}

struct CounterFunction {
    calls: AtomicUsize,
}

impl CounterFunction {
    fn new() -> Self {
        Self {
            calls: AtomicUsize::new(0),
        }
    }

    fn call_count(&self) -> usize {
        self.calls.load(Ordering::SeqCst)
    }
}

impl Object for CounterFunction {
    fn display(&self, _cx: &mut Cx) -> Result<String> {
        Ok("#<mcp-stdio-counter>".to_owned())
    }

    fn as_any(&self) -> &dyn std::any::Any {
        self
    }
}

impl ObjectCompat for CounterFunction {
    fn as_callable(&self) -> Option<&dyn Callable> {
        Some(self)
    }
}

impl Callable for CounterFunction {
    fn call(&self, cx: &mut Cx, args: Args) -> Result<Value> {
        self.calls.fetch_add(1, Ordering::SeqCst);
        args.values()
            .first()
            .cloned()
            .map(Ok)
            .unwrap_or_else(|| cx.factory().nil())
    }
}

struct CounterLib {
    id: Symbol,
    symbol: Symbol,
    function: Arc<CounterFunction>,
}

impl Lib for CounterLib {
    fn manifest(&self) -> LibManifest {
        LibManifest {
            id: self.id.clone(),
            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: self.symbol.clone(),
                function_id: None,
            }],
        }
    }

    fn load(&self, cx: &mut LoadCx, linker: &mut Linker<'_>) -> Result<()> {
        linker.function_value(
            self.symbol.clone(),
            cx.factory().opaque(self.function.clone())?,
        )?;
        Ok(())
    }
}