sim-lib-mcp 0.1.0-rc.1

Library-only MCP surface projection for SIM.
Documentation
use std::sync::Arc;

use sim_codec_mcp::{INVALID_PARAMS, McpEnvelope, McpErrorEnvelope, McpRequest, McpResponse};
use sim_kernel::{Args, CapabilityName, Cx, Expr, ShapeRef, Symbol};
use sim_shape::{AnyShape, shape_value};

use crate::{
    McpAnnotation, McpExportFacet, McpNativeCard, McpProfile, McpRouter, McpSession,
    install_mcp_lib, mcp_resources_read_capability, resources_symbol,
};

#[test]
fn resources_list_filters_roles_capabilities_and_redacts() {
    let mut cx = cx();
    let visible = CapabilityName::new("fixture.resource.visible");
    let session = McpSession::new("resources", McpProfile::all())
        .with_granted_capability(visible.clone())
        .with_native_cards(vec![
            native_resource(
                "visible.resource",
                "sim://browse/fixture/visible",
                vec![visible],
            ),
            native_resource(
                "blocked.resource",
                "sim://browse/fixture/blocked",
                vec![CapabilityName::new("fixture.resource.blocked")],
            ),
            native_resource("bad.resource", "file://fixture/bad", Vec::new()),
            native_tool("visible.tool"),
        ]);
    let mut router = McpRouter::new(session);

    let result = resources_list_result(&mut cx, &mut router);
    let text = format!("{result:?}");

    assert_eq!(resource_uris(&result), vec!["sim://browse/fixture/visible"]);
    assert!(!text.contains("fixture.resource.visible"));
    assert!(!text.contains("private-token"));
}

#[test]
fn resources_read_native_sim_resource_does_not_call_subject() {
    let mut cx = cx();
    let read_cap = CapabilityName::new("fixture.resource.read");
    let card = native_resource(
        "safe.resource",
        "sim://browse/fixture/safe",
        vec![read_cap.clone()],
    );
    let session = McpSession::new("resources", McpProfile::all())
        .with_granted_capability(mcp_resources_read_capability())
        .with_granted_capability(read_cap)
        .with_native_cards(vec![card]);
    let mut router = McpRouter::new(session);

    let result = expect_response_result(resources_read(
        &mut cx,
        &mut router,
        "sim://browse/fixture/safe",
    ));

    let json = single_json_content(&result).expect("resource read should return json content");
    assert_eq!(
        field_value(json, "name"),
        Some(&Expr::String("safe.resource".to_owned()))
    );
    assert!(!format!("{result:?}").contains("private-token"));
}

#[test]
fn resources_read_unknown_uri_returns_structured_not_found() {
    let mut cx = cx();
    let mut router = McpRouter::fixture();

    let error = expect_error(resources_read(
        &mut cx,
        &mut router,
        "sim://browse/fixture/missing",
    ));

    assert_eq!(error.error.code, INVALID_PARAMS);
    assert_eq!(
        field_value(&error.error.data, "code"),
        Some(&Expr::String("not-found".to_owned()))
    );
    assert_eq!(
        field_value(&error.error.data, "kind"),
        Some(&Expr::String("resource".to_owned()))
    );
    assert_eq!(
        field_value(&error.error.data, "id"),
        Some(&Expr::String("sim://browse/fixture/missing".to_owned()))
    );
}

#[test]
fn resources_lisp_function_returns_empty_fixture_list() {
    let mut cx = cx();
    install_mcp_lib(&mut cx).unwrap();

    let result = cx
        .call_function(&resources_symbol(), Args::default())
        .unwrap()
        .object()
        .as_expr(&mut cx)
        .unwrap();

    assert_eq!(resource_uris(&result), Vec::<String>::new());
}

#[cfg(feature = "skill")]
#[test]
fn resources_read_skill_resource_uses_bound_skill_callable_once() {
    let mut cx = skill_cx();
    cx.grant(sim_lib_skill::skill_call_capability());
    cx.grant(sim_lib_skill::skill_specific_call_capability(
        "skill.resource",
    ));
    let fixture = bind_skill(
        &mut cx,
        skill_card("skill.resource", sim_lib_skill::SkillRole::Resource),
    );
    let session = McpSession::new("skill-resource", McpProfile::all())
        .with_granted_capability(mcp_resources_read_capability())
        .with_granted_capability(sim_lib_skill::skill_specific_call_capability(
            "skill.resource",
        ));
    let mut router = McpRouter::new(session);

    let result = expect_response_result(resources_read(
        &mut cx,
        &mut router,
        "skill://skill.resource",
    ));

    assert_eq!(fixture.call_count(), 1);
    assert_eq!(single_json_content(&result), Some(&Expr::List(Vec::new())));
}

fn resources_list_result(cx: &mut Cx, router: &mut McpRouter) -> Expr {
    expect_response_result(
        router
            .handle(
                cx,
                McpEnvelope::Request(McpRequest {
                    id: Expr::String("resources".to_owned()),
                    method: "resources/list".to_owned(),
                    params: Expr::Nil,
                }),
            )
            .unwrap()
            .unwrap(),
    )
}

fn resources_read(cx: &mut Cx, router: &mut McpRouter, uri: &str) -> McpEnvelope {
    router
        .handle(
            cx,
            McpEnvelope::Request(McpRequest {
                id: Expr::String(format!("read-{uri}")),
                method: "resources/read".to_owned(),
                params: Expr::Map(vec![field("uri", Expr::String(uri.to_owned()))]),
            }),
        )
        .unwrap()
        .unwrap()
}

fn native_resource(name: &str, uri: &str, capabilities: Vec<CapabilityName>) -> McpNativeCard {
    let mut card = McpNativeCard::new(
        Symbol::qualified("fixture", format!("missing-{name}")),
        "Native resource",
    )
    .with_shapes(any_shape("args"), any_shape("result"))
    .exported(
        McpExportFacet::resource()
            .with_name(name.to_owned())
            .with_uri(uri.to_owned())
            .with_annotation(McpAnnotation::private(
                Symbol::new("secret"),
                Expr::String("private-token".to_owned()),
            )),
    );
    for capability in capabilities {
        card = card.with_capability(capability);
    }
    card
}

fn native_tool(name: &str) -> McpNativeCard {
    McpNativeCard::new(Symbol::qualified("fixture", name.to_owned()), "Native tool")
        .with_shapes(any_shape("args"), any_shape("result"))
        .exported(McpExportFacet::tool().with_name(name.to_owned()))
}

fn expect_response_result(envelope: McpEnvelope) -> Expr {
    let McpEnvelope::Response(McpResponse { result, .. }) = envelope else {
        panic!("expected MCP response envelope");
    };
    result
}

fn expect_error(envelope: McpEnvelope) -> McpErrorEnvelope {
    let McpEnvelope::Error(error) = envelope else {
        panic!("expected MCP error envelope");
    };
    error
}

fn resource_uris(result: &Expr) -> Vec<String> {
    resources(result)
        .iter()
        .filter_map(|resource| field_value(resource, "uri"))
        .filter_map(|value| match value {
            Expr::String(uri) => Some(uri.clone()),
            _ => None,
        })
        .collect()
}

fn resources(result: &Expr) -> &[Expr] {
    let Some(Expr::List(resources)) = field_value(result, "resources") else {
        panic!("resources/list result should contain resources list");
    };
    resources
}

fn single_json_content(result: &Expr) -> Option<&Expr> {
    single_content(result).and_then(|content| field_value(content, "json"))
}

fn single_content(result: &Expr) -> Option<&Expr> {
    match field_value(result, "contents") {
        Some(Expr::List(items)) if items.len() == 1 => items.first(),
        _ => None,
    }
}

fn field_value<'a>(expr: &'a Expr, name: &str) -> Option<&'a Expr> {
    let Expr::Map(fields) = expr else {
        return None;
    };
    fields.iter().find_map(|(key, value)| {
        let key = match key {
            Expr::Symbol(symbol) if symbol.namespace.is_none() => symbol.name.as_ref(),
            Expr::String(text) => text.as_str(),
            _ => return None,
        };
        (key == name).then_some(value)
    })
}

use sim_kernel::testing::eager_cx as cx;

fn any_shape(name: &str) -> ShapeRef {
    shape_value(
        Symbol::qualified("mcp-test", name.to_owned()),
        Arc::new(AnyShape),
    )
}

use sim_value::build::entry as field;

#[cfg(feature = "skill")]
fn skill_cx() -> Cx {
    let mut cx = cx();
    sim_lib_skill::install_skill_lib(&mut cx).unwrap();
    cx
}

#[cfg(feature = "skill")]
fn skill_card(id: &str, role: sim_lib_skill::SkillRole) -> sim_lib_skill::SkillCard {
    let mut card = sim_lib_skill::SkillCard::fixture(sim_lib_skill::FixtureSkillSpec {
        id: id.to_owned(),
        symbol: Symbol::qualified("skill", id.to_owned()),
        title: "Fixture Skill".to_owned(),
        description: "A skill projected through resources/read.".to_owned(),
        input_shape: any_shape("skill-args"),
        output_shape: any_shape("skill-result"),
        transport_id: "resources-transport".to_owned(),
        operation: "echo".to_owned(),
    });
    card.roles = vec![role];
    card
}

#[cfg(feature = "skill")]
fn bind_skill(cx: &mut Cx, card: sim_lib_skill::SkillCard) -> Arc<sim_lib_skill::FixtureTransport> {
    let registry = sim_lib_skill::skill_registry(cx).unwrap();
    let fixture = Arc::new(sim_lib_skill::FixtureTransport::new("resources-transport"));
    fixture
        .insert("echo", sim_lib_skill::FixtureBehavior::EchoArgs)
        .unwrap();
    registry.install_transport(fixture.clone()).unwrap();
    registry.bind_card(cx, card).unwrap();
    fixture
}