use crate::context::Context;
use crate::handler::{PromptHandler, ResourceHandler, ServerHandler, ToolHandler};
use mcpkit_core::capability::{ServerCapabilities, ServerInfo};
use mcpkit_core::error::McpError;
use mcpkit_core::types::{
CallToolResult, GetPromptResult, Object, Prompt, Resource, ResourceContents, ResourceTemplate,
Tool, ToolOutput,
};
use serde_json::Value;
use std::future::Future;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ValidationMode {
pub inputs: bool,
pub outputs: bool,
}
impl ValidationMode {
#[must_use]
pub const fn both() -> Self {
Self {
inputs: true,
outputs: true,
}
}
#[must_use]
pub const fn inputs_only() -> Self {
Self {
inputs: true,
outputs: false,
}
}
#[must_use]
pub const fn outputs_only() -> Self {
Self {
inputs: false,
outputs: true,
}
}
}
pub struct ValidatingToolHandler<H> {
inner: H,
mode: ValidationMode,
}
impl<H> ValidatingToolHandler<H> {
#[must_use]
pub const fn new(inner: H, mode: ValidationMode) -> Self {
Self { inner, mode }
}
pub fn into_inner(self) -> H {
self.inner
}
}
pub fn validate_json(schema: &Value, instance: &Value) -> Result<(), Vec<String>> {
match collect_errors(schema, instance) {
None => Ok(()),
Some(errors) => Err(errors),
}
}
fn collect_errors(schema: &Value, instance: &Value) -> Option<Vec<String>> {
let validator = match jsonschema::validator_for(schema) {
Ok(validator) => validator,
Err(error) => {
tracing::warn!(%error, "tool schema failed to compile; skipping validation");
return None;
}
};
let errors: Vec<String> = validator
.iter_errors(instance)
.map(|e| e.to_string())
.collect();
if errors.is_empty() {
None
} else {
Some(errors)
}
}
impl<H: ToolHandler> ToolHandler for ValidatingToolHandler<H> {
async fn list_tools(&self, ctx: &Context<'_>) -> Result<Vec<Tool>, McpError> {
self.inner.list_tools(ctx).await
}
async fn call_tool(
&self,
name: &str,
args: Object,
ctx: &Context<'_>,
) -> Result<ToolOutput, McpError> {
let tool = match self.inner.list_tools(ctx).await {
Ok(tools) => tools.into_iter().find(|t| t.name == name),
Err(error) => {
tracing::warn!(%error, tool = name, "could not list tools; skipping validation");
None
}
};
if self.mode.inputs {
if let Some(tool) = &tool {
if let Some(errors) =
collect_errors(&tool.input_schema, &Value::Object(args.clone()))
{
let message = format!(
"Input does not conform to the tool's inputSchema:\n{}",
errors.join("\n")
);
return Ok(ToolOutput::Success(CallToolResult::error(message)));
}
}
}
let output = self.inner.call_tool(name, args, ctx).await?;
if self.mode.outputs {
if let (Some(tool), ToolOutput::Success(result)) = (&tool, &output) {
if let (Some(schema), Some(structured)) =
(&tool.output_schema, &result.structured_content)
{
if let Some(errors) = collect_errors(schema, &Value::Object(structured.clone()))
{
tracing::error!(
tool = name,
?errors,
"tool output violates its declared outputSchema (server bug); \
dropping structuredContent"
);
let message = format!(
"The tool produced structured output that does not conform to its \
declared outputSchema:\n{}",
errors.join("\n")
);
return Ok(ToolOutput::Success(CallToolResult::error(message)));
}
}
}
}
Ok(output)
}
async fn on_tools_changed(&self) {
self.inner.on_tools_changed().await;
}
}
impl<H: ServerHandler> ServerHandler for ValidatingToolHandler<H> {
fn server_info(&self) -> ServerInfo {
self.inner.server_info()
}
fn capabilities(&self) -> ServerCapabilities {
self.inner.capabilities()
}
fn instructions(&self) -> Option<String> {
self.inner.instructions()
}
fn on_initialized(&self, ctx: &Context<'_>) -> impl Future<Output = ()> + Send {
self.inner.on_initialized(ctx)
}
fn on_roots_list_changed(&self, ctx: &Context<'_>) -> impl Future<Output = ()> + Send {
self.inner.on_roots_list_changed(ctx)
}
fn on_shutdown(&self) -> impl Future<Output = ()> + Send {
self.inner.on_shutdown()
}
fn set_log_level(
&self,
level: crate::handler::LogLevel,
ctx: &Context<'_>,
) -> impl Future<Output = Result<(), McpError>> + Send {
self.inner.set_log_level(level, ctx)
}
}
impl<H: ResourceHandler> ResourceHandler for ValidatingToolHandler<H> {
fn list_resources(
&self,
ctx: &Context<'_>,
) -> impl Future<Output = Result<Vec<Resource>, McpError>> + Send {
self.inner.list_resources(ctx)
}
fn list_resource_templates(
&self,
ctx: &Context<'_>,
) -> impl Future<Output = Result<Vec<ResourceTemplate>, McpError>> + Send {
self.inner.list_resource_templates(ctx)
}
fn read_resource(
&self,
uri: &str,
ctx: &Context<'_>,
) -> impl Future<Output = Result<Vec<ResourceContents>, McpError>> + Send {
self.inner.read_resource(uri, ctx)
}
fn subscribe(
&self,
uri: &str,
ctx: &Context<'_>,
) -> impl Future<Output = Result<bool, McpError>> + Send {
self.inner.subscribe(uri, ctx)
}
fn unsubscribe(
&self,
uri: &str,
ctx: &Context<'_>,
) -> impl Future<Output = Result<bool, McpError>> + Send {
self.inner.unsubscribe(uri, ctx)
}
}
impl<H: PromptHandler> PromptHandler for ValidatingToolHandler<H> {
fn list_prompts(
&self,
ctx: &Context<'_>,
) -> impl Future<Output = Result<Vec<Prompt>, McpError>> + Send {
self.inner.list_prompts(ctx)
}
fn get_prompt(
&self,
name: &str,
args: Option<serde_json::Map<String, Value>>,
ctx: &Context<'_>,
) -> impl Future<Output = Result<GetPromptResult, McpError>> + Send {
self.inner.get_prompt(name, args, ctx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::NoOpPeer;
use crate::router::route_tools;
use mcpkit_core::capability::{ClientCapabilities, ServerCapabilities};
use mcpkit_core::protocol::RequestId;
use mcpkit_core::protocol_version::ProtocolVersion;
use serde_json::json;
fn obj(v: Value) -> Object {
match v {
Value::Object(map) => map,
other => panic!("expected object, got {other}"),
}
}
struct SchemaHandler {
structured: Object,
}
impl ToolHandler for SchemaHandler {
async fn list_tools(&self, _ctx: &Context<'_>) -> Result<Vec<Tool>, McpError> {
Ok(vec![
Tool::new("add")
.input_schema(json!({
"type": "object",
"properties": { "n": { "type": "number" } },
"required": ["n"]
}))
.output_schema(json!({
"type": "object",
"properties": { "doubled": { "type": "number" } },
"required": ["doubled"]
})),
])
}
async fn call_tool(
&self,
_name: &str,
_args: serde_json::Map<String, Value>,
_ctx: &Context<'_>,
) -> Result<ToolOutput, McpError> {
Ok(ToolOutput::Success(
CallToolResult::text("ok").with_structured_content(self.structured.clone()),
))
}
}
async fn with_ctx<F, Fut, T>(f: F) -> T
where
F: FnOnce(Context<'static>) -> Fut,
Fut: std::future::Future<Output = T>,
{
let request_id = RequestId::Number(1);
let client_caps = ClientCapabilities::default();
let server_caps = ServerCapabilities::default();
let peer = NoOpPeer;
let request_id: &'static RequestId = Box::leak(Box::new(request_id));
let client_caps: &'static ClientCapabilities = Box::leak(Box::new(client_caps));
let server_caps: &'static ServerCapabilities = Box::leak(Box::new(server_caps));
let peer: &'static NoOpPeer = Box::leak(Box::new(peer));
let ctx = Context::new(
request_id,
None,
client_caps,
server_caps,
ProtocolVersion::LATEST,
peer,
);
f(ctx).await
}
#[tokio::test]
async fn input_failure_is_a_tool_error_not_protocol_error() {
let handler = SchemaHandler {
structured: obj(json!({ "doubled": 84 })),
};
let validating = ValidatingToolHandler::new(handler, ValidationMode::both());
let out = with_ctx(|ctx| async move {
validating
.call_tool("add", obj(json!({})), &ctx)
.await
.expect("input failure is Ok(isError), not a protocol Err")
})
.await;
match out {
ToolOutput::Success(result) => assert!(result.is_error(), "expected isError: true"),
other => panic!("expected a Success(isError) result, got {other:?}"),
}
}
#[tokio::test]
async fn valid_input_and_output_pass_through_untouched() {
let handler = SchemaHandler {
structured: obj(json!({ "doubled": 84 })),
};
let validating = ValidatingToolHandler::new(handler, ValidationMode::both());
let out = with_ctx(|ctx| async move {
validating
.call_tool("add", obj(json!({ "n": 42 })), &ctx)
.await
.expect("routed")
})
.await;
match out {
ToolOutput::Success(result) => {
assert!(!result.is_error());
assert_eq!(
result.structured_content,
Some(obj(json!({ "doubled": 84 })))
);
}
other => panic!("expected success, got {other:?}"),
}
}
#[tokio::test]
async fn output_schema_violation_drops_structured_content() {
let handler = SchemaHandler {
structured: obj(json!({ "doubled": "not a number" })),
};
let validating = ValidatingToolHandler::new(handler, ValidationMode::both());
let out = with_ctx(|ctx| async move {
validating
.call_tool("add", obj(json!({ "n": 42 })), &ctx)
.await
.expect("routed")
})
.await;
match out {
ToolOutput::Success(result) => {
assert!(result.is_error(), "output violation must be isError: true");
assert!(
result.structured_content.is_none(),
"invalid structuredContent must be dropped"
);
}
other => panic!("expected success(isError), got {other:?}"),
}
}
#[tokio::test]
async fn inputs_only_mode_ignores_bad_output() {
let handler = SchemaHandler {
structured: obj(json!({ "doubled": "not a number" })),
};
let validating = ValidatingToolHandler::new(handler, ValidationMode::inputs_only());
let out = with_ctx(|ctx| async move {
validating
.call_tool("add", obj(json!({ "n": 42 })), &ctx)
.await
.expect("routed")
})
.await;
match out {
ToolOutput::Success(result) => assert!(!result.is_error()),
other => panic!("expected success, got {other:?}"),
}
}
#[tokio::test]
async fn normal_tools_call_path_through_route_tools_is_validated() {
let handler = SchemaHandler {
structured: obj(json!({ "doubled": 84 })),
};
let validating = ValidatingToolHandler::new(handler, ValidationMode::both());
let result = with_ctx(|ctx| async move {
route_tools(
&validating,
"tools/call",
Some(&json!({ "name": "add", "arguments": {} })),
&ctx,
None,
)
.await
.expect("tools/call is routed")
.expect("ok result")
})
.await;
assert_eq!(result["isError"], json!(true));
}
#[test]
fn wrapped_combined_handler_satisfies_adapter_bounds() {
use crate::handler::{PromptHandler, ResourceHandler, ServerHandler};
use mcpkit_core::types::{GetPromptResult, Prompt, Resource, ResourceContents};
fn adapter_bound<H: ServerHandler + ToolHandler + ResourceHandler + PromptHandler>(_h: &H) {
}
struct Combined;
impl ServerHandler for Combined {
fn server_info(&self) -> ServerInfo {
ServerInfo::new("t", "1.0.0")
}
}
impl ToolHandler for Combined {
async fn list_tools(&self, _ctx: &Context<'_>) -> Result<Vec<Tool>, McpError> {
Ok(vec![])
}
async fn call_tool(
&self,
_name: &str,
_args: serde_json::Map<String, Value>,
_ctx: &Context<'_>,
) -> Result<ToolOutput, McpError> {
Ok(ToolOutput::text("x"))
}
}
impl ResourceHandler for Combined {
async fn list_resources(&self, _ctx: &Context<'_>) -> Result<Vec<Resource>, McpError> {
Ok(vec![])
}
async fn read_resource(
&self,
_uri: &str,
_ctx: &Context<'_>,
) -> Result<Vec<ResourceContents>, McpError> {
Ok(vec![])
}
}
impl PromptHandler for Combined {
async fn list_prompts(&self, _ctx: &Context<'_>) -> Result<Vec<Prompt>, McpError> {
Ok(vec![])
}
async fn get_prompt(
&self,
_name: &str,
_args: Option<serde_json::Map<String, Value>>,
_ctx: &Context<'_>,
) -> Result<GetPromptResult, McpError> {
Ok(GetPromptResult {
description: None,
messages: vec![],
meta: None,
})
}
}
let wrapped = ValidatingToolHandler::new(Combined, ValidationMode::both());
adapter_bound(&wrapped);
}
#[tokio::test]
async fn unwrapped_handler_does_not_validate() {
let handler = SchemaHandler {
structured: obj(json!({ "doubled": 84 })),
};
let result = with_ctx(|ctx| async move {
route_tools(
&handler,
"tools/call",
Some(&json!({ "name": "add", "arguments": {} })),
&ctx,
None,
)
.await
.expect("tools/call is routed")
.expect("ok result")
})
.await;
assert_ne!(result.get("isError"), Some(&json!(true)));
}
}