1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub struct ValidationMode {
37 pub inputs: bool,
39 pub outputs: bool,
41}
42
43impl ValidationMode {
44 #[must_use]
46 pub const fn both() -> Self {
47 Self {
48 inputs: true,
49 outputs: true,
50 }
51 }
52
53 #[must_use]
55 pub const fn inputs_only() -> Self {
56 Self {
57 inputs: true,
58 outputs: false,
59 }
60 }
61
62 #[must_use]
64 pub const fn outputs_only() -> Self {
65 Self {
66 inputs: false,
67 outputs: true,
68 }
69 }
70}
71
72pub struct ValidatingToolHandler<H> {
84 inner: H,
85 mode: ValidationMode,
86}
87
88impl<H> ValidatingToolHandler<H> {
89 #[must_use]
91 pub const fn new(inner: H, mode: ValidationMode) -> Self {
92 Self { inner, mode }
93 }
94
95 pub fn into_inner(self) -> H {
97 self.inner
98 }
99}
100
101pub 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
120fn 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 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
210impl<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 fn obj(v: Value) -> Object {
319 match v {
320 Value::Object(map) => map,
321 other => panic!("expected object, got {other}"),
322 }
323 }
324
325 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 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 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 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 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 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 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 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}