Skip to main content

mcpkit_server/
validation.rs

1//! Opt-in JSON Schema validation of tool inputs and outputs.
2//!
3//! The MCP Tools spec requires servers to validate `tools/call` arguments
4//! against each tool's `inputSchema`, and—when a tool declares an
5//! `outputSchema`—to return `structuredContent` conforming to it. mcpkit's
6//! generic [`ToolHandler`] path is an unchecked escape hatch: it receives raw
7//! JSON and returns arbitrary JSON. This module provides an **opt-in**
8//! [`ValidatingToolHandler`] decorator that enforces those schemas.
9//!
10//! Because it wraps the [`ToolHandler`] itself, it covers every dispatch path
11//! uniformly—normal `tools/call`, task-augmented background execution, and the
12//! HTTP adapters—since they all funnel through [`ToolHandler::call_tool`].
13//!
14//! Per the spec's error-handling section, arguments that fail a tool's
15//! `inputSchema` are reported as a tool-execution error (`isError: true`), not a
16//! JSON-RPC protocol error; malformed request envelopes and unknown tools remain
17//! protocol errors and are handled upstream. An `outputSchema` violation is a
18//! server-side bug: it is logged, the invalid `structuredContent` is dropped, and
19//! the call returns `isError: true`.
20//!
21//! This module is gated behind the `schema-validation` feature.
22
23use crate::context::Context;
24use crate::handler::{PromptHandler, ResourceHandler, ServerHandler, ToolHandler};
25use mcpkit_core::capability::{ServerCapabilities, ServerInfo};
26use mcpkit_core::error::McpError;
27use mcpkit_core::types::{
28    CallToolResult, GetPromptResult, Object, Prompt, Resource, ResourceContents, ResourceTemplate,
29    Tool, ToolOutput,
30};
31use serde_json::Value;
32use std::future::Future;
33
34/// Which directions [`ValidatingToolHandler`] enforces.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub struct ValidationMode {
37    /// Validate `tools/call` arguments against each tool's `inputSchema`.
38    pub inputs: bool,
39    /// Validate structured results against each tool's `outputSchema`.
40    pub outputs: bool,
41}
42
43impl ValidationMode {
44    /// Validate both inputs and outputs.
45    #[must_use]
46    pub const fn both() -> Self {
47        Self {
48            inputs: true,
49            outputs: true,
50        }
51    }
52
53    /// Validate inputs only.
54    #[must_use]
55    pub const fn inputs_only() -> Self {
56        Self {
57            inputs: true,
58            outputs: false,
59        }
60    }
61
62    /// Validate outputs only.
63    #[must_use]
64    pub const fn outputs_only() -> Self {
65        Self {
66            inputs: false,
67            outputs: true,
68        }
69    }
70}
71
72/// A [`ToolHandler`] decorator that validates tool inputs and/or outputs against
73/// their declared JSON Schemas.
74///
75/// Wrap a tool handler with [`ServerBuilder::validate_tool_io`] (or construct
76/// directly with [`ValidatingToolHandler::new`] for adapter users who don't build
77/// through [`Server`]). Schemas are resolved from the inner handler's
78/// [`list_tools`](ToolHandler::list_tools) at call time; there is no cache, so a
79/// dynamic tool list is always seen correctly.
80///
81/// [`ServerBuilder::validate_tool_io`]: crate::builder::ServerBuilder::validate_tool_io
82/// [`Server`]: crate::builder::Server
83pub struct ValidatingToolHandler<H> {
84    inner: H,
85    mode: ValidationMode,
86}
87
88impl<H> ValidatingToolHandler<H> {
89    /// Wrap `inner`, enforcing the directions selected by `mode`.
90    #[must_use]
91    pub const fn new(inner: H, mode: ValidationMode) -> Self {
92        Self { inner, mode }
93    }
94
95    /// Unwrap, returning the inner handler.
96    pub fn into_inner(self) -> H {
97        self.inner
98    }
99}
100
101/// Validate `instance` against JSON Schema `schema`, returning the list of
102/// violation messages (empty `Ok` means valid).
103///
104/// The schema's draft is auto-detected, defaulting to 2020-12 when no `$schema`
105/// is present (matching MCP). String `format` assertions are **not** enforced
106/// (the default for this validator). If `schema` itself cannot be compiled,
107/// validation is skipped and `Ok(())` is returned after logging a warning—a
108/// malformed schema is a server configuration bug and must not break tool calls.
109///
110/// # Errors
111///
112/// Returns the collected violation messages when `instance` does not conform.
113pub fn validate_json(schema: &Value, instance: &Value) -> Result<(), Vec<String>> {
114    match collect_errors(schema, instance) {
115        None => Ok(()),
116        Some(errors) => Err(errors),
117    }
118}
119
120/// `None` = valid (or schema uncompilable → skipped); `Some` = violations.
121fn collect_errors(schema: &Value, instance: &Value) -> Option<Vec<String>> {
122    let validator = match jsonschema::validator_for(schema) {
123        Ok(validator) => validator,
124        Err(error) => {
125            tracing::warn!(%error, "tool schema failed to compile; skipping validation");
126            return None;
127        }
128    };
129    let errors: Vec<String> = validator
130        .iter_errors(instance)
131        .map(|e| e.to_string())
132        .collect();
133    if errors.is_empty() {
134        None
135    } else {
136        Some(errors)
137    }
138}
139
140impl<H: ToolHandler> ToolHandler for ValidatingToolHandler<H> {
141    async fn list_tools(&self, ctx: &Context<'_>) -> Result<Vec<Tool>, McpError> {
142        self.inner.list_tools(ctx).await
143    }
144
145    async fn call_tool(
146        &self,
147        name: &str,
148        args: Object,
149        ctx: &Context<'_>,
150    ) -> Result<ToolOutput, McpError> {
151        // Resolve this tool's declared schemas. If the list can't be fetched or
152        // the tool isn't found, skip validation and let the inner handler decide
153        // (an unknown tool stays a protocol error upstream).
154        let tool = match self.inner.list_tools(ctx).await {
155            Ok(tools) => tools.into_iter().find(|t| t.name == name),
156            Err(error) => {
157                tracing::warn!(%error, tool = name, "could not list tools; skipping validation");
158                None
159            }
160        };
161
162        if self.mode.inputs {
163            if let Some(tool) = &tool {
164                if let Some(errors) =
165                    collect_errors(&tool.input_schema, &Value::Object(args.clone()))
166                {
167                    let message = format!(
168                        "Input does not conform to the tool's inputSchema:\n{}",
169                        errors.join("\n")
170                    );
171                    return Ok(ToolOutput::Success(CallToolResult::error(message)));
172                }
173            }
174        }
175
176        let output = self.inner.call_tool(name, args, ctx).await?;
177
178        if self.mode.outputs {
179            if let (Some(tool), ToolOutput::Success(result)) = (&tool, &output) {
180                if let (Some(schema), Some(structured)) =
181                    (&tool.output_schema, &result.structured_content)
182                {
183                    if let Some(errors) = collect_errors(schema, &Value::Object(structured.clone()))
184                    {
185                        tracing::error!(
186                            tool = name,
187                            ?errors,
188                            "tool output violates its declared outputSchema (server bug); \
189                             dropping structuredContent"
190                        );
191                        let message = format!(
192                            "The tool produced structured output that does not conform to its \
193                             declared outputSchema:\n{}",
194                            errors.join("\n")
195                        );
196                        return Ok(ToolOutput::Success(CallToolResult::error(message)));
197                    }
198                }
199            }
200        }
201
202        Ok(output)
203    }
204
205    async fn on_tools_changed(&self) {
206        self.inner.on_tools_changed().await;
207    }
208}
209
210// Transparent forwarding of the other handler traits, so a single combined
211// handler wrapped in `ValidatingToolHandler` remains a drop-in for the HTTP
212// adapters (which require `ServerHandler + ToolHandler + ResourceHandler +
213// PromptHandler` on one type). Only `ToolHandler` is intercepted above.
214
215impl<H: ServerHandler> ServerHandler for ValidatingToolHandler<H> {
216    fn server_info(&self) -> ServerInfo {
217        self.inner.server_info()
218    }
219
220    fn capabilities(&self) -> ServerCapabilities {
221        self.inner.capabilities()
222    }
223
224    fn instructions(&self) -> Option<String> {
225        self.inner.instructions()
226    }
227
228    fn on_initialized(&self, ctx: &Context<'_>) -> impl Future<Output = ()> + Send {
229        self.inner.on_initialized(ctx)
230    }
231
232    fn on_roots_list_changed(&self, ctx: &Context<'_>) -> impl Future<Output = ()> + Send {
233        self.inner.on_roots_list_changed(ctx)
234    }
235
236    fn on_shutdown(&self) -> impl Future<Output = ()> + Send {
237        self.inner.on_shutdown()
238    }
239
240    fn set_log_level(
241        &self,
242        level: crate::handler::LogLevel,
243        ctx: &Context<'_>,
244    ) -> impl Future<Output = Result<(), McpError>> + Send {
245        self.inner.set_log_level(level, ctx)
246    }
247}
248
249impl<H: ResourceHandler> ResourceHandler for ValidatingToolHandler<H> {
250    fn list_resources(
251        &self,
252        ctx: &Context<'_>,
253    ) -> impl Future<Output = Result<Vec<Resource>, McpError>> + Send {
254        self.inner.list_resources(ctx)
255    }
256
257    fn list_resource_templates(
258        &self,
259        ctx: &Context<'_>,
260    ) -> impl Future<Output = Result<Vec<ResourceTemplate>, McpError>> + Send {
261        self.inner.list_resource_templates(ctx)
262    }
263
264    fn read_resource(
265        &self,
266        uri: &str,
267        ctx: &Context<'_>,
268    ) -> impl Future<Output = Result<Vec<ResourceContents>, McpError>> + Send {
269        self.inner.read_resource(uri, ctx)
270    }
271
272    fn subscribe(
273        &self,
274        uri: &str,
275        ctx: &Context<'_>,
276    ) -> impl Future<Output = Result<bool, McpError>> + Send {
277        self.inner.subscribe(uri, ctx)
278    }
279
280    fn unsubscribe(
281        &self,
282        uri: &str,
283        ctx: &Context<'_>,
284    ) -> impl Future<Output = Result<bool, McpError>> + Send {
285        self.inner.unsubscribe(uri, ctx)
286    }
287}
288
289impl<H: PromptHandler> PromptHandler for ValidatingToolHandler<H> {
290    fn list_prompts(
291        &self,
292        ctx: &Context<'_>,
293    ) -> impl Future<Output = Result<Vec<Prompt>, McpError>> + Send {
294        self.inner.list_prompts(ctx)
295    }
296
297    fn get_prompt(
298        &self,
299        name: &str,
300        args: Option<serde_json::Map<String, Value>>,
301        ctx: &Context<'_>,
302    ) -> impl Future<Output = Result<GetPromptResult, McpError>> + Send {
303        self.inner.get_prompt(name, args, ctx)
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::context::NoOpPeer;
311    use crate::router::route_tools;
312    use mcpkit_core::capability::{ClientCapabilities, ServerCapabilities};
313    use mcpkit_core::protocol::RequestId;
314    use mcpkit_core::protocol_version::ProtocolVersion;
315    use serde_json::json;
316
317    /// Unwrap a `json!` object literal into an `Object` map.
318    fn obj(v: Value) -> Object {
319        match v {
320            Value::Object(map) => map,
321            other => panic!("expected object, got {other}"),
322        }
323    }
324
325    /// A tool "add" declaring an inputSchema (`{ n: number }`, required) and an
326    /// outputSchema (`{ doubled: number }`, required). `call_tool` echoes back a
327    /// configurable `structuredContent` so tests can drive output validation.
328    struct SchemaHandler {
329        structured: Object,
330    }
331
332    impl ToolHandler for SchemaHandler {
333        async fn list_tools(&self, _ctx: &Context<'_>) -> Result<Vec<Tool>, McpError> {
334            Ok(vec![
335                Tool::new("add")
336                    .input_schema(json!({
337                        "type": "object",
338                        "properties": { "n": { "type": "number" } },
339                        "required": ["n"]
340                    }))
341                    .output_schema(json!({
342                        "type": "object",
343                        "properties": { "doubled": { "type": "number" } },
344                        "required": ["doubled"]
345                    })),
346            ])
347        }
348
349        async fn call_tool(
350            &self,
351            _name: &str,
352            _args: serde_json::Map<String, Value>,
353            _ctx: &Context<'_>,
354        ) -> Result<ToolOutput, McpError> {
355            Ok(ToolOutput::Success(
356                CallToolResult::text("ok").with_structured_content(self.structured.clone()),
357            ))
358        }
359    }
360
361    /// Run `f` with a throwaway `Context`.
362    async fn with_ctx<F, Fut, T>(f: F) -> T
363    where
364        F: FnOnce(Context<'static>) -> Fut,
365        Fut: std::future::Future<Output = T>,
366    {
367        let request_id = RequestId::Number(1);
368        let client_caps = ClientCapabilities::default();
369        let server_caps = ServerCapabilities::default();
370        let peer = NoOpPeer;
371        // Leak the borrows for the duration of the test: simplest way to hand a
372        // `Context` into an async closure without lifetime gymnastics.
373        let request_id: &'static RequestId = Box::leak(Box::new(request_id));
374        let client_caps: &'static ClientCapabilities = Box::leak(Box::new(client_caps));
375        let server_caps: &'static ServerCapabilities = Box::leak(Box::new(server_caps));
376        let peer: &'static NoOpPeer = Box::leak(Box::new(peer));
377        let ctx = Context::new(
378            request_id,
379            None,
380            client_caps,
381            server_caps,
382            ProtocolVersion::LATEST,
383            peer,
384        );
385        f(ctx).await
386    }
387
388    #[tokio::test]
389    async fn input_failure_is_a_tool_error_not_protocol_error() {
390        let handler = SchemaHandler {
391            structured: obj(json!({ "doubled": 84 })),
392        };
393        let validating = ValidatingToolHandler::new(handler, ValidationMode::both());
394        let out = with_ctx(|ctx| async move {
395            // Missing required "n" -> fails inputSchema.
396            validating
397                .call_tool("add", obj(json!({})), &ctx)
398                .await
399                .expect("input failure is Ok(isError), not a protocol Err")
400        })
401        .await;
402        match out {
403            ToolOutput::Success(result) => assert!(result.is_error(), "expected isError: true"),
404            other => panic!("expected a Success(isError) result, got {other:?}"),
405        }
406    }
407
408    #[tokio::test]
409    async fn valid_input_and_output_pass_through_untouched() {
410        let handler = SchemaHandler {
411            structured: obj(json!({ "doubled": 84 })),
412        };
413        let validating = ValidatingToolHandler::new(handler, ValidationMode::both());
414        let out = with_ctx(|ctx| async move {
415            validating
416                .call_tool("add", obj(json!({ "n": 42 })), &ctx)
417                .await
418                .expect("routed")
419        })
420        .await;
421        match out {
422            ToolOutput::Success(result) => {
423                assert!(!result.is_error());
424                assert_eq!(
425                    result.structured_content,
426                    Some(obj(json!({ "doubled": 84 })))
427                );
428            }
429            other => panic!("expected success, got {other:?}"),
430        }
431    }
432
433    #[tokio::test]
434    async fn output_schema_violation_drops_structured_content() {
435        let handler = SchemaHandler {
436            // `doubled` must be a number; a string violates the outputSchema.
437            structured: obj(json!({ "doubled": "not a number" })),
438        };
439        let validating = ValidatingToolHandler::new(handler, ValidationMode::both());
440        let out = with_ctx(|ctx| async move {
441            validating
442                .call_tool("add", obj(json!({ "n": 42 })), &ctx)
443                .await
444                .expect("routed")
445        })
446        .await;
447        match out {
448            ToolOutput::Success(result) => {
449                assert!(result.is_error(), "output violation must be isError: true");
450                assert!(
451                    result.structured_content.is_none(),
452                    "invalid structuredContent must be dropped"
453                );
454            }
455            other => panic!("expected success(isError), got {other:?}"),
456        }
457    }
458
459    #[tokio::test]
460    async fn inputs_only_mode_ignores_bad_output() {
461        let handler = SchemaHandler {
462            structured: obj(json!({ "doubled": "not a number" })),
463        };
464        let validating = ValidatingToolHandler::new(handler, ValidationMode::inputs_only());
465        let out = with_ctx(|ctx| async move {
466            validating
467                .call_tool("add", obj(json!({ "n": 42 })), &ctx)
468                .await
469                .expect("routed")
470        })
471        .await;
472        match out {
473            // outputs are not validated in inputs-only mode: bad structured passes.
474            ToolOutput::Success(result) => assert!(!result.is_error()),
475            other => panic!("expected success, got {other:?}"),
476        }
477    }
478
479    #[tokio::test]
480    async fn normal_tools_call_path_through_route_tools_is_validated() {
481        let handler = SchemaHandler {
482            structured: obj(json!({ "doubled": 84 })),
483        };
484        let validating = ValidatingToolHandler::new(handler, ValidationMode::both());
485        let result = with_ctx(|ctx| async move {
486            route_tools(
487                &validating,
488                "tools/call",
489                Some(&json!({ "name": "add", "arguments": {} })),
490                &ctx,
491                None,
492            )
493            .await
494            .expect("tools/call is routed")
495            .expect("ok result")
496        })
497        .await;
498        assert_eq!(result["isError"], json!(true));
499    }
500
501    #[test]
502    fn wrapped_combined_handler_satisfies_adapter_bounds() {
503        // Compile-time proof of the adapter escape hatch: the HTTP adapters bound
504        // a single handler on `ServerHandler + ToolHandler + ResourceHandler +
505        // PromptHandler`. Wrapping such a handler must still satisfy that bound,
506        // so `McpState::new(ValidatingToolHandler::new(h, mode))` type-checks.
507        use crate::handler::{PromptHandler, ResourceHandler, ServerHandler};
508        use mcpkit_core::types::{GetPromptResult, Prompt, Resource, ResourceContents};
509
510        fn adapter_bound<H: ServerHandler + ToolHandler + ResourceHandler + PromptHandler>(_h: &H) {
511        }
512
513        struct Combined;
514        impl ServerHandler for Combined {
515            fn server_info(&self) -> ServerInfo {
516                ServerInfo::new("t", "1.0.0")
517            }
518        }
519        impl ToolHandler for Combined {
520            async fn list_tools(&self, _ctx: &Context<'_>) -> Result<Vec<Tool>, McpError> {
521                Ok(vec![])
522            }
523            async fn call_tool(
524                &self,
525                _name: &str,
526                _args: serde_json::Map<String, Value>,
527                _ctx: &Context<'_>,
528            ) -> Result<ToolOutput, McpError> {
529                Ok(ToolOutput::text("x"))
530            }
531        }
532        impl ResourceHandler for Combined {
533            async fn list_resources(&self, _ctx: &Context<'_>) -> Result<Vec<Resource>, McpError> {
534                Ok(vec![])
535            }
536            async fn read_resource(
537                &self,
538                _uri: &str,
539                _ctx: &Context<'_>,
540            ) -> Result<Vec<ResourceContents>, McpError> {
541                Ok(vec![])
542            }
543        }
544        impl PromptHandler for Combined {
545            async fn list_prompts(&self, _ctx: &Context<'_>) -> Result<Vec<Prompt>, McpError> {
546                Ok(vec![])
547            }
548            async fn get_prompt(
549                &self,
550                _name: &str,
551                _args: Option<serde_json::Map<String, Value>>,
552                _ctx: &Context<'_>,
553            ) -> Result<GetPromptResult, McpError> {
554                Ok(GetPromptResult {
555                    description: None,
556                    messages: vec![],
557                    meta: None,
558                })
559            }
560        }
561
562        let wrapped = ValidatingToolHandler::new(Combined, ValidationMode::both());
563        adapter_bound(&wrapped);
564    }
565
566    #[tokio::test]
567    async fn unwrapped_handler_does_not_validate() {
568        // The escape hatch: without the decorator, bad input is not rejected.
569        // Proves validation is strictly opt-in (feature compiled in, not applied).
570        let handler = SchemaHandler {
571            structured: obj(json!({ "doubled": 84 })),
572        };
573        let result = with_ctx(|ctx| async move {
574            route_tools(
575                &handler,
576                "tools/call",
577                Some(&json!({ "name": "add", "arguments": {} })),
578                &ctx,
579                None,
580            )
581            .await
582            .expect("tools/call is routed")
583            .expect("ok result")
584        })
585        .await;
586        assert_ne!(result.get("isError"), Some(&json!(true)));
587    }
588}