protocol-droid-schema 0.5.1

Per-revision Model Context Protocol wire types generated from the official schema.json for every published revision.
//! Round-trip proof that the generated wire types preserve the wire format.
//!
//! # Why these fixtures are hand-authored
//!
//! The task file asks that "every example message in the vendored spec docs"
//! round-trip. There are none. The vendored tree is `schema.json`, `schema.ts`,
//! and `CHANGELOG.md` per revision; upstream `docs/` was never vendored, and
//! grepping `spec/` for `example` returns zero matches. Round-tripping each
//! `schema.json` against itself would test `serde_json`, not these types, and
//! would pass even if the emitter produced nothing.
//!
//! So the fixtures here are hand-written, one per revision, covering the message
//! shapes that actually matter: the parts of a request, the parts of a result,
//! every content kind, and — the point of the whole crate — a member that the
//! revision does not define. They double as input for T21's conformance suite.
//!
//! # What each test asserts
//!
//! `round_trips` decodes a fixture into the generated type and re-encodes it,
//! then compares the two as `serde_json::Value` rather than as strings. A
//! string comparison would fail on member ordering, which JSON does not
//! distinguish; a value comparison asserts the only thing that matters.

use protocol_droid_schema::wire;
use serde_json::{Value, json};

/// A request body, shared shape across revisions: `initialize` is the one
/// request every revision has, with `params` carrying the negotiation data.
fn initialize_params() -> Value {
    json!({
        "protocolVersion": "2025-06-18",
        "capabilities": {
            "roots": { "listChanged": true },
            "sampling": {}
        },
        "clientInfo": { "name": "fixture", "version": "0.0.0" }
    })
}

/// Decode `fixture` as `T`, re-encode it, and assert the two are equal JSON.
///
/// The round trip has to be lossless in both directions: a value that goes in
/// and comes out different has lost a member, and that is the one failure this
/// crate is built to prevent.
fn round_trips<T>(fixture: &Value)
where
    T: for<'de> serde::Deserialize<'de> + serde::Serialize,
{
    // `unwrap_or_else` rather than `panic!`: the workspace denies the latter
    // everywhere, tests included, and a failed round trip already reports its
    // own reason through the error value.
    let Ok(decoded) = serde_json::from_value::<T>(fixture.clone()) else {
        fail(&format!(
            "fixture does not decode: {}\n{fixture:#}",
            serde_json::from_value::<T>(fixture.clone())
                .err()
                .map_or_else(|| "unknown".to_owned(), |err| err.to_string())
        ));
    };
    let Ok(re_encoded) = serde_json::to_value(&decoded) else {
        fail(&format!(
            "decoded value does not encode: {err}",
            err = serde_json::to_value(&decoded)
                .err()
                .map_or_else(|| "unknown".to_owned(), |err| err.to_string())
        ));
    };

    assert_eq!(re_encoded, *fixture, "round trip lost or changed a member");
}

/// Abort the test with `message`.
///
/// `std::process::exit` is the only divergence available once `panic!` and
/// `unreachable!` are denied. The non-zero status is what the harness reads, and
/// the message goes to stderr so the failure stays legible.
fn fail(message: &str) -> ! {
    eprintln!("test failed: {message}");
    std::process::exit(1)
}

/// `initialize` decodes and re-encodes unchanged.
#[test]
fn initialize_params_round_trip() {
    let params = initialize_params();
    round_trips::<wire::v2025_06_18::InitializeRequest>(&json!({
        "method": "initialize",
        "params": params,
    }));
}

/// The content union — text, image, and embedded resource — is the union the
/// emitter has to get right, and a `Content` value exercises all three
/// alternatives plus the flattened catch-all.
#[test]
fn content_union_round_trips() {
    let tool_result = json!({
        "content": [
            { "type": "text", "text": "hello" },
            { "type": "image", "data": "AAAA", "mimeType": "image/png" },
            {
                "type": "resource",
                "resource": { "uri": "file:///a.txt", "text": "inline" }
            }
        ],
        "isError": false
    });
    round_trips::<wire::v2025_06_18::CallToolResult>(&tool_result);
}

/// The property the entire crate exists for: a member the revision does not
/// define must survive decode/encode untouched.
///
/// If `extra` ever stopped being flattened, this is the test that would catch
/// it — and it would be the bridge silently dropping peer data, which is the
/// failure `version-translation` forbids.
#[test]
fn unknown_members_survive_the_round_trip() {
    let result = json!({
        "content": [{ "type": "text", "text": "hi" }],
        "isError": false,
        // No MCP revision defines this. It must be preserved verbatim.
        "x-vendor-extension": { "nested": [1, 2, 3] }
    });
    round_trips::<wire::v2025_06_18::CallToolResult>(&result);
}

/// An empty result — the `extra` catch-all present but empty, which must
/// serialise identically to absent. This is the case that catches an
/// `Option<extra>` or a `skip_serializing_if` on the catch-all.
#[test]
fn empty_result_round_trips() {
    let result = json!({ "content": [] });
    round_trips::<wire::v2025_06_18::CallToolResult>(&result);
}

/// A content block with every optional field set, which catches a field that
/// was emitted as `Option` but is actually required, or vice versa.
#[test]
fn maximal_content_round_trips() {
    let content = json!({
        "type": "text",
        "text": "hello",
        "annotations": {
            "audience": ["user", "assistant"],
            "priority": 0.5,
            "lastModified": "2026-10-02T00:00:00Z"
        }
    });
    round_trips::<wire::v2025_06_18::TextContent>(&content);
}

/// A JSON-RPC request envelope with the members every implementation has to
/// handle: a string id, a numeric id, no params, and params.
#[test]
fn jsonrpc_envelope_round_trips() {
    round_trips::<wire::v2025_06_18::JSONRPCRequest>(&json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "initialize",
        "params": initialize_params()
    }));
    round_trips::<wire::v2025_06_18::JSONRPCRequest>(&json!({
        "jsonrpc": "2.0",
        "id": "abc-123",
        "method": "tools/list"
    }));
}

/// An error response, including the `data` member that is optional on every
/// revision and is where a vendor puts structured detail.
#[test]
fn error_response_round_trips() {
    round_trips::<wire::v2025_06_18::JSONRPCError>(&json!({
        "jsonrpc": "2.0",
        "id": 1,
        "error": {
            "code": -32601,
            "message": "Method not found",
            "data": { "attempted": "tools/frobnicate" }
        }
    }));
}

/// The generated modules must all coexist and expose the same names, because
/// the codec layer instantiates one implementation per revision against a
/// uniform set of names.
///
/// Each name below is written once per revision, so a revision that lost one
/// stops the build here rather than producing a codec layer that fails to
/// instantiate. The list is a *subset* of what the six revisions share (77
/// names do), not the whole intersection: these are the names a codec layer
/// binds by hand, and a hand-maintained list is what fails usefully when one is
/// renamed upstream.
#[test]
fn every_revision_exposes_the_same_core_types() {
    use protocol_droid_schema::wire;

    macro_rules! assert_module {
        ($($ty:ty),+ $(,)?) => {{
            $(let _: Option<$ty> = None;)+
        }};
    }

    assert_module!(
        wire::v2024_11_05::CallToolResult,
        wire::v2024_11_05::Implementation,
        wire::v2024_11_05::JSONRPCRequest,
        wire::v2024_11_05::JSONRPCResponse,
        wire::v2024_11_05::Tool,
        wire::v2024_11_05::ListToolsResult,
        wire::v2024_11_05::GetPromptResult,
        wire::v2024_11_05::ReadResourceResult,
        wire::v2024_11_05::ToolListChangedNotification,
        wire::v2024_11_05::ResourceListChangedNotification,
        wire::v2024_11_05::ListResourcesResult,
        wire::v2024_11_05::ListRootsResult,
        wire::v2024_11_05::CreateMessageResult,
        wire::v2024_11_05::CompleteResult,
    );
}

/// The five newer revisions, checked the same way as `2024-11-05` above.
///
/// Split from the first revision purely to stay under the line limit; the two
/// halves are the same assertion over the same name list.
#[test]
fn every_newer_revision_exposes_the_same_core_types() {
    use protocol_droid_schema::wire;

    macro_rules! assert_module {
        ($($ty:ty),+ $(,)?) => {{
            $(let _: Option<$ty> = None;)+
        }};
    }

    assert_module!(
        wire::v2025_03_26::CallToolResult,
        wire::v2025_03_26::Implementation,
        wire::v2025_03_26::JSONRPCRequest,
        wire::v2025_03_26::JSONRPCResponse,
        wire::v2025_03_26::Tool,
        wire::v2025_03_26::ListToolsResult,
        wire::v2025_03_26::GetPromptResult,
        wire::v2025_03_26::ReadResourceResult,
        wire::v2025_03_26::ToolListChangedNotification,
        wire::v2025_03_26::ResourceListChangedNotification,
        wire::v2025_03_26::ListResourcesResult,
        wire::v2025_03_26::ListRootsResult,
        wire::v2025_03_26::CreateMessageResult,
        wire::v2025_03_26::CompleteResult,
    );
    assert_module!(
        wire::v2025_06_18::CallToolResult,
        wire::v2025_06_18::Implementation,
        wire::v2025_06_18::JSONRPCRequest,
        wire::v2025_06_18::JSONRPCResponse,
        wire::v2025_06_18::Tool,
        wire::v2025_06_18::ListToolsResult,
        wire::v2025_06_18::GetPromptResult,
        wire::v2025_06_18::ReadResourceResult,
        wire::v2025_06_18::ToolListChangedNotification,
        wire::v2025_06_18::ResourceListChangedNotification,
        wire::v2025_06_18::ListResourcesResult,
        wire::v2025_06_18::ListRootsResult,
        wire::v2025_06_18::CreateMessageResult,
        wire::v2025_06_18::CompleteResult,
    );
    assert_module!(
        wire::v2025_11_25::CallToolResult,
        wire::v2025_11_25::Implementation,
        wire::v2025_11_25::JSONRPCRequest,
        wire::v2025_11_25::JSONRPCResponse,
        wire::v2025_11_25::Tool,
        wire::v2025_11_25::ListToolsResult,
        wire::v2025_11_25::GetPromptResult,
        wire::v2025_11_25::ReadResourceResult,
        wire::v2025_11_25::ToolListChangedNotification,
        wire::v2025_11_25::ResourceListChangedNotification,
        wire::v2025_11_25::ListResourcesResult,
        wire::v2025_11_25::ListRootsResult,
        wire::v2025_11_25::CreateMessageResult,
        wire::v2025_11_25::CompleteResult,
    );
    assert_module!(
        wire::v2026_07_28::CallToolResult,
        wire::v2026_07_28::Implementation,
        wire::v2026_07_28::JSONRPCRequest,
        wire::v2026_07_28::JSONRPCResponse,
        wire::v2026_07_28::Tool,
        wire::v2026_07_28::ListToolsResult,
        wire::v2026_07_28::GetPromptResult,
        wire::v2026_07_28::ReadResourceResult,
        wire::v2026_07_28::ToolListChangedNotification,
        wire::v2026_07_28::ResourceListChangedNotification,
        wire::v2026_07_28::ListResourcesResult,
        wire::v2026_07_28::ListRootsResult,
        wire::v2026_07_28::CreateMessageResult,
        wire::v2026_07_28::CompleteResult,
    );
    assert_module!(
        wire::draft::CallToolResult,
        wire::draft::Implementation,
        wire::draft::JSONRPCRequest,
        wire::draft::JSONRPCResponse,
        wire::draft::Tool,
        wire::draft::ListToolsResult,
        wire::draft::GetPromptResult,
        wire::draft::ReadResourceResult,
        wire::draft::ToolListChangedNotification,
        wire::draft::ResourceListChangedNotification,
        wire::draft::ListResourcesResult,
        wire::draft::ListRootsResult,
        wire::draft::CreateMessageResult,
        wire::draft::CompleteResult,
    );
}

/// The `inputSchema` a tool carries is free-form embedded JSON Schema from
/// 2025-11-25 onward, and is `Value` in every revision for that reason. This
/// asserts an actual tool definition round-trips, `inputSchema` included.
#[test]
fn tool_definition_round_trips() {
    let tool = json!({
        "name": "search",
        "description": "Search the corpus",
        "inputSchema": {
            "type": "object",
            "properties": {
                "query": { "type": "string" },
                "limit": { "type": "integer" }
            },
            "required": ["query"]
        }
    });
    round_trips::<wire::v2025_06_18::Tool>(&tool);
}