feat!: add tool input arguments validation (#873)
added tool input arguments validation causes tool execution error. Breaking change, because validation is activated by default closes #697 Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
parent
95203233c8
commit
d1823389a9
6 changed files with 496 additions and 9 deletions
|
|
@ -38,6 +38,7 @@ import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
|
||||||
import io.modelcontextprotocol.util.Assert;
|
import io.modelcontextprotocol.util.Assert;
|
||||||
import io.modelcontextprotocol.util.DefaultMcpUriTemplateManagerFactory;
|
import io.modelcontextprotocol.util.DefaultMcpUriTemplateManagerFactory;
|
||||||
import io.modelcontextprotocol.util.McpUriTemplateManagerFactory;
|
import io.modelcontextprotocol.util.McpUriTemplateManagerFactory;
|
||||||
|
import io.modelcontextprotocol.util.ToolInputValidator;
|
||||||
import io.modelcontextprotocol.util.Utils;
|
import io.modelcontextprotocol.util.Utils;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
@ -98,6 +99,8 @@ public class McpAsyncServer {
|
||||||
|
|
||||||
private final JsonSchemaValidator jsonSchemaValidator;
|
private final JsonSchemaValidator jsonSchemaValidator;
|
||||||
|
|
||||||
|
private final boolean validateToolInputs;
|
||||||
|
|
||||||
private final McpSchema.ServerCapabilities serverCapabilities;
|
private final McpSchema.ServerCapabilities serverCapabilities;
|
||||||
|
|
||||||
private final McpSchema.Implementation serverInfo;
|
private final McpSchema.Implementation serverInfo;
|
||||||
|
|
@ -129,7 +132,8 @@ public class McpAsyncServer {
|
||||||
*/
|
*/
|
||||||
McpAsyncServer(McpServerTransportProvider mcpTransportProvider, McpJsonMapper jsonMapper,
|
McpAsyncServer(McpServerTransportProvider mcpTransportProvider, McpJsonMapper jsonMapper,
|
||||||
McpServerFeatures.Async features, Duration requestTimeout,
|
McpServerFeatures.Async features, Duration requestTimeout,
|
||||||
McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator) {
|
McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator,
|
||||||
|
boolean validateToolInputs) {
|
||||||
this.mcpTransportProvider = mcpTransportProvider;
|
this.mcpTransportProvider = mcpTransportProvider;
|
||||||
this.jsonMapper = jsonMapper;
|
this.jsonMapper = jsonMapper;
|
||||||
this.serverInfo = features.serverInfo();
|
this.serverInfo = features.serverInfo();
|
||||||
|
|
@ -142,6 +146,7 @@ public class McpAsyncServer {
|
||||||
this.completions.putAll(features.completions());
|
this.completions.putAll(features.completions());
|
||||||
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
|
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
|
||||||
this.jsonSchemaValidator = jsonSchemaValidator;
|
this.jsonSchemaValidator = jsonSchemaValidator;
|
||||||
|
this.validateToolInputs = validateToolInputs;
|
||||||
|
|
||||||
Map<String, McpRequestHandler<?>> requestHandlers = prepareRequestHandlers();
|
Map<String, McpRequestHandler<?>> requestHandlers = prepareRequestHandlers();
|
||||||
Map<String, McpNotificationHandler> notificationHandlers = prepareNotificationHandlers(features);
|
Map<String, McpNotificationHandler> notificationHandlers = prepareNotificationHandlers(features);
|
||||||
|
|
@ -157,7 +162,8 @@ public class McpAsyncServer {
|
||||||
|
|
||||||
McpAsyncServer(McpStreamableServerTransportProvider mcpTransportProvider, McpJsonMapper jsonMapper,
|
McpAsyncServer(McpStreamableServerTransportProvider mcpTransportProvider, McpJsonMapper jsonMapper,
|
||||||
McpServerFeatures.Async features, Duration requestTimeout,
|
McpServerFeatures.Async features, Duration requestTimeout,
|
||||||
McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator) {
|
McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator,
|
||||||
|
boolean validateToolInputs) {
|
||||||
this.mcpTransportProvider = mcpTransportProvider;
|
this.mcpTransportProvider = mcpTransportProvider;
|
||||||
this.jsonMapper = jsonMapper;
|
this.jsonMapper = jsonMapper;
|
||||||
this.serverInfo = features.serverInfo();
|
this.serverInfo = features.serverInfo();
|
||||||
|
|
@ -170,6 +176,7 @@ public class McpAsyncServer {
|
||||||
this.completions.putAll(features.completions());
|
this.completions.putAll(features.completions());
|
||||||
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
|
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
|
||||||
this.jsonSchemaValidator = jsonSchemaValidator;
|
this.jsonSchemaValidator = jsonSchemaValidator;
|
||||||
|
this.validateToolInputs = validateToolInputs;
|
||||||
|
|
||||||
Map<String, McpRequestHandler<?>> requestHandlers = prepareRequestHandlers();
|
Map<String, McpRequestHandler<?>> requestHandlers = prepareRequestHandlers();
|
||||||
Map<String, McpNotificationHandler> notificationHandlers = prepareNotificationHandlers(features);
|
Map<String, McpNotificationHandler> notificationHandlers = prepareNotificationHandlers(features);
|
||||||
|
|
@ -543,6 +550,13 @@ public class McpAsyncServer {
|
||||||
.build());
|
.build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
McpSchema.Tool tool = toolSpecification.get().tool();
|
||||||
|
CallToolResult validationError = ToolInputValidator.validate(tool, callToolRequest.arguments(),
|
||||||
|
this.validateToolInputs, this.jsonSchemaValidator);
|
||||||
|
if (validationError != null) {
|
||||||
|
return Mono.just(validationError);
|
||||||
|
}
|
||||||
|
|
||||||
return toolSpecification.get().callHandler().apply(exchange, callToolRequest);
|
return toolSpecification.get().callHandler().apply(exchange, callToolRequest);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -243,7 +243,7 @@ public interface McpServer {
|
||||||
: McpJsonDefaults.getSchemaValidator();
|
: McpJsonDefaults.getSchemaValidator();
|
||||||
|
|
||||||
return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
|
return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
|
||||||
features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator);
|
features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -269,7 +269,7 @@ public interface McpServer {
|
||||||
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
|
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
|
||||||
: McpJsonDefaults.getSchemaValidator();
|
: McpJsonDefaults.getSchemaValidator();
|
||||||
return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
|
return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
|
||||||
features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator);
|
features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -293,6 +293,8 @@ public interface McpServer {
|
||||||
|
|
||||||
boolean strictToolNameValidation = ToolNameValidator.isStrictByDefault();
|
boolean strictToolNameValidation = ToolNameValidator.isStrictByDefault();
|
||||||
|
|
||||||
|
boolean validateToolInputs = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Model Context Protocol (MCP) allows servers to expose tools that can be
|
* The Model Context Protocol (MCP) allows servers to expose tools that can be
|
||||||
* invoked by language models. Tools enable models to interact with external
|
* invoked by language models. Tools enable models to interact with external
|
||||||
|
|
@ -421,6 +423,17 @@ public interface McpServer {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets whether to validate tool inputs against the tool's input schema.
|
||||||
|
* @param validate true to validate inputs and return error on validation failure,
|
||||||
|
* false to skip validation. Defaults to true.
|
||||||
|
* @return This builder instance for method chaining
|
||||||
|
*/
|
||||||
|
public AsyncSpecification<S> validateToolInputs(boolean validate) {
|
||||||
|
this.validateToolInputs = validate;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the server capabilities that will be advertised to clients during
|
* Sets the server capabilities that will be advertised to clients during
|
||||||
* connection initialization. Capabilities define what features the server
|
* connection initialization. Capabilities define what features the server
|
||||||
|
|
@ -818,7 +831,8 @@ public interface McpServer {
|
||||||
var asyncServer = new McpAsyncServer(transportProvider,
|
var asyncServer = new McpAsyncServer(transportProvider,
|
||||||
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, requestTimeout,
|
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, requestTimeout,
|
||||||
uriTemplateManagerFactory,
|
uriTemplateManagerFactory,
|
||||||
jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator());
|
jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(),
|
||||||
|
validateToolInputs);
|
||||||
return new McpSyncServer(asyncServer, this.immediateExecution);
|
return new McpSyncServer(asyncServer, this.immediateExecution);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -849,7 +863,7 @@ public interface McpServer {
|
||||||
: McpJsonDefaults.getSchemaValidator();
|
: McpJsonDefaults.getSchemaValidator();
|
||||||
var asyncServer = new McpAsyncServer(transportProvider,
|
var asyncServer = new McpAsyncServer(transportProvider,
|
||||||
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, this.requestTimeout,
|
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, this.requestTimeout,
|
||||||
this.uriTemplateManagerFactory, jsonSchemaValidator);
|
this.uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs);
|
||||||
return new McpSyncServer(asyncServer, this.immediateExecution);
|
return new McpSyncServer(asyncServer, this.immediateExecution);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -872,6 +886,8 @@ public interface McpServer {
|
||||||
|
|
||||||
boolean strictToolNameValidation = ToolNameValidator.isStrictByDefault();
|
boolean strictToolNameValidation = ToolNameValidator.isStrictByDefault();
|
||||||
|
|
||||||
|
boolean validateToolInputs = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Model Context Protocol (MCP) allows servers to expose tools that can be
|
* The Model Context Protocol (MCP) allows servers to expose tools that can be
|
||||||
* invoked by language models. Tools enable models to interact with external
|
* invoked by language models. Tools enable models to interact with external
|
||||||
|
|
@ -1004,6 +1020,17 @@ public interface McpServer {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets whether to validate tool inputs against the tool's input schema.
|
||||||
|
* @param validate true to validate inputs and return error on validation failure,
|
||||||
|
* false to skip validation. Defaults to true.
|
||||||
|
* @return This builder instance for method chaining
|
||||||
|
*/
|
||||||
|
public SyncSpecification<S> validateToolInputs(boolean validate) {
|
||||||
|
this.validateToolInputs = validate;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the server capabilities that will be advertised to clients during
|
* Sets the server capabilities that will be advertised to clients during
|
||||||
* connection initialization. Capabilities define what features the server
|
* connection initialization. Capabilities define what features the server
|
||||||
|
|
@ -1401,6 +1428,8 @@ public interface McpServer {
|
||||||
|
|
||||||
boolean strictToolNameValidation = ToolNameValidator.isStrictByDefault();
|
boolean strictToolNameValidation = ToolNameValidator.isStrictByDefault();
|
||||||
|
|
||||||
|
boolean validateToolInputs = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Model Context Protocol (MCP) allows servers to expose tools that can be
|
* The Model Context Protocol (MCP) allows servers to expose tools that can be
|
||||||
* invoked by language models. Tools enable models to interact with external
|
* invoked by language models. Tools enable models to interact with external
|
||||||
|
|
@ -1530,6 +1559,17 @@ public interface McpServer {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets whether to validate tool inputs against the tool's input schema.
|
||||||
|
* @param validate true to validate inputs and return error on validation failure,
|
||||||
|
* false to skip validation. Defaults to true.
|
||||||
|
* @return This builder instance for method chaining
|
||||||
|
*/
|
||||||
|
public StatelessAsyncSpecification validateToolInputs(boolean validate) {
|
||||||
|
this.validateToolInputs = validate;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the server capabilities that will be advertised to clients during
|
* Sets the server capabilities that will be advertised to clients during
|
||||||
* connection initialization. Capabilities define what features the server
|
* connection initialization. Capabilities define what features the server
|
||||||
|
|
@ -1859,7 +1899,8 @@ public interface McpServer {
|
||||||
this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions);
|
this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions);
|
||||||
return new McpStatelessAsyncServer(transport, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
|
return new McpStatelessAsyncServer(transport, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
|
||||||
features, requestTimeout, uriTemplateManagerFactory,
|
features, requestTimeout, uriTemplateManagerFactory,
|
||||||
jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator());
|
jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(),
|
||||||
|
validateToolInputs);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -1884,6 +1925,8 @@ public interface McpServer {
|
||||||
|
|
||||||
boolean strictToolNameValidation = ToolNameValidator.isStrictByDefault();
|
boolean strictToolNameValidation = ToolNameValidator.isStrictByDefault();
|
||||||
|
|
||||||
|
boolean validateToolInputs = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Model Context Protocol (MCP) allows servers to expose tools that can be
|
* The Model Context Protocol (MCP) allows servers to expose tools that can be
|
||||||
* invoked by language models. Tools enable models to interact with external
|
* invoked by language models. Tools enable models to interact with external
|
||||||
|
|
@ -2013,6 +2056,17 @@ public interface McpServer {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets whether to validate tool inputs against the tool's input schema.
|
||||||
|
* @param validate true to validate inputs and return error on validation failure,
|
||||||
|
* false to skip validation. Defaults to true.
|
||||||
|
* @return This builder instance for method chaining
|
||||||
|
*/
|
||||||
|
public StatelessSyncSpecification validateToolInputs(boolean validate) {
|
||||||
|
this.validateToolInputs = validate;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the server capabilities that will be advertised to clients during
|
* Sets the server capabilities that will be advertised to clients during
|
||||||
* connection initialization. Capabilities define what features the server
|
* connection initialization. Capabilities define what features the server
|
||||||
|
|
@ -2360,7 +2414,8 @@ public interface McpServer {
|
||||||
var asyncServer = new McpStatelessAsyncServer(transport,
|
var asyncServer = new McpStatelessAsyncServer(transport,
|
||||||
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, requestTimeout,
|
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, requestTimeout,
|
||||||
uriTemplateManagerFactory,
|
uriTemplateManagerFactory,
|
||||||
this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator());
|
this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(),
|
||||||
|
validateToolInputs);
|
||||||
return new McpStatelessSyncServer(asyncServer, this.immediateExecution);
|
return new McpStatelessSyncServer(asyncServer, this.immediateExecution);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import io.modelcontextprotocol.spec.McpStatelessServerTransport;
|
||||||
import io.modelcontextprotocol.util.Assert;
|
import io.modelcontextprotocol.util.Assert;
|
||||||
import io.modelcontextprotocol.util.DefaultMcpUriTemplateManagerFactory;
|
import io.modelcontextprotocol.util.DefaultMcpUriTemplateManagerFactory;
|
||||||
import io.modelcontextprotocol.util.McpUriTemplateManagerFactory;
|
import io.modelcontextprotocol.util.McpUriTemplateManagerFactory;
|
||||||
|
import io.modelcontextprotocol.util.ToolInputValidator;
|
||||||
import io.modelcontextprotocol.util.Utils;
|
import io.modelcontextprotocol.util.Utils;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
@ -77,9 +78,12 @@ public class McpStatelessAsyncServer {
|
||||||
|
|
||||||
private final JsonSchemaValidator jsonSchemaValidator;
|
private final JsonSchemaValidator jsonSchemaValidator;
|
||||||
|
|
||||||
|
private final boolean validateToolInputs;
|
||||||
|
|
||||||
McpStatelessAsyncServer(McpStatelessServerTransport mcpTransport, McpJsonMapper jsonMapper,
|
McpStatelessAsyncServer(McpStatelessServerTransport mcpTransport, McpJsonMapper jsonMapper,
|
||||||
McpStatelessServerFeatures.Async features, Duration requestTimeout,
|
McpStatelessServerFeatures.Async features, Duration requestTimeout,
|
||||||
McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator) {
|
McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator,
|
||||||
|
boolean validateToolInputs) {
|
||||||
this.mcpTransportProvider = mcpTransport;
|
this.mcpTransportProvider = mcpTransport;
|
||||||
this.jsonMapper = jsonMapper;
|
this.jsonMapper = jsonMapper;
|
||||||
this.serverInfo = features.serverInfo();
|
this.serverInfo = features.serverInfo();
|
||||||
|
|
@ -92,6 +96,7 @@ public class McpStatelessAsyncServer {
|
||||||
this.completions.putAll(features.completions());
|
this.completions.putAll(features.completions());
|
||||||
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
|
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
|
||||||
this.jsonSchemaValidator = jsonSchemaValidator;
|
this.jsonSchemaValidator = jsonSchemaValidator;
|
||||||
|
this.validateToolInputs = validateToolInputs;
|
||||||
|
|
||||||
Map<String, McpStatelessRequestHandler<?>> requestHandlers = new HashMap<>();
|
Map<String, McpStatelessRequestHandler<?>> requestHandlers = new HashMap<>();
|
||||||
|
|
||||||
|
|
@ -409,6 +414,13 @@ public class McpStatelessAsyncServer {
|
||||||
.build());
|
.build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
McpSchema.Tool tool = toolSpecification.get().tool();
|
||||||
|
CallToolResult validationError = ToolInputValidator.validate(tool, callToolRequest.arguments(),
|
||||||
|
this.validateToolInputs, this.jsonSchemaValidator);
|
||||||
|
if (validationError != null) {
|
||||||
|
return Mono.just(validationError);
|
||||||
|
}
|
||||||
|
|
||||||
return toolSpecification.get().callHandler().apply(ctx, callToolRequest);
|
return toolSpecification.get().callHandler().apply(ctx, callToolRequest);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2026-2026 the original author or authors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.modelcontextprotocol.util;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates tool input arguments against JSON schema.
|
||||||
|
*
|
||||||
|
* @author Andrei Shakirin
|
||||||
|
*/
|
||||||
|
public final class ToolInputValidator {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(ToolInputValidator.class);
|
||||||
|
|
||||||
|
private ToolInputValidator() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates tool arguments against the tool's input schema.
|
||||||
|
* @param tool the tool definition containing the input schema
|
||||||
|
* @param arguments the arguments to validate
|
||||||
|
* @param validateToolInputs whether validation is enabled
|
||||||
|
* @param validator the JSON schema validator (may be null)
|
||||||
|
* @return CallToolResult with isError=true if validation fails, null if valid or
|
||||||
|
* validation skipped
|
||||||
|
*/
|
||||||
|
public static CallToolResult validate(McpSchema.Tool tool, Map<String, Object> arguments,
|
||||||
|
boolean validateToolInputs, JsonSchemaValidator validator) {
|
||||||
|
if (!validateToolInputs || tool.inputSchema() == null || validator == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Map<String, Object> args = arguments != null ? arguments : Map.of();
|
||||||
|
var validation = validator.validate(tool.inputSchema(), args);
|
||||||
|
if (!validation.valid()) {
|
||||||
|
logger.warn("Tool '{}' input validation failed: {}", tool.name(), validation.errorMessage());
|
||||||
|
return CallToolResult.builder()
|
||||||
|
.content(List.of(new McpSchema.TextContent(validation.errorMessage())))
|
||||||
|
.isError(true)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2026-2026 the original author or authors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.modelcontextprotocol.util;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
|
||||||
|
import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.TextContent;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.Tool;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link ToolInputValidator}.
|
||||||
|
*
|
||||||
|
* @author Andrei Shakirin
|
||||||
|
*/
|
||||||
|
class ToolInputValidatorTests {
|
||||||
|
|
||||||
|
private final JsonSchemaValidator validator = mock(JsonSchemaValidator.class);
|
||||||
|
|
||||||
|
private final Map<String, Object> inputSchema = Map.of("type", "object", "properties",
|
||||||
|
Map.of("name", Map.of("type", "string")), "required", List.of("name"));
|
||||||
|
|
||||||
|
private final Tool toolWithSchema = Tool.builder()
|
||||||
|
.name("test-tool")
|
||||||
|
.description("Test tool")
|
||||||
|
.inputSchema(inputSchema)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
private final Tool toolWithoutSchema = Tool.builder().name("test-tool").description("Test tool").build();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_whenDisabled_returnsNull() {
|
||||||
|
CallToolResult result = ToolInputValidator.validate(toolWithSchema, Map.of("name", "test"), false, validator);
|
||||||
|
|
||||||
|
assertThat(result).isNull();
|
||||||
|
verify(validator, never()).validate(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_whenNoSchema_returnsNull() {
|
||||||
|
CallToolResult result = ToolInputValidator.validate(toolWithoutSchema, Map.of("name", "test"), true, validator);
|
||||||
|
|
||||||
|
assertThat(result).isNull();
|
||||||
|
verify(validator, never()).validate(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_whenNoValidator_returnsNull() {
|
||||||
|
CallToolResult result = ToolInputValidator.validate(toolWithSchema, Map.of("name", "test"), true, null);
|
||||||
|
|
||||||
|
assertThat(result).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_withValidInput_returnsNull() {
|
||||||
|
when(validator.validate(any(), any())).thenReturn(ValidationResponse.asValid(null));
|
||||||
|
|
||||||
|
CallToolResult result = ToolInputValidator.validate(toolWithSchema, Map.of("name", "test"), true, validator);
|
||||||
|
|
||||||
|
assertThat(result).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_withInvalidInput_returnsErrorResult() {
|
||||||
|
when(validator.validate(any(), any())).thenReturn(ValidationResponse.asInvalid("missing required: 'name'"));
|
||||||
|
|
||||||
|
CallToolResult result = ToolInputValidator.validate(toolWithSchema, Map.of(), true, validator);
|
||||||
|
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
assertThat(result.isError()).isTrue();
|
||||||
|
assertThat(((TextContent) result.content().get(0)).text()).contains("missing required: 'name'");
|
||||||
|
verify(validator).validate(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_withNullArguments_usesEmptyMap() {
|
||||||
|
when(validator.validate(any(), any())).thenReturn(ValidationResponse.asValid(null));
|
||||||
|
|
||||||
|
CallToolResult result = ToolInputValidator.validate(toolWithSchema, null, true, validator);
|
||||||
|
|
||||||
|
assertThat(result).isNull();
|
||||||
|
verify(validator).validate(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,254 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2026-2026 the original author or authors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.modelcontextprotocol.server;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import io.modelcontextprotocol.client.McpClient;
|
||||||
|
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
|
||||||
|
import io.modelcontextprotocol.common.McpTransportContext;
|
||||||
|
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
|
||||||
|
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.TextContent;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.Tool;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.apache.catalina.LifecycleException;
|
||||||
|
import org.apache.catalina.LifecycleState;
|
||||||
|
import org.apache.catalina.startup.Tomcat;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Timeout;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.Arguments;
|
||||||
|
import org.junit.jupiter.params.provider.MethodSource;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for tool input validation against JSON schema. Validates that input validation
|
||||||
|
* errors are returned as Tool Execution Errors (isError=true) rather than Protocol
|
||||||
|
* Errors, per MCP specification.
|
||||||
|
*
|
||||||
|
* @author Andrei Shakirin
|
||||||
|
*/
|
||||||
|
@Timeout(15)
|
||||||
|
class ToolInputValidationIntegrationTests {
|
||||||
|
|
||||||
|
private static final int PORT = TomcatTestUtil.findAvailablePort();
|
||||||
|
|
||||||
|
private static final String MESSAGE_ENDPOINT = "/mcp/message";
|
||||||
|
|
||||||
|
private static final String TOOL_NAME = "test-tool";
|
||||||
|
|
||||||
|
private static final McpSchema.JsonSchema INPUT_SCHEMA = new McpSchema.JsonSchema("object",
|
||||||
|
Map.of("name", Map.of("type", "string"), "age", Map.of("type", "integer", "minimum", 0)),
|
||||||
|
List.of("name", "age"), null, null, null);
|
||||||
|
|
||||||
|
private static final McpTransportContextExtractor<HttpServletRequest> TEST_CONTEXT_EXTRACTOR = (
|
||||||
|
r) -> McpTransportContext.create(Map.of("important", "value"));
|
||||||
|
|
||||||
|
private HttpServletStreamableServerTransportProvider mcpServerTransportProvider;
|
||||||
|
|
||||||
|
private Tomcat tomcat;
|
||||||
|
|
||||||
|
static Stream<Arguments> validInputTestCases() {
|
||||||
|
return Stream.of(
|
||||||
|
// serverType, validationEnabled, inputArgs, expectedOutput
|
||||||
|
Arguments.of("sync", true, Map.of("name", "Alice", "age", 30), "Hello Alice, age 30"),
|
||||||
|
Arguments.of("async", true, Map.of("name", "Bob", "age", 25), "Hello Bob, age 25"),
|
||||||
|
Arguments.of("sync", false, Map.of("name", "Alice", "age", 30), "Hello Alice, age 30"),
|
||||||
|
Arguments.of("async", false, Map.of("name", "Bob", "age", 25), "Hello Bob, age 25"));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Stream<Arguments> invalidInputTestCases() {
|
||||||
|
return Stream.of(
|
||||||
|
// serverType, inputArgs, expectedErrorSubstring
|
||||||
|
Arguments.of("sync", Map.of("name", "Alice"), "age"), // missing required
|
||||||
|
Arguments.of("async", Map.of("name", "Bob", "age", -10), "minimum")); // invalid
|
||||||
|
// value
|
||||||
|
}
|
||||||
|
|
||||||
|
private final McpClient.SyncSpec clientBuilder = McpClient
|
||||||
|
.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT).endpoint(MESSAGE_ENDPOINT).build())
|
||||||
|
.requestTimeout(Duration.ofSeconds(10));
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
public void before() {
|
||||||
|
mcpServerTransportProvider = HttpServletStreamableServerTransportProvider.builder()
|
||||||
|
.mcpEndpoint(MESSAGE_ENDPOINT)
|
||||||
|
.contextExtractor(TEST_CONTEXT_EXTRACTOR)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
tomcat = TomcatTestUtil.createTomcatServer("", PORT, mcpServerTransportProvider);
|
||||||
|
try {
|
||||||
|
tomcat.start();
|
||||||
|
assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED);
|
||||||
|
}
|
||||||
|
catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to start Tomcat", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected McpServer.AsyncSpecification<?> prepareAsyncServerBuilder() {
|
||||||
|
return McpServer.async(this.mcpServerTransportProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected McpServer.SyncSpecification<?> prepareSyncServerBuilder() {
|
||||||
|
return McpServer.sync(this.mcpServerTransportProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
public void after() {
|
||||||
|
if (mcpServerTransportProvider != null) {
|
||||||
|
mcpServerTransportProvider.closeGracefully().block();
|
||||||
|
}
|
||||||
|
if (tomcat != null) {
|
||||||
|
try {
|
||||||
|
tomcat.stop();
|
||||||
|
tomcat.destroy();
|
||||||
|
}
|
||||||
|
catch (LifecycleException e) {
|
||||||
|
throw new RuntimeException("Failed to stop Tomcat", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private McpServerFeatures.SyncToolSpecification createSyncTool() {
|
||||||
|
Tool tool = Tool.builder()
|
||||||
|
.name(TOOL_NAME)
|
||||||
|
.description("Test tool with schema")
|
||||||
|
.inputSchema(INPUT_SCHEMA)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return McpServerFeatures.SyncToolSpecification.builder().tool(tool).callHandler((exchange, request) -> {
|
||||||
|
String name = (String) request.arguments().get("name");
|
||||||
|
Integer age = ((Number) request.arguments().get("age")).intValue();
|
||||||
|
return CallToolResult.builder()
|
||||||
|
.content(List.of(new TextContent("Hello " + name + ", age " + age)))
|
||||||
|
.isError(false)
|
||||||
|
.build();
|
||||||
|
}).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private McpServerFeatures.AsyncToolSpecification createAsyncTool() {
|
||||||
|
Tool tool = Tool.builder()
|
||||||
|
.name(TOOL_NAME)
|
||||||
|
.description("Test tool with schema")
|
||||||
|
.inputSchema(INPUT_SCHEMA)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return McpServerFeatures.AsyncToolSpecification.builder().tool(tool).callHandler((exchange, request) -> {
|
||||||
|
String name = (String) request.arguments().get("name");
|
||||||
|
Integer age = ((Number) request.arguments().get("age")).intValue();
|
||||||
|
return Mono.just(CallToolResult.builder()
|
||||||
|
.content(List.of(new TextContent("Hello " + name + ", age " + age)))
|
||||||
|
.isError(false)
|
||||||
|
.build());
|
||||||
|
}).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest(name = "{0} server, validation={1}")
|
||||||
|
@MethodSource("validInputTestCases")
|
||||||
|
void validInput_shouldSucceed(String serverType, boolean validationEnabled, Map<String, Object> input,
|
||||||
|
String expectedOutput) {
|
||||||
|
Object server = createServer(serverType, validationEnabled);
|
||||||
|
|
||||||
|
try (var client = clientBuilder.clientInfo(new McpSchema.Implementation("test-client", "1.0.0")).build()) {
|
||||||
|
client.initialize();
|
||||||
|
CallToolResult result = client.callTool(new CallToolRequest(TOOL_NAME, input));
|
||||||
|
|
||||||
|
assertThat(result.isError()).isFalse();
|
||||||
|
assertThat(((TextContent) result.content().get(0)).text()).isEqualTo(expectedOutput);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
closeServer(server, serverType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest(name = "{0} server, input={1}")
|
||||||
|
@MethodSource("invalidInputTestCases")
|
||||||
|
void invalidInput_withDefaultValidation_shouldReturnToolError(String serverType, Map<String, Object> input,
|
||||||
|
String expectedErrorSubstring) {
|
||||||
|
Object server = createServerWithDefaultValidation(serverType);
|
||||||
|
|
||||||
|
try (var client = clientBuilder.clientInfo(new McpSchema.Implementation("test-client", "1.0.0")).build()) {
|
||||||
|
client.initialize();
|
||||||
|
CallToolResult result = client.callTool(new CallToolRequest(TOOL_NAME, input));
|
||||||
|
|
||||||
|
assertThat(result.isError()).isTrue();
|
||||||
|
String errorMessage = ((TextContent) result.content().get(0)).text();
|
||||||
|
assertThat(errorMessage).containsIgnoringCase(expectedErrorSubstring);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
closeServer(server, serverType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest(name = "{0} server, input={1}")
|
||||||
|
@MethodSource("invalidInputTestCases")
|
||||||
|
void invalidInput_withValidationDisabled_shouldSucceed(String serverType, Map<String, Object> input,
|
||||||
|
String ignored) {
|
||||||
|
Object server = createServer(serverType, false);
|
||||||
|
|
||||||
|
try (var client = clientBuilder.clientInfo(new McpSchema.Implementation("test-client", "1.0.0")).build()) {
|
||||||
|
client.initialize();
|
||||||
|
// Invalid input should pass through when validation is disabled
|
||||||
|
// The tool handler will fail, but that's expected - we're testing validation
|
||||||
|
// is skipped
|
||||||
|
try {
|
||||||
|
client.callTool(new CallToolRequest(TOOL_NAME, input));
|
||||||
|
}
|
||||||
|
catch (Exception e) {
|
||||||
|
// Expected - tool handler fails on invalid input, but validation didn't
|
||||||
|
// block it
|
||||||
|
assertThat(e.getMessage()).doesNotContainIgnoringCase("validation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
closeServer(server, serverType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object createServerWithDefaultValidation(String serverType) {
|
||||||
|
if ("sync".equals(serverType)) {
|
||||||
|
return prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").tools(createSyncTool()).build();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(createAsyncTool()).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object createServer(String serverType, boolean validationEnabled) {
|
||||||
|
if ("sync".equals(serverType)) {
|
||||||
|
return prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||||
|
.validateToolInputs(validationEnabled)
|
||||||
|
.tools(createSyncTool())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||||
|
.validateToolInputs(validationEnabled)
|
||||||
|
.tools(createAsyncTool())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeServer(Object server, String serverType) {
|
||||||
|
if ("async".equals(serverType)) {
|
||||||
|
((McpAsyncServer) server).closeGracefully().block();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
((McpSyncServer) server).close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue