Skip to content

Commit 87e2c7d

Browse files
authored
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>
1 parent 5d4a1cc commit 87e2c7d

20 files changed

Lines changed: 903 additions & 35 deletions

File tree

conformance-tests/VALIDATION_RESULTS.md

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,31 @@
22

33
## Summary
44

5-
**Server Tests:** 40/40 passed (100%)
5+
**Server Tests (active suite):** 44/44 passed (31 scenarios, 100%)
6+
**Server Tests (spec 2025-11-25):** 4/4 passed — SEP-1613 `json-schema-2020-12` scenario ✨
67
**Client Tests:** 3/4 scenarios passed (9/10 checks passed)
78
**Auth Tests:** 14/15 scenarios fully passing (196 passed, 0 failed, 1 warning, 93.3% scenarios, 99.5% checks)
89

910
## Server Test Results
1011

11-
### Passing (40/40)
12+
### Active Suite — Passing (31/31 scenarios, 44/44 checks)
1213

1314
- **Lifecycle & Utilities (4/4):** initialize, ping, logging-set-level, completion-complete
14-
- **Tools (11/11):** All scenarios including progress notifications ✨
15+
- **Tools (13/13):** All scenarios including progress notifications, sampling, elicitation
1516
- **Elicitation (10/10):** SEP-1034 defaults (5 checks), SEP-1330 enums (5 checks)
16-
- **Resources (6/6):** list, read-text, read-binary, templates-read, subscribe, unsubscribe
17-
- **Prompts (4/4):** list, simple, with-args, embedded-resource, with-image
17+
- **Resources (7/7):** list, read-text, read-binary, templates-read, subscribe, unsubscribe, SEP-2164 resource-not-found
18+
- **Prompts (5/5):** list, simple, with-args, embedded-resource, with-image
1819
- **SSE Transport (2/2):** Multiple streams
1920
- **Security (2/2):** Localhost validation passes, DNS rebinding protection
2021

22+
### Spec 2025-11-25 Scenarios — Passing (1/1 scenario, 4/4 checks)
23+
24+
- **JSON Schema 2020-12 (SEP-1613) (4/4):**
25+
- `json_schema_2020_12_tool` found
26+
- `inputSchema.$schema` field preserved
27+
- `inputSchema.$defs` field preserved
28+
- `inputSchema.additionalProperties` field preserved
29+
2130
## Client Test Results
2231

2332
### Passing (3/4 scenarios, 9/10 checks)
@@ -69,7 +78,7 @@ Uses the `client-spring-http-client` module with Spring Security OAuth2 and the
6978

7079
## Running Tests
7180

72-
### Server
81+
### Server (active suite)
7382
```bash
7483
# Start server
7584
./mvnw compile -pl conformance-tests/server-servlet -am exec:java
@@ -78,6 +87,17 @@ Uses the `client-spring-http-client` module with Spring Security OAuth2 and the
7887
npx @modelcontextprotocol/conformance server --url http://localhost:8080/mcp --suite active
7988
```
8089

90+
### Server (spec 2025-11-25 scenarios — SEP-1613)
91+
```bash
92+
# Start server (if not already running)
93+
./mvnw compile -pl conformance-tests/server-servlet -am exec:java
94+
95+
# Run json-schema-2020-12 scenario
96+
cd ../conformance && node --import tsx/esm src/index.ts server \
97+
--url http://localhost:8080/mcp \
98+
--scenario json-schema-2020-12
99+
```
100+
81101
### Client
82102
```bash
83103
# Build

conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import io.modelcontextprotocol.server.McpServerFeatures;
1010
import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator;
1111
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
12+
import io.modelcontextprotocol.spec.McpSchema;
1213
import io.modelcontextprotocol.spec.McpSchema.AudioContent;
1314
import io.modelcontextprotocol.spec.McpSchema.BlobResourceContents;
1415
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
@@ -402,6 +403,29 @@ private static List<McpServerFeatures.SyncToolSpecification> createToolSpecs() {
402403
})
403404
.build(),
404405

406+
// json_schema_2020_12_tool - SEP-1613 dialect/keyword preservation
407+
McpServerFeatures.SyncToolSpecification.builder()
408+
.tool(Tool
409+
.builder("json_schema_2020_12_tool", Map.of("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12,
410+
"type", "object", "$defs",
411+
Map.of("address",
412+
Map.of("type", "object", "properties",
413+
Map.of("street", Map.of("type", "string"), "city",
414+
Map.of("type", "string")))),
415+
"properties",
416+
Map.of("name", Map.of("type", "string"), "address", Map.of("$ref", "#/$defs/address")),
417+
"additionalProperties", false))
418+
.description("Tool with JSON Schema 2020-12 features (SEP-1613)")
419+
.build())
420+
.callHandler((exchange, request) -> {
421+
logger.info("Tool 'json_schema_2020_12_tool' called");
422+
return CallToolResult.builder()
423+
.content(List.of(TextContent.builder("ok").build()))
424+
.isError(false)
425+
.build();
426+
})
427+
.build(),
428+
405429
// test_elicitation_sep1330_enums - Tool with enum schema improvements
406430
McpServerFeatures.SyncToolSpecification.builder()
407431
.tool(Tool.builder("test_elicitation_sep1330_enums", EMPTY_JSON_SCHEMA)

mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,29 @@
1313
*/
1414
public interface JsonSchemaValidator {
1515

16+
/**
17+
* Asserts that the given schema document is a structurally valid JSON Schema. Schemas
18+
* without an explicit {@code $schema} declaration, or those that declare JSON Schema
19+
* 2020-12, are validated against the 2020-12 meta-schema. Schemas that explicitly
20+
* declare a different dialect are accepted without meta-schema validation. Throws
21+
* {@link IllegalArgumentException} if validation fails. Silently returns on null
22+
* schema. The default implementation delegates to {@link #validateSchema}.
23+
* @param context human-readable description of the schema's location (used in error
24+
* messages)
25+
* @param schema the schema document to validate, or {@code null} (no-op)
26+
* @throws IllegalArgumentException if the schema is structurally invalid
27+
*/
28+
default void assertConforms(String context, Map<String, Object> schema) {
29+
if (schema == null) {
30+
return;
31+
}
32+
var result = validateSchema(schema);
33+
if (!result.valid()) {
34+
throw new IllegalArgumentException(
35+
context + " is not a valid JSON Schema 2020-12 document (SEP-1613): " + result.errorMessage());
36+
}
37+
}
38+
1639
/**
1740
* Represents the result of a validation operation.
1841
*
@@ -41,4 +64,15 @@ public static ValidationResponse asInvalid(String message) {
4164
*/
4265
ValidationResponse validate(Map<String, Object> schema, Object structuredContent);
4366

67+
/**
68+
* Validates that the given schema document itself conforms to JSON Schema 2020-12
69+
* (SEP-1613). Schemas that declare an explicit non-2020-12 {@code $schema} dialect
70+
* are skipped and considered valid. The default implementation is a no-op.
71+
* @param schema the schema document to check
72+
* @return a ValidationResponse indicating conformance
73+
*/
74+
default ValidationResponse validateSchema(Map<String, Object> schema) {
75+
return ValidationResponse.asValid(null);
76+
}
77+
4478
}

mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,8 @@ public class McpAsyncServer {
156156
mcpTransportProvider.setSessionFactory(transport -> {
157157
String sessionId = UUID.randomUUID().toString();
158158
return new McpServerSession(sessionId, requestTimeout, transport, this::asyncInitializeRequestHandler,
159-
requestHandlers, notificationHandlers, () -> this.cleanupForSession(sessionId));
159+
requestHandlers, notificationHandlers, () -> this.cleanupForSession(sessionId),
160+
this.jsonSchemaValidator);
160161
});
161162
}
162163

@@ -183,9 +184,9 @@ public class McpAsyncServer {
183184

184185
this.protocolVersions = mcpTransportProvider.protocolVersions();
185186

186-
mcpTransportProvider.setSessionFactory(
187-
new DefaultMcpStreamableServerSessionFactory(requestTimeout, this::asyncInitializeRequestHandler,
188-
requestHandlers, notificationHandlers, sessionId -> this.cleanupForSession(sessionId)));
187+
mcpTransportProvider.setSessionFactory(new DefaultMcpStreamableServerSessionFactory(requestTimeout,
188+
this::asyncInitializeRequestHandler, requestHandlers, notificationHandlers,
189+
sessionId -> this.cleanupForSession(sessionId), this.jsonSchemaValidator));
189190
}
190191

191192
private Map<String, McpNotificationHandler> prepareNotificationHandlers(McpServerFeatures.Async features) {
@@ -347,6 +348,15 @@ public Mono<Void> addTool(McpServerFeatures.AsyncToolSpecification toolSpecifica
347348
return Mono.error(new IllegalStateException("Server must be configured with tool capabilities"));
348349
}
349350

351+
try {
352+
var t = toolSpecification.tool();
353+
this.jsonSchemaValidator.assertConforms("Tool '" + t.name() + "' inputSchema", t.inputSchema());
354+
this.jsonSchemaValidator.assertConforms("Tool '" + t.name() + "' outputSchema", t.outputSchema());
355+
}
356+
catch (IllegalArgumentException e) {
357+
return Mono.error(e);
358+
}
359+
350360
var wrappedToolSpecification = withStructuredOutputHandling(this.jsonSchemaValidator, toolSpecification);
351361

352362
return Mono.defer(() -> {

mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import java.util.Collections;
1010

1111
import io.modelcontextprotocol.json.TypeRef;
12+
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
1213
import io.modelcontextprotocol.spec.McpError;
1314
import io.modelcontextprotocol.spec.McpLoggableSession;
1415
import io.modelcontextprotocol.spec.McpSchema;
@@ -37,6 +38,8 @@ public class McpAsyncServerExchange {
3738

3839
private final McpTransportContext transportContext;
3940

41+
private final JsonSchemaValidator jsonSchemaValidator;
42+
4043
private static final TypeRef<McpSchema.CreateMessageResult> CREATE_MESSAGE_RESULT_TYPE_REF = new TypeRef<>() {
4144
};
4245

@@ -51,21 +54,40 @@ public class McpAsyncServerExchange {
5154

5255
/**
5356
* Create a new asynchronous exchange with the client.
57+
* @param sessionId the session ID
5458
* @param session The server session representing a 1-1 interaction.
5559
* @param clientCapabilities The client capabilities that define the supported
5660
* features and functionality.
5761
* @param clientInfo The client implementation information.
5862
* @param transportContext context associated with the client as extracted from the
5963
* transport
64+
* @param jsonSchemaValidator optional validator used to verify elicitation schemas
6065
*/
6166
public McpAsyncServerExchange(String sessionId, McpLoggableSession session,
6267
McpSchema.ClientCapabilities clientCapabilities, McpSchema.Implementation clientInfo,
63-
McpTransportContext transportContext) {
68+
McpTransportContext transportContext, JsonSchemaValidator jsonSchemaValidator) {
6469
this.sessionId = sessionId;
6570
this.session = session;
6671
this.clientCapabilities = clientCapabilities;
6772
this.clientInfo = clientInfo;
6873
this.transportContext = transportContext;
74+
this.jsonSchemaValidator = jsonSchemaValidator;
75+
}
76+
77+
/**
78+
* Create a new asynchronous exchange with the client.
79+
* @param sessionId the session ID
80+
* @param session The server session representing a 1-1 interaction.
81+
* @param clientCapabilities The client capabilities that define the supported
82+
* features and functionality.
83+
* @param clientInfo The client implementation information.
84+
* @param transportContext context associated with the client as extracted from the
85+
* transport
86+
*/
87+
public McpAsyncServerExchange(String sessionId, McpLoggableSession session,
88+
McpSchema.ClientCapabilities clientCapabilities, McpSchema.Implementation clientInfo,
89+
McpTransportContext transportContext) {
90+
this(sessionId, session, clientCapabilities, clientInfo, transportContext, null);
6991
}
7092

7193
/**
@@ -152,6 +174,15 @@ public Mono<McpSchema.ElicitResult> createElicitation(McpSchema.ElicitRequest el
152174
if (this.clientCapabilities.elicitation() == null) {
153175
return Mono.error(new IllegalStateException("Client must be configured with elicitation capabilities"));
154176
}
177+
if (this.jsonSchemaValidator != null) {
178+
try {
179+
this.jsonSchemaValidator.assertConforms("ElicitRequest requestedSchema",
180+
elicitRequest.requestedSchema());
181+
}
182+
catch (IllegalArgumentException e) {
183+
return Mono.error(e);
184+
}
185+
}
155186
return this.session.sendRequest(McpSchema.METHOD_ELICITATION_CREATE, elicitRequest,
156187
ELICITATION_RESULT_TYPE_REF);
157188
}

mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,8 @@ public McpAsyncServer build() {
243243
var jsonSchemaValidator = (this.jsonSchemaValidator != null) ? this.jsonSchemaValidator
244244
: McpJsonDefaults.getSchemaValidator();
245245

246+
validateAsyncToolSchemas(jsonSchemaValidator, this.tools);
247+
246248
return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
247249
features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs);
248250
}
@@ -269,6 +271,9 @@ public McpAsyncServer build() {
269271
this.instructions);
270272
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
271273
: McpJsonDefaults.getSchemaValidator();
274+
275+
validateAsyncToolSchemas(jsonSchemaValidator, this.tools);
276+
272277
return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
273278
features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs);
274279
}
@@ -829,11 +834,14 @@ public McpSyncServer build() {
829834
McpServerFeatures.Async asyncFeatures = McpServerFeatures.Async.fromSync(syncFeatures,
830835
this.immediateExecution);
831836

837+
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
838+
: McpJsonDefaults.getSchemaValidator();
839+
840+
validateSyncToolSchemas(jsonSchemaValidator, this.tools);
841+
832842
var asyncServer = new McpAsyncServer(transportProvider,
833843
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, requestTimeout,
834-
uriTemplateManagerFactory,
835-
jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(),
836-
validateToolInputs);
844+
uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs);
837845
return new McpSyncServer(asyncServer, this.immediateExecution);
838846
}
839847

@@ -862,6 +870,9 @@ public McpSyncServer build() {
862870
this.immediateExecution);
863871
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
864872
: McpJsonDefaults.getSchemaValidator();
873+
874+
validateSyncToolSchemas(jsonSchemaValidator, this.tools);
875+
865876
var asyncServer = new McpAsyncServer(transportProvider,
866877
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, this.requestTimeout,
867878
this.uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs);
@@ -1898,10 +1909,13 @@ public StatelessAsyncSpecification jsonSchemaValidator(JsonSchemaValidator jsonS
18981909
public McpStatelessAsyncServer build() {
18991910
var features = new McpStatelessServerFeatures.Async(this.serverInfo, this.serverCapabilities, this.tools,
19001911
this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions);
1912+
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
1913+
: McpJsonDefaults.getSchemaValidator();
1914+
1915+
validateStatelessAsyncToolSchemas(jsonSchemaValidator, this.tools);
1916+
19011917
return new McpStatelessAsyncServer(transport, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
1902-
features, requestTimeout, uriTemplateManagerFactory,
1903-
jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(),
1904-
validateToolInputs);
1918+
features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs);
19051919
}
19061920

19071921
}
@@ -2412,14 +2426,42 @@ public McpStatelessSyncServer build() {
24122426
var syncFeatures = new McpStatelessServerFeatures.Sync(this.serverInfo, this.serverCapabilities, this.tools,
24132427
this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions);
24142428
var asyncFeatures = McpStatelessServerFeatures.Async.fromSync(syncFeatures, this.immediateExecution);
2429+
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
2430+
: McpJsonDefaults.getSchemaValidator();
2431+
2432+
validateStatelessSyncToolSchemas(jsonSchemaValidator, this.tools);
2433+
24152434
var asyncServer = new McpStatelessAsyncServer(transport,
24162435
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, requestTimeout,
2417-
uriTemplateManagerFactory,
2418-
this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(),
2419-
validateToolInputs);
2436+
uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs);
24202437
return new McpStatelessSyncServer(asyncServer, this.immediateExecution);
24212438
}
24222439

24232440
}
24242441

2442+
private static void validateAsyncToolSchemas(JsonSchemaValidator validator,
2443+
List<McpServerFeatures.AsyncToolSpecification> tools) {
2444+
tools.forEach(spec -> validateToolSchema(validator, spec.tool()));
2445+
}
2446+
2447+
private static void validateSyncToolSchemas(JsonSchemaValidator validator,
2448+
List<McpServerFeatures.SyncToolSpecification> tools) {
2449+
tools.forEach(spec -> validateToolSchema(validator, spec.tool()));
2450+
}
2451+
2452+
private static void validateStatelessAsyncToolSchemas(JsonSchemaValidator validator,
2453+
List<McpStatelessServerFeatures.AsyncToolSpecification> tools) {
2454+
tools.forEach(spec -> validateToolSchema(validator, spec.tool()));
2455+
}
2456+
2457+
private static void validateStatelessSyncToolSchemas(JsonSchemaValidator validator,
2458+
List<McpStatelessServerFeatures.SyncToolSpecification> tools) {
2459+
tools.forEach(spec -> validateToolSchema(validator, spec.tool()));
2460+
}
2461+
2462+
private static void validateToolSchema(JsonSchemaValidator validator, McpSchema.Tool tool) {
2463+
validator.assertConforms("Tool '" + tool.name() + "' inputSchema", tool.inputSchema());
2464+
validator.assertConforms("Tool '" + tool.name() + "' outputSchema", tool.outputSchema());
2465+
}
2466+
24252467
}

mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,15 @@ public Mono<Void> addTool(McpStatelessServerFeatures.AsyncToolSpecification tool
339339
return Mono.error(new IllegalStateException("Server must be configured with tool capabilities"));
340340
}
341341

342+
try {
343+
var t = toolSpecification.tool();
344+
this.jsonSchemaValidator.assertConforms("Tool '" + t.name() + "' inputSchema", t.inputSchema());
345+
this.jsonSchemaValidator.assertConforms("Tool '" + t.name() + "' outputSchema", t.outputSchema());
346+
}
347+
catch (IllegalArgumentException e) {
348+
return Mono.error(e);
349+
}
350+
342351
var wrappedToolSpecification = withStructuredOutputHandling(this.jsonSchemaValidator, toolSpecification);
343352

344353
return Mono.defer(() -> {

0 commit comments

Comments
 (0)