component-shape-mcp 0.3.2

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

#[test]
fn explicit_any_value_accepts_unconstrained_json() {
    for raw in [
        Value::Null,
        json!(true),
        json!(42),
        json!("text"),
        json!(["nested", null]),
        json!({ "nested": { "value": true } }),
    ] {
        let decoded = <McpAny as crate::McpToolValue>::from_tool_value("payload", raw.clone())
            .expect("McpAny should accept any JSON value");

        assert_eq!(decoded.into_value(), raw);
    }
}

#[test]
fn tool_value_trait_pairs_schema_and_strict_decode() {
    let schema = <McpRange<u32> as crate::McpToolValue>::tool_value_schema();
    assert_eq!(schema["type"], "object");
    assert_eq!(schema["properties"]["min"]["anyOf"][0]["type"], "integer");

    let value = <McpRange<u32> as crate::McpToolValue>::from_tool_value(
        "window",
        json!({ "min": 1, "max": null }),
    )
    .expect("range value should decode");
    assert_eq!(value, McpRange::new(Some(1), None));

    let error = <String as crate::McpToolValue>::from_tool_value("title", Value::Null)
        .expect_err("null should not decode as a present string");
    assert_eq!(
        error,
        crate::McpToolError::UnexpectedNull {
            field: "title".to_string(),
        }
    );

    let value = <Option<String> as crate::McpToolValue>::from_tool_value("title", Value::Null)
        .expect("nullable schema should decode null");
    assert_eq!(value, None);
}

#[test]
fn tool_value_trait_enforces_closed_object_schema_before_serde() {
    #[derive(Debug, serde::Deserialize, crate::McpJsonSchema, PartialEq)]
    struct Preferences {
        #[mcp(rename = "email", alias = "emailUpdates")]
        email_updates: bool,
        #[serde(default)]
        topics: Vec<String>,
    }

    let value = <Preferences as crate::McpToolValue>::from_tool_value(
        "preferences",
        json!({
            "email": true,
            "topics": ["rust"]
        }),
    )
    .expect("alias should decode through serde after schema validation");
    assert_eq!(
        value,
        Preferences {
            email_updates: true,
            topics: vec!["rust".to_string()],
        }
    );

    let error = <Preferences as crate::McpToolValue>::from_tool_value(
        "preferences",
        json!({
            "email": true,
            "emailUpdates": false
        }),
    )
    .expect_err("primary and alias should be rejected as duplicate input");
    assert_eq!(
        error,
        crate::McpToolError::DuplicateField {
            field: "preferences.email".to_string(),
        }
    );

    let error = <Preferences as crate::McpToolValue>::from_tool_value(
        "preferences",
        json!({
            "email": true,
            "unexpected": true
        }),
    )
    .expect_err("unknown nested field should be rejected before serde");
    assert_eq!(
        error,
        crate::McpToolError::UnknownField {
            field: "preferences.unexpected".to_string(),
        }
    );

    let error = <Preferences as crate::McpToolValue>::from_tool_value("preferences", json!({}))
        .expect_err("missing required nested field should be rejected before serde");
    assert_eq!(
        error,
        crate::McpToolError::MissingField {
            field: "preferences.email".to_string(),
        }
    );
}

#[test]
fn tool_value_trait_normalizes_mcp_enum_aliases_before_serde() {
    #[derive(Debug, serde::Deserialize, crate::McpJsonSchema, PartialEq)]
    enum IssueState {
        Open,
        #[mcp(rename = "in-review", alias = "reviewing")]
        InReview,
    }

    let value = <IssueState as crate::McpToolValue>::from_tool_value("state", json!("reviewing"))
        .expect("mcp enum alias should decode");

    assert_eq!(value, IssueState::InReview);
}

#[test]
fn tool_value_trait_applies_string_keyed_object_value_schemas() {
    #[derive(Debug, serde::Deserialize, crate::McpJsonSchema, PartialEq)]
    enum IssueState {
        Open,
        #[mcp(rename = "in-review", alias = "reviewing")]
        InReview,
    }

    let states =
        <std::collections::BTreeMap<String, IssueState> as crate::McpToolValue>::from_tool_value(
            "states",
            json!({
                "issue": "reviewing"
            }),
        )
        .expect("additional property values should be normalized through their schema");
    let expected = std::collections::BTreeMap::from([("issue".to_string(), IssueState::InReview)]);
    assert_eq!(states, expected);

    #[derive(Debug, serde::Deserialize, crate::McpJsonSchema, PartialEq)]
    struct Preferences {
        #[mcp(rename = "email", alias = "emailUpdates")]
        email_updates: bool,
    }

    let error =
        <std::collections::BTreeMap<String, Preferences> as crate::McpToolValue>::from_tool_value(
            "preferences",
            json!({
                "team": {
                    "email": true,
                    "unexpected": true
                }
            }),
        )
        .expect_err("nested unknown fields should be rejected through additionalProperties schema");
    assert_eq!(
        error,
        crate::McpToolError::UnknownField {
            field: "preferences.team.unexpected".to_string(),
        }
    );

    let error =
        <std::collections::BTreeMap<String, Preferences> as crate::McpToolValue>::from_tool_value(
            "preferences",
            json!({
                "team": {}
            }),
        )
        .expect_err(
            "nested required fields should be enforced through additionalProperties schema",
        );
    assert_eq!(
        error,
        crate::McpToolError::MissingField {
            field: "preferences.team.email".to_string(),
        }
    );
}

#[test]
fn mcp_tool_call_normalizes_arguments() {
    let call = crate::McpToolCall::from_value(None).expect("missing arguments are empty");
    assert!(call.arguments().is_empty());

    let call = crate::McpToolCall::from_value(Some(json!({ "value": 42 })))
        .expect("object arguments are accepted");
    assert_eq!(call.arguments()["value"], 42);

    let error = crate::McpToolCall::from_value(Some(json!(42)))
        .expect_err("non-object arguments should fail");
    assert_eq!(error, crate::McpToolError::ArgumentsMustBeObject);
}

#[test]
fn mcp_arguments_decodes_tool_values_and_rejects_unknown_fields() {
    let call = crate::McpToolCall::from_value(Some(json!({
        "name": "Ada",
        "nickname": null,
        "limit": 2,
        "unused": true
    })))
    .expect("object arguments are accepted");
    let mut arguments = call.into_arguments();

    let name = arguments
        .take_required_tool_value::<String>("name")
        .expect("name should decode");
    let nickname = arguments
        .take_present_tool_value::<Option<String>>("nickname")
        .expect("nickname should decode");
    let limit = arguments
        .take_present_tool_value::<usize>("limit")
        .expect("limit should decode");

    assert_eq!(name, "Ada");
    assert_eq!(nickname, Some(None));
    assert_eq!(limit, Some(2));
    assert_eq!(
        arguments.finish(),
        Err(crate::McpToolError::UnknownField {
            field: "unused".to_string()
        })
    );
}

#[test]
fn tool_value_usize_rejects_negative_values() {
    assert_eq!(
        <usize as crate::McpToolValue>::from_tool_value("limit", json!(10))
            .expect("usize should decode"),
        10
    );

    let error = <usize as crate::McpToolValue>::from_tool_value("limit", json!(-1))
        .expect_err("negative should fail");
    assert!(matches!(error, crate::McpToolError::DecodeField { field, .. } if field == "limit"));
}

#[test]
fn closed_schema_validation_covers_composites_arrays_and_nested_objects() {
    let object_a = json!({
        "type": "object",
        "required": ["a"],
        "additionalProperties": false,
        "properties": { "a": { "type": "integer" } }
    });
    let object_b = json!({
        "type": ["null", "object"],
        "required": ["b"],
        "additionalProperties": false,
        "properties": { "b": { "type": "integer" } }
    });

    for schema in [
        json!({ "anyOf": [false, object_a, object_b] }),
        json!({ "oneOf": [object_a, object_b] }),
    ] {
        crate::validate_value_against_closed_schema("input", &schema, &json!({ "b": 1 }))
            .expect("one applicable object branch should accept the value");
        assert!(
            crate::validate_value_against_closed_schema("input", &schema, &json!({ "c": 1 }))
                .is_err()
        );
    }

    let all_of = json!({
        "allOf": [
            { "type": "object", "required": ["a"] },
            { "type": "object", "required": ["b"] }
        ]
    });
    crate::validate_value_against_closed_schema("", &all_of, &json!({ "a": 1, "b": 2 }))
        .expect("all object branches should accept the value");

    let array = json!({
        "anyOf": [{ "type": "array" }],
        "items": {
            "type": "object",
            "required": ["value"],
            "additionalProperties": false,
            "properties": { "value": { "type": "string" } }
        },
        "prefixItems": [
            {
                "type": "object",
                "required": ["value"],
                "additionalProperties": false,
                "properties": { "value": { "type": "string" } }
            }
        ]
    });
    crate::validate_value_against_closed_schema("items", &array, &json!([{ "value": "ok" }]))
        .expect("nested array objects should validate");
    assert!(
        crate::validate_value_against_closed_schema("items", &array, &json!([{ "extra": true }]))
            .is_err()
    );

    assert!(crate::validate_value_against_closed_schema("value", &json!(true), &json!(1)).is_ok());
    assert!(crate::validate_value_against_closed_schema("value", &json!({}), &Value::Null).is_ok());
}