feat: validate embedded JSON Schema documents against 2020-12 meta-schema (SEP-1613) (#949)

MCP embeds JSON Schema documents in three places — Tool.inputSchema,
Tool.outputSchema, and ElicitRequest.requestedSchema — and SEP-1613
mandates that these documents conform to JSON Schema 2020-12 by default.

Servers now reject malformed schemas at both build time (McpServer.build())
and runtime (McpAsyncServer/McpStatelessAsyncServer.addTool()), returning
an IllegalArgumentException that identifies the offending field and
references SEP-1613.  Elicitation requests whose requestedSchema violates
the meta-schema are rejected before being sent to the client.

Schemas that explicitly declare a different dialect via $schema are accepted
without meta-schema validation, since 2020-12 is the default, not the only
permitted dialect.

Refine meta-schema loading and deprecate orphan JsonSchemaValidator

Resolves #700

Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com>
This commit is contained in:
Dariusz Jędrzejczyk 2026-05-07 14:14:53 +02:00 • committed by GitHub
commit 87e2c7d4de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 903 additions and 35 deletions

View file

@ -8,10 +8,13 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import com.networknt.schema.Schema;
import com.networknt.schema.SchemaLocation;
import com.networknt.schema.SchemaRegistry;
import com.networknt.schema.Error;
import com.networknt.schema.dialect.Dialects;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.util.Assert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -37,14 +40,18 @@ public class DefaultJsonSchemaValidator implements JsonSchemaValidator {
// TODO: Implement a strategy to purge the cache (TTL, size limit, etc.)
private final ConcurrentHashMap<String, Schema> schemaCache;
private final Schema metaSchema202012;
public DefaultJsonSchemaValidator() {
this(JsonMapper.shared());
}
public DefaultJsonSchemaValidator(JsonMapper jsonMapper) {
this.jsonMapper = jsonMapper;
this.schemaFactory = SchemaRegistry.withDialect(Dialects.getDraft202012());
this.schemaFactory = SchemaRegistry.withDefaultDialect(Dialects.getDraft202012());
this.schemaCache = new ConcurrentHashMap<>();
this.metaSchema202012 = schemaFactory
.getSchema(SchemaLocation.of("https://json-schema.org/draft/2020-12/schema"));
}
@Override
@ -85,6 +92,31 @@ public class DefaultJsonSchemaValidator implements JsonSchemaValidator {
}
}
@Override
public ValidationResponse validateSchema(Map<String, Object> schema) {
Assert.notNull(schema, "schema must not be null");
Object declaredDialect = schema.get("$schema");
if (declaredDialect != null && !McpSchema.JSON_SCHEMA_DIALECT_2020_12.equals(declaredDialect.toString())) {
return ValidationResponse.asValid(null);
}
if (this.metaSchema202012 == null) {
return ValidationResponse.asValid(null);
}
try {
JsonNode schemaNode = this.jsonMapper.valueToTree(schema);
List<Error> errors = this.metaSchema202012.validate(schemaNode);
if (!errors.isEmpty()) {
return ValidationResponse
.asInvalid("Schema does not conform to JSON Schema 2020-12 (SEP-1613): " + errors);
}
return ValidationResponse.asValid(null);
}
catch (Exception e) {
logger.error("Failed to validate schema definition: {}", e.getMessage());
return ValidationResponse.asInvalid("Failed to validate schema definition: " + e.getMessage());
}
}
/**
* Gets a cached Schema or creates and caches a new one.
* @param schema the schema map to convert

View file

@ -4,6 +4,7 @@
package io.modelcontextprotocol.json;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
@ -17,6 +18,8 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -804,4 +807,107 @@ class DefaultJsonSchemaValidatorTests {
assertNotEquals(response1, response2);
}
@Test
void validatesSchemaWithExplicitDraft07Dialect() {
Map<String, Object> schema = Map.of("$schema", "http://json-schema.org/draft-07/schema#", "type", "object",
"properties", Map.of("name", Map.of("type", "string")), "required", List.of("name"));
assertTrue(validator.validate(schema, Map.of("name", "alice")).valid());
assertFalse(validator.validate(schema, Map.of()).valid());
}
@Test
void validatesSchemaWithExplicit2020_12Dialect() {
Map<String, Object> schema = Map.of("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12, "type", "object",
"properties", Map.of("name", Map.of("type", "string")), "required", List.of("name"));
assertTrue(validator.validate(schema, Map.of("name", "alice")).valid());
assertFalse(validator.validate(schema, Map.of()).valid());
}
@Test
void validatesSchemaWith2020_12Keywords() {
Map<String, Object> schema = Map.of("type", "array", "prefixItems",
List.of(Map.of("type", "string"), Map.of("type", "number")));
assertTrue(validator.validate(schema, List.of("hello", 42)).valid());
assertFalse(validator.validate(schema, List.of(1, "wrong")).valid());
}
@Test
void validatesOutputAgainstSchemaWithDefsAndRef() {
Map<String, Object> schema = Map.of("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12, "type", "object", "$defs",
Map.of("address",
Map.of("type", "object", "properties",
Map.of("street", Map.of("type", "string"), "city", Map.of("type", "string")))),
"properties", Map.of("name", Map.of("type", "string"), "address", Map.of("$ref", "#/$defs/address")),
"additionalProperties", false);
assertTrue(validator
.validate(schema, Map.of("name", "alice", "address", Map.of("street", "1 Main", "city", "Springfield")))
.valid());
assertFalse(validator.validate(schema, Map.of("name", "alice", "extra", 1)).valid());
}
@Test
void validateSchemaAcceptsValidSchema() {
Map<String, Object> schema = Map.of("type", "object", "properties",
Map.of("name", Map.of("type", "string"), "age", Map.of("type", "integer")), "required",
List.of("name"));
assertTrue(validator.validateSchema(schema).valid());
}
@Test
void validateSchemaAcceptsValid2020_12SchemaWithExplicitDialect() {
Map<String, Object> schema = Map.of("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12, "type", "object",
"properties", Map.of("count", Map.of("type", "integer")));
assertTrue(validator.validateSchema(schema).valid());
}
@Test
void validateSchemaRejectsSchemaWithInvalidTypeValue() {
Map<String, Object> schema = Map.of("type", "not-a-valid-type");
assertFalse(validator.validateSchema(schema).valid());
}
@Test
void validateSchemaRejectsSchemaWithWrongTypeForRequired() {
Map<String, Object> schema = Map.of("type", "object", "required", "should-be-an-array");
assertFalse(validator.validateSchema(schema).valid());
}
@Test
void validateSchemaSkipsDraft07SchemasWithExplicitDialect() {
Map<String, Object> schema = Map.of("$schema", "http://json-schema.org/draft-07/schema#", "type", "object",
"properties", Map.of("a", Map.of("type", "string")));
assertTrue(validator.validateSchema(schema).valid());
}
@Test
void assertConformsDoesNothingOnNullSchema() {
validator.assertConforms("test context", null);
}
@Test
void assertConformsPassesForValidSchema() {
Map<String, Object> schema = Map.of("type", "object", "properties", Map.of("name", Map.of("type", "string")));
validator.assertConforms("Tool 'my-tool' inputSchema", schema);
}
@Test
void assertConformsThrowsForInvalidSchema() {
Map<String, Object> schema = Map.of("type", "not-a-valid-type");
assertThatThrownBy(() -> validator.assertConforms("Tool 'bad' inputSchema", schema))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Tool 'bad' inputSchema")
.hasMessageContaining("SEP-1613");
}
}

View file

@ -0,0 +1,97 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.json;
import java.util.Map;
import tools.jackson.databind.json.JsonMapper;
import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapper;
import io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator;
import io.modelcontextprotocol.server.McpAsyncServer;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.spec.McpServerTransportProvider;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
/**
* Integration tests for {@link McpAsyncServer#addTool} schema validation using the real
* {@link DefaultJsonSchemaValidator}.
*/
class McpServerAddToolSchemaValidationTests {
private McpServerTransportProvider transportProvider;
private JacksonMcpJsonMapper jsonMapper;
private DefaultJsonSchemaValidator validator;
@BeforeEach
void setUp() {
transportProvider = mock(McpServerTransportProvider.class);
jsonMapper = new JacksonMcpJsonMapper(JsonMapper.builder().build());
validator = new DefaultJsonSchemaValidator();
}
private McpAsyncServer buildServer() {
return McpServer.async(transportProvider)
.serverInfo("test", "1.0")
.jsonMapper(jsonMapper)
.capabilities(McpSchema.ServerCapabilities.builder().tools(false).build())
.jsonSchemaValidator(validator)
.build();
}
@Test
void addToolRejectsInvalidInputSchema() {
// "type" value must be one of the allowed JSON Schema type strings
Tool tool = Tool.builder("my-tool", Map.of("type", "not-a-valid-type")).build();
McpServerFeatures.AsyncToolSpecification spec = McpServerFeatures.AsyncToolSpecification.builder()
.tool(tool)
.callHandler((exchange, request) -> Mono.empty())
.build();
assertThatThrownBy(() -> buildServer().addTool(spec).block()).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("SEP-1613")
.hasMessageContaining("my-tool")
.hasMessageContaining("inputSchema");
}
@Test
void addToolRejectsInvalidOutputSchema() {
// "required" must be an array of strings, not a plain string
Tool tool = Tool.builder("output-tool", Map.of("type", "object"))
.outputSchema(Map.of("required", "not-an-array"))
.build();
McpServerFeatures.AsyncToolSpecification spec = McpServerFeatures.AsyncToolSpecification.builder()
.tool(tool)
.callHandler((exchange, request) -> Mono.empty())
.build();
assertThatThrownBy(() -> buildServer().addTool(spec).block()).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("SEP-1613")
.hasMessageContaining("output-tool")
.hasMessageContaining("outputSchema");
}
@Test
void addToolAcceptsValidSchemas() {
Tool tool = Tool.builder("valid-tool", Map.of("type", "object")).build();
McpServerFeatures.AsyncToolSpecification spec = McpServerFeatures.AsyncToolSpecification.builder()
.tool(tool)
.callHandler((exchange, request) -> Mono.empty())
.build();
assertThatCode(() -> buildServer().addTool(spec).block()).doesNotThrowAnyException();
}
}