sim-lib-skill 0.1.0

SIM workspace package for sim lib skill.
Documentation
#![cfg(feature = "serve")]

use std::sync::Arc;

use sim_codec_mcp::{CAPABILITY_DENIED, McpEnvelope, McpErrorEnvelope, McpRequest, McpResponse};
use sim_kernel::{Args, Cx, DefaultFactory, EagerPolicy, Expr, ShapeRef, Symbol};
use sim_lib_mcp::{McpProfile, McpRouter, McpSession, mcp_tools_call_capability};
use sim_lib_skill::{
    FixtureBehavior, FixtureSkillSpec, FixtureTransport, SkillCard, SkillRole, install_skill_lib,
    skill_call_capability, skill_install_capability, skill_install_symbol,
    skill_specific_call_capability, skill_transport_value,
};
use sim_shape::{AnyShape, shape_value};

#[test]
fn in_process_mcp_projection_lists_by_role_and_capability() {
    let mut cx = skill_cx();
    bind_role_fixture_cards(&mut cx);
    let session = McpSession::new("skill-serve-list", McpProfile::all())
        .with_granted_capability(skill_specific_call_capability("skill.echo"))
        .with_granted_capability(skill_specific_call_capability("skill.resource"))
        .with_granted_capability(skill_specific_call_capability("skill.prompt"));
    let mut router = McpRouter::new(session);

    let tools = expect_response_result(list_request(&mut cx, &mut router, "tools/list"));
    let resources = expect_response_result(list_request(&mut cx, &mut router, "resources/list"));
    let prompts = expect_response_result(list_request(&mut cx, &mut router, "prompts/list"));

    assert_eq!(tool_names(&tools), vec!["skill.echo"]);
    assert_eq!(resource_uris(&resources), vec!["skill://skill.resource"]);
    assert_eq!(prompt_names(&prompts), vec!["skill.prompt"]);
    let output = format!("{tools:?}{resources:?}{prompts:?}");
    assert!(!output.contains("skill.hidden"));
    assert!(!output.contains("private-token"));
}

#[test]
fn in_process_mcp_tools_call_uses_skill_callable_once() {
    let mut cx = skill_cx();
    cx.grant(skill_call_capability());
    cx.grant(skill_specific_call_capability("skill.echo"));
    let fixture = bind_fixture_cards(
        &mut cx,
        vec![skill_card("skill.echo", SkillRole::Tool, "visible tool")],
    );
    let session = McpSession::new("skill-serve-call", McpProfile::all())
        .with_granted_capability(mcp_tools_call_capability())
        .with_granted_capability(skill_specific_call_capability("skill.echo"));
    let mut router = McpRouter::new(session);

    let result = expect_response_result(tools_call(
        &mut cx,
        &mut router,
        "skill.echo",
        vec![Expr::String("hello".to_owned())],
    ));

    assert_eq!(fixture.call_count(), 1);
    assert_eq!(is_error(&result), Some(false));
    assert_eq!(
        single_json_content(&result),
        Some(&Expr::List(vec![Expr::String("hello".to_owned())]))
    );
}

#[test]
fn tools_call_denial_returns_mcp_error_without_leaking_skill_metadata() {
    let mut cx = skill_cx();
    cx.grant(skill_call_capability());
    let fixture = bind_fixture_cards(
        &mut cx,
        vec![skill_card("skill.hidden", SkillRole::Tool, "private-token")],
    );
    let session = McpSession::new("skill-serve-denied", McpProfile::all())
        .with_granted_capability(mcp_tools_call_capability());
    let mut router = McpRouter::new(session);

    let error = expect_error(tools_call(
        &mut cx,
        &mut router,
        "skill.hidden",
        vec![Expr::String("blocked".to_owned())],
    ));

    assert_eq!(error.error.code, CAPABILITY_DENIED);
    assert!(!format!("{error:?}").contains("private-token"));
    assert_eq!(fixture.call_count(), 0);
}

fn skill_cx() -> Cx {
    let mut cx = Cx::new(Arc::new(EagerPolicy), Arc::new(DefaultFactory));
    install_skill_lib(&mut cx).unwrap();
    cx
}

fn bind_role_fixture_cards(cx: &mut Cx) {
    bind_fixture_cards(
        cx,
        vec![
            skill_card("skill.echo", SkillRole::Tool, "visible tool"),
            skill_card("skill.hidden", SkillRole::Tool, "private-token"),
            skill_card("skill.resource", SkillRole::Resource, "visible resource"),
            skill_card(
                "skill.hidden-resource",
                SkillRole::Resource,
                "private-token",
            ),
            skill_card("skill.prompt", SkillRole::Prompt, "visible prompt"),
            skill_card("skill.hidden-prompt", SkillRole::Prompt, "private-token"),
        ],
    );
}

fn bind_fixture_cards(cx: &mut Cx, cards: Vec<SkillCard>) -> Arc<FixtureTransport> {
    let fixture = Arc::new(fixture_transport());
    install_cards(cx, fixture.clone(), cards);
    fixture
}

fn install_cards(cx: &mut Cx, fixture: Arc<FixtureTransport>, cards: Vec<SkillCard>) {
    cx.grant(skill_install_capability());
    let mut values = vec![skill_transport_value(cx, fixture).unwrap()];
    values.extend(cards.into_iter().map(|card| card.value(cx).unwrap()));
    cx.call_function(&skill_install_symbol(), Args::new(values))
        .unwrap();
}

fn fixture_transport() -> FixtureTransport {
    let fixture = FixtureTransport::new("serve-fixture");
    fixture.insert("echo", FixtureBehavior::EchoArgs).unwrap();
    fixture
}

fn skill_card(id: &str, role: SkillRole, description: &str) -> SkillCard {
    let mut card = SkillCard::fixture(FixtureSkillSpec {
        id: id.to_owned(),
        symbol: Symbol::qualified("skill", id.to_owned()),
        title: id.to_owned(),
        description: description.to_owned(),
        input_shape: any_shape("args"),
        output_shape: any_shape("result"),
        transport_id: "serve-fixture".to_owned(),
        operation: "echo".to_owned(),
    });
    card.roles = vec![role];
    card
}

fn list_request(cx: &mut Cx, router: &mut McpRouter, method: &str) -> McpEnvelope {
    router
        .handle(
            cx,
            McpEnvelope::Request(McpRequest {
                id: Expr::String(method.to_owned()),
                method: method.to_owned(),
                params: Expr::Nil,
            }),
        )
        .unwrap()
        .unwrap()
}

fn tools_call(
    cx: &mut Cx,
    router: &mut McpRouter,
    name: &str,
    arguments: Vec<Expr>,
) -> McpEnvelope {
    router
        .handle(
            cx,
            McpEnvelope::Request(McpRequest {
                id: Expr::String(format!("call-{name}")),
                method: "tools/call".to_owned(),
                params: Expr::Map(vec![
                    field("name", Expr::String(name.to_owned())),
                    field("arguments", Expr::List(arguments)),
                ]),
            }),
        )
        .unwrap()
        .unwrap()
}

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 tool_names(result: &Expr) -> Vec<&str> {
    list_field(result, "tools")
        .iter()
        .filter_map(|tool| string_field(tool, "name"))
        .collect()
}

fn resource_uris(result: &Expr) -> Vec<&str> {
    list_field(result, "resources")
        .iter()
        .filter_map(|resource| string_field(resource, "uri"))
        .collect()
}

fn prompt_names(result: &Expr) -> Vec<&str> {
    list_field(result, "prompts")
        .iter()
        .filter_map(|prompt| string_field(prompt, "name"))
        .collect()
}

fn list_field<'a>(expr: &'a Expr, name: &str) -> &'a [Expr] {
    match field_value(expr, name) {
        Some(Expr::List(items)) => items,
        _ => &[],
    }
}

fn is_error(result: &Expr) -> Option<bool> {
    match field_value(result, "isError") {
        Some(Expr::Bool(value)) => Some(*value),
        _ => None,
    }
}

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, "content") {
        Some(Expr::List(items)) if items.len() == 1 => items.first(),
        _ => None,
    }
}

fn string_field<'a>(expr: &'a Expr, name: &str) -> Option<&'a str> {
    field_value(expr, name).and_then(|value| match value {
        Expr::String(text) => Some(text.as_str()),
        _ => 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)
    })
}

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

use sim_value::build::entry as field;