Skip to main content

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 = crate::schema_dialect::DEFAULT_SCHEMA_ID;
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<Result<Validator, String>> = OnceLock::new();
21static CALL_TOOL_REQUEST_VALIDATOR: OnceLock<Result<Validator, String>> = 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    /// Unsupported MCP schema version.
32    UnsupportedSchemaVersion(String),
33}
34
35impl fmt::Display for SchemaError {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            SchemaError::InvalidListTools(message) => write!(f, "invalid tools/list: {message}"),
39            SchemaError::InvalidCallToolRequest(message) => {
40                write!(f, "invalid tools/call request: {message}")
41            }
42            SchemaError::InvalidCallToolResult(message) => {
43                write!(f, "invalid tools/call result: {message}")
44            }
45            SchemaError::UnsupportedSchemaVersion(version) => {
46                write!(f, "unsupported MCP schema version: {version}")
47            }
48        }
49    }
50}
51
52impl std::error::Error for SchemaError {}
53
54/// Validate and parse a tools/list response payload.
55pub fn parse_list_tools(
56    payload: JsonValue,
57    config: &SchemaConfig,
58) -> Result<ListToolsResult, SchemaError> {
59    validate_list_tools(&payload, config)?;
60    parse_list_tools_payload(payload)
61}
62
63/// Validate and parse a tools/call request payload.
64pub fn parse_call_tool_request(
65    payload: JsonValue,
66    config: &SchemaConfig,
67) -> Result<CallToolRequestParam, SchemaError> {
68    validate_call_tool_request(&payload, config)?;
69    parse_call_tool_request_payload(payload)
70}
71
72/// Validate and parse a tools/call response payload.
73pub fn parse_call_tool_result(
74    payload: JsonValue,
75    config: &SchemaConfig,
76) -> Result<CallToolResult, SchemaError> {
77    let _ = schema_json_for(config)?;
78    serde_json::from_value(payload)
79        .map_err(|err| SchemaError::InvalidCallToolResult(err.to_string()))
80}
81
82#[inline(never)]
83fn validate_list_tools(payload: &JsonValue, config: &SchemaConfig) -> Result<(), SchemaError> {
84    let validator = list_tools_validator(config)?;
85    if let Err(error) = validator.validate(payload) {
86        return Err(SchemaError::InvalidListTools(error.to_string()));
87    }
88    Ok(())
89}
90
91#[inline(never)]
92fn validate_call_tool_request(
93    payload: &JsonValue,
94    config: &SchemaConfig,
95) -> Result<(), SchemaError> {
96    let validator = call_tool_request_validator(config)?;
97    if let Err(error) = validator.validate(payload) {
98        return Err(SchemaError::InvalidCallToolRequest(error.to_string()));
99    }
100    Ok(())
101}
102
103fn parse_list_tools_payload(payload: JsonValue) -> Result<ListToolsResult, SchemaError> {
104    match serde_json::from_value(payload) {
105        Ok(result) => Ok(result),
106        Err(err) => Err(SchemaError::InvalidListTools(err.to_string())),
107    }
108}
109
110fn parse_call_tool_request_payload(
111    payload: JsonValue,
112) -> Result<CallToolRequestParam, SchemaError> {
113    match serde_json::from_value(payload) {
114        Ok(result) => Ok(result),
115        Err(err) => Err(SchemaError::InvalidCallToolRequest(err.to_string())),
116    }
117}
118
119fn validator_for_def<'a>(
120    lock: &'a OnceLock<Result<Validator, String>>,
121    schema_json: &str,
122    def_name: &str,
123    wrap_error: impl FnOnce(String) -> SchemaError,
124) -> Result<&'a Validator, SchemaError> {
125    lock.get_or_init(|| build_validator_for_def(schema_json, def_name))
126        .as_ref()
127        .map_err(|message| wrap_error(message.clone()))
128}
129
130#[inline(never)]
131fn list_tools_validator(config: &SchemaConfig) -> Result<&'static Validator, SchemaError> {
132    let schema_json = schema_json_for(config)?;
133    validator_for_def(
134        &LIST_TOOLS_VALIDATOR,
135        schema_json,
136        "ListToolsResult",
137        SchemaError::InvalidListTools,
138    )
139}
140
141#[inline(never)]
142fn call_tool_request_validator(config: &SchemaConfig) -> Result<&'static Validator, SchemaError> {
143    let schema_json = schema_json_for(config)?;
144    validator_for_def(
145        &CALL_TOOL_REQUEST_VALIDATOR,
146        schema_json,
147        "CallToolRequestParams",
148        SchemaError::InvalidCallToolRequest,
149    )
150}
151
152#[inline(never)]
153fn build_validator_for_def(schema_json: &str, def_name: &str) -> Result<Validator, String> {
154    let schema: JsonValue = serde_json::from_str(schema_json)
155        .map_err(|err| format!("failed to parse MCP schema JSON: {err}"))?;
156    let defs = schema
157        .get("$defs")
158        .cloned()
159        .ok_or_else(|| "MCP schema missing $defs".to_string())?;
160    let schema_id = schema_id_for(&schema);
161    let list_tools_schema = serde_json::json!({
162        "$schema": schema_id,
163        "$defs": defs,
164        "$ref": format!("#/$defs/{def_name}")
165    });
166    draft202012::new(&list_tools_schema)
167        .map_err(|err| format!("failed to compile MCP schema: {err}"))
168}
169
170fn schema_id_for(schema: &JsonValue) -> JsonValue {
171    schema
172        .get("$schema")
173        .cloned()
174        .unwrap_or_else(|| JsonValue::String(DEFAULT_SCHEMA_ID.to_string()))
175}
176
177fn schema_json_for(config: &SchemaConfig) -> Result<&'static str, SchemaError> {
178    match &config.version {
179        SchemaVersion::V2025_11_25 => Ok(MCP_SCHEMA),
180        SchemaVersion::Other(value) => Err(SchemaError::UnsupportedSchemaVersion(value.clone())),
181    }
182}
183
184pub fn schema_version_label(version: &SchemaVersion) -> Cow<'_, str> {
185    match version {
186        SchemaVersion::V2025_11_25 => Cow::Borrowed(SUPPORTED_SCHEMA_VERSION),
187        SchemaVersion::Other(value) => Cow::Borrowed(value),
188    }
189}
190
191#[cfg(test)]
192#[path = "../tests/internal/schema_unit_tests.rs"]
193mod tests;