component-shape-mcp 0.3.4

Shared MCP protocol and schema helpers for component-shape integrations.
Documentation
use super::*;
use koruma_collection::numeric::RangeValidation;

#[derive(crate::McpToolInput, ::koruma::Koruma)]
struct BoundedArgs {
    #[koruma(RangeValidation::<_>.min(1).max(5))]
    count: u32,
}

fn definition(name: &str) -> crate::McpTypedTool<BoundedArgs> {
    crate::tool_definition_for_input::<BoundedArgs>(
        name,
        None,
        None,
        Some(crate::McpSchema::integer()),
    )
    .expect("paired tool definition")
}

fn response(args: BoundedArgs) -> crate::ToolCallResult {
    assert!(
        (1..=5).contains(&args.count),
        "invalid input reached handler"
    );
    crate::tool_structured_result(json!(args.count))
}

fn assert_validation(result: crate::ToolCallResult) {
    assert_eq!(result.is_error, Some(true));
    let error = result.structured_content.expect("structured failure");
    assert_eq!(error["error"]["kind"], "validation");
    assert_eq!(error["error"]["details"][0]["field"], "count");
    assert_eq!(error["error"]["details"][0]["scope"], "field");
}

#[test]
fn koruma_registry_decodes_then_validates_before_sync_dispatch() {
    let mut registry = crate::McpToolRegistry::new();
    registry
        .add_koruma_tool(definition("bounded"), response)
        .expect("register");
    assert_validation(registry.call_tool("bounded", Some(json!({"count": 0}))));
    let valid = registry.call_tool("bounded", Some(json!({"count": 3})));
    assert_eq!(valid.is_error, Some(false));
    assert_eq!(valid.structured_content, Some(json!(3)));

    for input in [
        json!({"count": "bad"}),
        json!({"count": 3, "unknown": true}),
    ] {
        let result = registry.call_tool("bounded", Some(input));
        assert_eq!(result.is_error, Some(true));
        assert_ne!(
            result.structured_content.expect("decode failure")["error"]["kind"],
            "validation"
        );
    }
    assert!(
        registry
            .add_koruma_tool(definition("bounded"), response)
            .is_err()
    );
}

#[test]
fn koruma_server_builder_validates_before_async_future_creation_and_checks_outputs() {
    let server = crate::McpServer::builder("bounded", "1")
        .koruma_tool(definition("sync"), response)
        .koruma_tool_async(definition("async"), |args| {
            assert!(
                (1..=5).contains(&args.count),
                "invalid input created handler future"
            );
            async move { response(args) }
        })
        .koruma_tool(definition("bad_output"), |_args| {
            crate::tool_structured_result(json!("bad"))
        })
        .build()
        .expect("build server");
    assert_validation(server.call_tool("sync", Some(json!({"count": 0}))));
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("runtime");
    runtime.block_on(async {
        assert_validation(
            server
                .call_tool_async("async", Some(json!({"count": 6})))
                .await,
        );
        let valid = server
            .call_tool_async("async", Some(json!({"count": 4})))
            .await;
        assert_eq!(valid.structured_content, Some(json!(4)));
        assert_eq!(valid.is_error, Some(false));
    });
    let output = server.call_tool("bad_output", Some(json!({"count": 3})));
    assert_eq!(output.is_error, Some(true));
    assert_eq!(
        output.structured_content.expect("output failure")["error"]["kind"],
        "invalid_tool_output"
    );
}

#[test]
fn koruma_validation_is_enforced_over_the_mcp_protocol() {
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("runtime");
    runtime.block_on(async {
        use rmcp::model::{CallToolRequestParams, ProtocolVersion};
        use rmcp::{ClientLifecycleMode, ClientServiceExt as _, ServiceExt as _};

        let server = crate::McpServer::builder("bounded", "1")
            .koruma_tool_async(definition("bounded"), |args| {
                assert!(
                    (1..=5).contains(&args.count),
                    "invalid protocol input reached handler"
                );
                async move { response(args) }
            })
            .build()
            .expect("server");
        let (server_transport, client_transport) = tokio::io::duplex(4096);
        let server_task = tokio::spawn(async move {
            server
                .serve(server_transport)
                .await
                .expect("serve")
                .waiting()
                .await
                .expect("join service");
        });
        let client = ()
            .serve_with_lifecycle(
                client_transport,
                ClientLifecycleMode::Discover {
                    preferred_versions: vec![ProtocolVersion::V_2026_07_28],
                },
            )
            .await
            .expect("client");
        for count in [0, 3] {
            let result = client
                .peer()
                .call_tool(
                    CallToolRequestParams::new("bounded").with_arguments(
                        json!({"count": count})
                            .as_object()
                            .expect("arguments")
                            .clone(),
                    ),
                )
                .await
                .expect("tool call");
            if count == 0 {
                assert_validation(result);
            } else {
                assert_eq!(result.structured_content, Some(json!(3)));
                assert_eq!(result.is_error, Some(false));
            }
        }
        client.cancel().await.expect("cancel client");
        server_task.await.expect("server task");
    });
}

#[test]
fn koruma_failure_with_no_issues_still_stops_validation() {
    #[derive(Default)]
    struct SilentError;
    impl koruma_core::ValidationError for SilentError {
        fn is_empty(&self) -> bool {
            false
        }
    }
    impl koruma_core::ValidationIssues for SilentError {
        fn issues(&self) -> Vec<koruma_core::ValidationIssue> {
            Vec::new()
        }
    }
    struct Rejected;
    impl koruma_core::ValidateExt for Rejected {
        type Error = SilentError;
        fn validate(&self) -> Result<(), Self::Error> {
            Err(SilentError)
        }
    }
    let error = crate::validate_koruma(&Rejected)
        .expect_err("validation must fail")
        .to_structured_value();
    assert_eq!(error["kind"], "validation");
    assert_eq!(
        error["details"],
        json!([{"scope": "form", "message": "domain validation failed"}])
    );
}

#[test]
fn canonical_issue_adapter_preserves_scopes_and_typed_runtime_parameters() {
    use koruma_core::{ValidationIssue, ValidatorParam, ValidatorParamValue as Param};
    let params = vec![
        ValidatorParam::new("flag", Param::Bool(true)),
        ValidatorParam::new("min", Param::I64(-2)),
        ValidatorParam::new("max", Param::U64(u64::MAX)),
        ValidatorParam::new("fraction", Param::F64(1.5)),
        ValidatorParam::new("text", Param::String("value".into())),
        ValidatorParam::new("optional", Param::None),
        ValidatorParam::new(
            "opaque",
            Param::Opaque {
                type_name: "DomainLimit",
            },
        ),
        ValidatorParam::new("nonfinite", Param::F64(f64::INFINITY)),
    ];
    let issue = ValidationIssue::element(
        "items",
        2,
        "DomainRule",
        Some("limit"),
        "invalid item",
        params,
    );
    let converted = crate::McpValidationIssue::from(&issue).to_value();
    assert_eq!(
        converted,
        json!({
            "scope": "element", "field": "items", "element_index": 2,
            "validator": "DomainRule", "label": "limit", "message": "invalid item",
            "params": [
                {"name": "flag", "value": true}, {"name": "min", "value": -2},
                {"name": "max", "value": u64::MAX}, {"name": "fraction", "value": 1.5},
                {"name": "text", "value": "value"}, {"name": "optional", "value": null},
                {"name": "opaque", "value": {"kind": "opaque", "type_name": "DomainLimit"}},
                {"name": "nonfinite", "value": {"kind": "f64", "value": "inf"}}
            ]
        })
    );
    assert_eq!(
        crate::McpValidationIssue::from(&ValidationIssue::form("invalid form")).to_value(),
        json!({"scope": "form", "message": "invalid form"})
    );
}