use super::*;
#[derive(Debug, Clone, Copy)]
#[cfg_attr(not(feature = "schema-validation"), allow(dead_code))]
pub(crate) struct SchemaValidation {
pub(crate) input: bool,
pub(crate) output: bool,
}
impl Default for SchemaValidation {
fn default() -> Self {
Self {
input: true,
output: true,
}
}
}
#[cfg(feature = "schema-validation")]
pub fn check_schema(schema: &Value) -> std::result::Result<(), SchemaError> {
engine::options()
.build(schema)
.map(drop)
.map_err(|error| SchemaError {
location: error.instance_path().as_str().to_string(),
message: error.to_string(),
})
}
#[cfg(feature = "schema-validation")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchemaError {
location: String,
message: String,
}
#[cfg(feature = "schema-validation")]
impl SchemaError {
pub fn location(&self) -> &str {
&self.location
}
pub fn message(&self) -> &str {
&self.message
}
}
#[cfg(feature = "schema-validation")]
impl std::fmt::Display for SchemaError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let location = match self.location.as_str() {
"" => "(root)",
path => path,
};
write!(f, "at {location}: {}", self.message)
}
}
#[cfg(feature = "schema-validation")]
impl std::error::Error for SchemaError {}
impl Tool {
pub(crate) fn with_schema_validation(self, validation: SchemaValidation) -> Self {
let schemas = Arc::new(engine::ToolChecks {
tool: self.name.clone(),
check_shape: validation.output,
#[cfg(feature = "schema-validation")]
schemas: engine::ToolSchemas::compile(
&self.name,
validation.input.then_some(&self.input_schema),
validation
.output
.then_some(self.output_schema.as_ref())
.flatten(),
),
});
Tool {
service: self.service.map(|inner| {
BoxCloneService::new(engine::ValidateService {
inner,
schemas: schemas.clone(),
})
}),
#[cfg(feature = "stateless")]
mrtr_handler: self.mrtr_handler.map(|inner| {
Arc::new(engine::ValidatedMrtrToolHandler {
inner,
schemas: schemas.clone(),
}) as Arc<dyn MrtrToolHandler>
}),
live_handler: self.live_handler.map(|inner| {
Arc::new(engine::ValidatedLiveToolHandler {
inner,
schemas: schemas.clone(),
}) as Arc<dyn LiveToolHandler>
}),
..self
}
}
}
mod engine {
use super::*;
#[cfg(feature = "schema-validation")]
use jsonschema::Validator;
#[cfg(feature = "schema-validation")]
const MAX_REPORTED_ERRORS: usize = 5;
#[cfg(feature = "schema-validation")]
const MAX_ERROR_CHARS: usize = 300;
#[cfg(feature = "schema-validation")]
pub(super) struct ToolSchemas {
tool: String,
input: Option<Validator>,
output: Option<Validator>,
}
#[cfg(feature = "schema-validation")]
pub(super) fn options() -> jsonschema::ValidationOptions<'static> {
jsonschema::options().should_validate_formats(true)
}
#[cfg(feature = "schema-validation")]
fn compile(tool: &str, which: &'static str, schema: &Value) -> Option<Validator> {
match options().build(schema) {
Ok(validator) => Some(validator),
Err(error) => {
tracing::error!(
target: "mcp::tools",
tool,
schema = which,
%error,
"tool schema does not compile; it will not be validated"
);
None
}
}
}
#[cfg(feature = "schema-validation")]
fn violations(validator: &Validator, instance: &Value) -> Option<String> {
let mut errors = validator.iter_errors(instance);
let lines: Vec<String> = errors
.by_ref()
.take(MAX_REPORTED_ERRORS)
.map(|error| {
let location = match error.instance_path().as_str() {
"" => "(root)",
path => path,
};
let mut message = error.to_string();
if let Some((end, _)) = message.char_indices().nth(MAX_ERROR_CHARS) {
message.truncate(end);
message.push_str("...");
}
match error.schema_path().as_str().rsplit('/').next() {
Some(rule) if !rule.is_empty() => {
format!("- at {location}: {message} ({rule})")
}
_ => format!("- at {location}: {message}"),
}
})
.collect();
if lines.is_empty() {
return None;
}
let mut report = lines.join("\n");
if errors.next().is_some() {
report.push_str("\n- and more violations");
}
Some(report)
}
#[cfg(feature = "schema-validation")]
impl ToolSchemas {
pub(super) fn compile(
tool: &str,
input: Option<&Value>,
output: Option<&Value>,
) -> Option<Self> {
let input = input.and_then(|schema| compile(tool, "inputSchema", schema));
let output = output.and_then(|schema| compile(tool, "outputSchema", schema));
if input.is_none() && output.is_none() {
return None;
}
Some(Self {
tool: tool.to_string(),
input,
output,
})
}
fn check_input(&self, arguments: &Value) -> Option<String> {
let validator = self.input.as_ref()?;
let empty = Value::Object(Map::new());
let instance = if arguments.is_null() {
if validator.is_valid(arguments) {
return None;
}
&empty
} else {
arguments
};
let report = violations(validator, instance)?;
tracing::debug!(
target: "mcp::tools",
tool = %self.tool,
%report,
"tool arguments do not match the input schema"
);
Some(format!(
"Invalid input: the arguments do not match the tool's input schema.\n{report}"
))
}
fn check_output(&self, result: CallToolResult) -> CallToolResult {
let Some(validator) = self.output.as_ref() else {
return result;
};
if result.is_error {
return result;
}
let Some(structured) = result.structured_content.as_ref() else {
return result;
};
let Some(report) = violations(validator, structured) else {
return result;
};
tracing::warn!(
target: "mcp::tools",
tool = %self.tool,
%report,
"tool structuredContent does not match the output schema"
);
CallToolResult::error(format!(
"Tool returned structured content that does not match its output schema.\n{report}"
))
}
}
pub(super) struct ToolChecks {
pub(super) tool: String,
pub(super) check_shape: bool,
#[cfg(feature = "schema-validation")]
pub(super) schemas: Option<ToolSchemas>,
}
impl ToolChecks {
fn check_input(&self, _arguments: &Value) -> Option<String> {
#[cfg(feature = "schema-validation")]
return self.schemas.as_ref()?.check_input(_arguments);
#[cfg(not(feature = "schema-validation"))]
None
}
fn check_output(&self, result: CallToolResult, final_protocol: bool) -> CallToolResult {
if self.check_shape
&& !final_protocol
&& !result.is_error
&& let Some(structured) = result.structured_content.as_ref()
{
let kind = match structured {
Value::Object(_) => None,
Value::Array(_) => Some("array"),
Value::String(_) => Some("string"),
Value::Number(_) => Some("number"),
Value::Bool(_) => Some("boolean"),
Value::Null => Some("null"),
};
if let Some(kind) = kind {
let message = format!(
"Tool '{}' returned structuredContent of type {kind}; MCP 2025-11-25 and earlier require a JSON object. Wrap a list with CallToolResult::from_list or return an object.",
self.tool,
);
tracing::warn!(
target: "mcp::tools",
tool = %self.tool,
%message,
"tool structuredContent has an invalid shape for the protocol revision"
);
return CallToolResult::error(message);
}
}
#[cfg(feature = "schema-validation")]
if let Some(schemas) = &self.schemas {
return schemas.check_output(result);
}
result
}
}
#[derive(Clone)]
pub(super) struct ValidateService<S> {
pub(super) inner: S,
pub(super) schemas: Arc<ToolChecks>,
}
impl<S> Service<ToolRequest> for ValidateService<S>
where
S: Service<ToolRequest, Response = CallToolResult, Error = Infallible>,
S::Future: Send + 'static,
{
type Response = CallToolResult;
type Error = Infallible;
type Future = BoxFuture<'static, std::result::Result<CallToolResult, Infallible>>;
fn poll_ready(
&mut self,
cx: &mut Context<'_>,
) -> Poll<std::result::Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: ToolRequest) -> Self::Future {
if let Some(message) = self.schemas.check_input(&req.args) {
return Box::pin(std::future::ready(Ok(CallToolResult::error(message))));
}
let final_protocol = crate::router::is_final_protocol_request(req.ctx.extensions());
let future = self.inner.call(req);
let schemas = self.schemas.clone();
Box::pin(async move {
future
.await
.map(|result| schemas.check_output(result, final_protocol))
})
}
}
#[cfg(feature = "stateless")]
pub(super) struct ValidatedMrtrToolHandler {
pub(super) inner: Arc<dyn MrtrToolHandler>,
pub(super) schemas: Arc<ToolChecks>,
}
#[cfg(feature = "stateless")]
impl MrtrToolHandler for ValidatedMrtrToolHandler {
fn call(
&self,
ctx: RequestContext,
args: Value,
) -> BoxFuture<'_, Result<RequestOutcome<CallToolResult>>> {
if let Some(message) = self.schemas.check_input(&args) {
return Box::pin(async move {
Ok(RequestOutcome::Complete(CallToolResult::error(message)))
});
}
let final_protocol = crate::router::is_final_protocol_request(ctx.extensions());
let future = self.inner.call(ctx, args);
Box::pin(async move {
Ok(match future.await? {
RequestOutcome::Complete(result) => {
RequestOutcome::Complete(self.schemas.check_output(result, final_protocol))
}
other => other,
})
})
}
fn input_schema(&self) -> Value {
self.inner.input_schema()
}
}
pub(super) struct ValidatedLiveToolHandler {
pub(super) inner: Arc<dyn LiveToolHandler>,
pub(super) schemas: Arc<ToolChecks>,
}
#[async_trait::async_trait]
impl LiveToolHandler for ValidatedLiveToolHandler {
async fn call(
&self,
ctx: RequestContext,
task: TaskContext,
arguments: Value,
) -> Result<TaskOutcome> {
if let Some(message) = self.schemas.check_input(&arguments) {
return Ok(TaskOutcome::Completed(CallToolResult::error(message)));
}
let final_protocol = crate::router::is_final_protocol_request(ctx.extensions());
Ok(match self.inner.call(ctx, task, arguments).await? {
TaskOutcome::Completed(result) => {
TaskOutcome::Completed(self.schemas.check_output(result, final_protocol))
}
other => other,
})
}
}
}
#[cfg(test)]
mod tests;