tooltest_core/
schema.rs

1use std::borrow::Cow;
2use std::fmt;
3use std::sync::OnceLock;
4
5use jsonschema::{draft202012, Validator};
6use rmcp::model::{CallToolRequestParam, CallToolResult, ListToolsResult};
7use serde_json::Value as JsonValue;
8
9use crate::{SchemaConfig, SchemaVersion};
10
11const SUPPORTED_SCHEMA_VERSION: &str = "2025-11-25";
12const DEFAULT_SCHEMA_ID: &str = "https://json-schema.org/draft/2020-12/schema";
13// Source: https://github.com/modelcontextprotocol/specification/tree/main/schema/2025-11-25
14// Update: run `scripts/update-mcp-schema.sh 2025-11-25`.
15// Provenance: see tooltest-core/resources/mcp-schema-2025-11-25.source.txt.
16const MCP_SCHEMA: &str = include_str!(concat!(
17    env!("CARGO_MANIFEST_DIR"),
18    "/resources/mcp-schema-2025-11-25.json"
19));
20static LIST_TOOLS_VALIDATOR: OnceLock<Validator> = OnceLock::new();
21static CALL_TOOL_REQUEST_VALIDATOR: OnceLock<Validator> = OnceLock::new();
22/// Errors produced while parsing MCP schema data.
23#[derive(Debug)]
24pub enum SchemaError {
25    /// Failed to parse a tools/list response.
26    InvalidListTools(String),
27    /// Failed to parse a tools/call request payload.
28    InvalidCallToolRequest(String),
29    /// Failed to parse a tools/call response payload.
30    InvalidCallToolResult(String),
31}
32
33impl fmt::Display for SchemaError {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self {
36            SchemaError::InvalidListTools(message) => write!(f, "invalid tools/list: {message}"),
37            SchemaError::InvalidCallToolRequest(message) => {
38                write!(f, "invalid tools/call request: {message}")
39            }
40            SchemaError::InvalidCallToolResult(message) => {
41                write!(f, "invalid tools/call result: {message}")
42            }
43        }
44    }
45}
46
47impl std::error::Error for SchemaError {}
48
49/// Validate and parse a tools/list response payload.
50pub fn parse_list_tools(
51    payload: JsonValue,
52    config: &SchemaConfig,
53) -> Result<ListToolsResult, SchemaError> {
54    let _ = config;
55    validate_list_tools(&payload)?;
56    parse_list_tools_payload(payload)
57}
58
59/// Validate and parse a tools/call request payload.
60pub fn parse_call_tool_request(
61    payload: JsonValue,
62    config: &SchemaConfig,
63) -> Result<CallToolRequestParam, SchemaError> {
64    let _ = config;
65    validate_call_tool_request(&payload)?;
66    parse_call_tool_request_payload(payload)
67}
68
69/// Validate and parse a tools/call response payload.
70pub fn parse_call_tool_result(
71    payload: JsonValue,
72    config: &SchemaConfig,
73) -> Result<CallToolResult, SchemaError> {
74    let _ = config;
75    serde_json::from_value(payload)
76        .map_err(|err| SchemaError::InvalidCallToolResult(err.to_string()))
77}
78
79#[inline(never)]
80fn validate_list_tools(payload: &JsonValue) -> Result<(), SchemaError> {
81    let validator = list_tools_validator();
82    if let Err(error) = validator.validate(payload) {
83        return Err(SchemaError::InvalidListTools(error.to_string()));
84    }
85    Ok(())
86}
87
88#[inline(never)]
89fn validate_call_tool_request(payload: &JsonValue) -> Result<(), SchemaError> {
90    let validator = call_tool_request_validator();
91    if let Err(error) = validator.validate(payload) {
92        return Err(SchemaError::InvalidCallToolRequest(error.to_string()));
93    }
94    Ok(())
95}
96
97fn parse_list_tools_payload(payload: JsonValue) -> Result<ListToolsResult, SchemaError> {
98    match serde_json::from_value(payload) {
99        Ok(result) => Ok(result),
100        Err(err) => Err(SchemaError::InvalidListTools(err.to_string())),
101    }
102}
103
104fn parse_call_tool_request_payload(
105    payload: JsonValue,
106) -> Result<CallToolRequestParam, SchemaError> {
107    match serde_json::from_value(payload) {
108        Ok(result) => Ok(result),
109        Err(err) => Err(SchemaError::InvalidCallToolRequest(err.to_string())),
110    }
111}
112
113#[inline(never)]
114fn list_tools_validator() -> &'static Validator {
115    if let Some(validator) = LIST_TOOLS_VALIDATOR.get() {
116        return validator;
117    }
118    let validator =
119        build_validator_for_def("ListToolsResult").expect("list tools validator compiles");
120    let _ = LIST_TOOLS_VALIDATOR.set(validator);
121    LIST_TOOLS_VALIDATOR
122        .get()
123        .expect("list tools validator initialized")
124}
125
126#[inline(never)]
127fn call_tool_request_validator() -> &'static Validator {
128    if let Some(validator) = CALL_TOOL_REQUEST_VALIDATOR.get() {
129        return validator;
130    }
131    let validator = build_validator_for_def("CallToolRequestParams")
132        .expect("call tool request validator compiles");
133    let _ = CALL_TOOL_REQUEST_VALIDATOR.set(validator);
134    CALL_TOOL_REQUEST_VALIDATOR
135        .get()
136        .expect("call tool request validator initialized")
137}
138
139#[inline(never)]
140fn build_validator_for_def(def_name: &str) -> Result<Validator, String> {
141    let schema: JsonValue = serde_json::from_str(MCP_SCHEMA).expect("MCP schema JSON parses");
142    let defs = schema
143        .get("$defs")
144        .cloned()
145        .expect("MCP schema defines $defs");
146    let schema_id = schema_id_for(&schema);
147    let list_tools_schema = serde_json::json!({
148        "$schema": schema_id,
149        "$defs": defs,
150        "$ref": format!("#/$defs/{def_name}")
151    });
152    draft202012::new(&list_tools_schema)
153        .map_err(|err| format!("failed to compile MCP schema: {err}"))
154}
155
156fn schema_id_for(schema: &JsonValue) -> JsonValue {
157    schema
158        .get("$schema")
159        .cloned()
160        .unwrap_or_else(|| JsonValue::String(DEFAULT_SCHEMA_ID.to_string()))
161}
162
163pub fn schema_version_label(version: &SchemaVersion) -> Cow<'_, str> {
164    match version {
165        SchemaVersion::V2025_11_25 => Cow::Borrowed(SUPPORTED_SCHEMA_VERSION),
166        SchemaVersion::Other(value) => Cow::Borrowed(value),
167    }
168}
169
170#[cfg(test)]
171#[path = "../tests/internal/schema_unit_tests.rs"]
172mod tests;