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
20 changed files with 903 additions and 35 deletions

View file

@ -2,22 +2,31 @@
## Summary
**Server Tests:** 40/40 passed (100%)
**Server Tests (active suite):** 44/44 passed (31 scenarios, 100%)
**Server Tests (spec 2025-11-25):** 4/4 passed — SEP-1613 `json-schema-2020-12` scenario ✨
**Client Tests:** 3/4 scenarios passed (9/10 checks passed)
**Auth Tests:** 14/15 scenarios fully passing (196 passed, 0 failed, 1 warning, 93.3% scenarios, 99.5% checks)
## Server Test Results
### Passing (40/40)
### Active Suite — Passing (31/31 scenarios, 44/44 checks)
- **Lifecycle & Utilities (4/4):** initialize, ping, logging-set-level, completion-complete
- **Tools (11/11):** All scenarios including progress notifications ✨
- **Tools (13/13):** All scenarios including progress notifications, sampling, elicitation ✨
- **Elicitation (10/10):** SEP-1034 defaults (5 checks), SEP-1330 enums (5 checks)
- **Resources (6/6):** list, read-text, read-binary, templates-read, subscribe, unsubscribe
- **Prompts (4/4):** list, simple, with-args, embedded-resource, with-image
- **Resources (7/7):** list, read-text, read-binary, templates-read, subscribe, unsubscribe, SEP-2164 resource-not-found
- **Prompts (5/5):** list, simple, with-args, embedded-resource, with-image
- **SSE Transport (2/2):** Multiple streams
- **Security (2/2):** Localhost validation passes, DNS rebinding protection
### Spec 2025-11-25 Scenarios — Passing (1/1 scenario, 4/4 checks)
- **JSON Schema 2020-12 (SEP-1613) (4/4):** ✨
- `json_schema_2020_12_tool` found
- `inputSchema.$schema` field preserved
- `inputSchema.$defs` field preserved
- `inputSchema.additionalProperties` field preserved
## Client Test Results
### Passing (3/4 scenarios, 9/10 checks)
@ -69,7 +78,7 @@ Uses the `client-spring-http-client` module with Spring Security OAuth2 and the
## Running Tests
### Server
### Server (active suite)
```bash
# Start server
./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
npx @modelcontextprotocol/conformance server --url http://localhost:8080/mcp --suite active
```
### Server (spec 2025-11-25 scenarios — SEP-1613)
```bash
# Start server (if not already running)
./mvnw compile -pl conformance-tests/server-servlet -am exec:java
# Run json-schema-2020-12 scenario
cd ../conformance && node --import tsx/esm src/index.ts server \
--url http://localhost:8080/mcp \
--scenario json-schema-2020-12
```
### Client
```bash
# Build

View file

@ -9,6 +9,7 @@ import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.AudioContent;
import io.modelcontextprotocol.spec.McpSchema.BlobResourceContents;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
@ -402,6 +403,29 @@ public class ConformanceServlet {
})
.build(),
// json_schema_2020_12_tool - SEP-1613 dialect/keyword preservation
McpServerFeatures.SyncToolSpecification.builder()
.tool(Tool
.builder("json_schema_2020_12_tool", 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))
.description("Tool with JSON Schema 2020-12 features (SEP-1613)")
.build())
.callHandler((exchange, request) -> {
logger.info("Tool 'json_schema_2020_12_tool' called");
return CallToolResult.builder()
.content(List.of(TextContent.builder("ok").build()))
.isError(false)
.build();
})
.build(),
// test_elicitation_sep1330_enums - Tool with enum schema improvements
McpServerFeatures.SyncToolSpecification.builder()
.tool(Tool.builder("test_elicitation_sep1330_enums", EMPTY_JSON_SCHEMA)

View file

@ -13,6 +13,29 @@ import java.util.Map;
*/
public interface JsonSchemaValidator {
/**
* Asserts that the given schema document is a structurally valid JSON Schema. Schemas
* without an explicit {@code $schema} declaration, or those that declare JSON Schema
* 2020-12, are validated against the 2020-12 meta-schema. Schemas that explicitly
* declare a different dialect are accepted without meta-schema validation. Throws
* {@link IllegalArgumentException} if validation fails. Silently returns on null
* schema. The default implementation delegates to {@link #validateSchema}.
* @param context human-readable description of the schema's location (used in error
* messages)
* @param schema the schema document to validate, or {@code null} (no-op)
* @throws IllegalArgumentException if the schema is structurally invalid
*/
default void assertConforms(String context, Map<String, Object> schema) {
if (schema == null) {
return;
}
var result = validateSchema(schema);
if (!result.valid()) {
throw new IllegalArgumentException(
context + " is not a valid JSON Schema 2020-12 document (SEP-1613): " + result.errorMessage());
}
}
/**
* Represents the result of a validation operation.
*
@ -41,4 +64,15 @@ public interface JsonSchemaValidator {
*/
ValidationResponse validate(Map<String, Object> schema, Object structuredContent);
/**
* Validates that the given schema document itself conforms to JSON Schema 2020-12
* (SEP-1613). Schemas that declare an explicit non-2020-12 {@code $schema} dialect
* are skipped and considered valid. The default implementation is a no-op.
* @param schema the schema document to check
* @return a ValidationResponse indicating conformance
*/
default ValidationResponse validateSchema(Map<String, Object> schema) {
return ValidationResponse.asValid(null);
}
}

View file

@ -156,7 +156,8 @@ public class McpAsyncServer {
mcpTransportProvider.setSessionFactory(transport -> {
String sessionId = UUID.randomUUID().toString();
return new McpServerSession(sessionId, requestTimeout, transport, this::asyncInitializeRequestHandler,
requestHandlers, notificationHandlers, () -> this.cleanupForSession(sessionId));
requestHandlers, notificationHandlers, () -> this.cleanupForSession(sessionId),
this.jsonSchemaValidator);
});
}
@ -183,9 +184,9 @@ public class McpAsyncServer {
this.protocolVersions = mcpTransportProvider.protocolVersions();
mcpTransportProvider.setSessionFactory(
new DefaultMcpStreamableServerSessionFactory(requestTimeout, this::asyncInitializeRequestHandler,
requestHandlers, notificationHandlers, sessionId -> this.cleanupForSession(sessionId)));
mcpTransportProvider.setSessionFactory(new DefaultMcpStreamableServerSessionFactory(requestTimeout,
this::asyncInitializeRequestHandler, requestHandlers, notificationHandlers,
sessionId -> this.cleanupForSession(sessionId), this.jsonSchemaValidator));
}
private Map<String, McpNotificationHandler> prepareNotificationHandlers(McpServerFeatures.Async features) {
@ -347,6 +348,15 @@ public class McpAsyncServer {
return Mono.error(new IllegalStateException("Server must be configured with tool capabilities"));
}
try {
var t = toolSpecification.tool();
this.jsonSchemaValidator.assertConforms("Tool '" + t.name() + "' inputSchema", t.inputSchema());
this.jsonSchemaValidator.assertConforms("Tool '" + t.name() + "' outputSchema", t.outputSchema());
}
catch (IllegalArgumentException e) {
return Mono.error(e);
}
var wrappedToolSpecification = withStructuredOutputHandling(this.jsonSchemaValidator, toolSpecification);
return Mono.defer(() -> {

View file

@ -9,6 +9,7 @@ import java.util.ArrayList;
import java.util.Collections;
import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpLoggableSession;
import io.modelcontextprotocol.spec.McpSchema;
@ -37,6 +38,8 @@ public class McpAsyncServerExchange {
private final McpTransportContext transportContext;
private final JsonSchemaValidator jsonSchemaValidator;
private static final TypeRef<McpSchema.CreateMessageResult> CREATE_MESSAGE_RESULT_TYPE_REF = new TypeRef<>() {
};
@ -51,6 +54,29 @@ public class McpAsyncServerExchange {
/**
* Create a new asynchronous exchange with the client.
* @param sessionId the session ID
* @param session The server session representing a 1-1 interaction.
* @param clientCapabilities The client capabilities that define the supported
* features and functionality.
* @param clientInfo The client implementation information.
* @param transportContext context associated with the client as extracted from the
* transport
* @param jsonSchemaValidator optional validator used to verify elicitation schemas
*/
public McpAsyncServerExchange(String sessionId, McpLoggableSession session,
McpSchema.ClientCapabilities clientCapabilities, McpSchema.Implementation clientInfo,
McpTransportContext transportContext, JsonSchemaValidator jsonSchemaValidator) {
this.sessionId = sessionId;
this.session = session;
this.clientCapabilities = clientCapabilities;
this.clientInfo = clientInfo;
this.transportContext = transportContext;
this.jsonSchemaValidator = jsonSchemaValidator;
}
/**
* Create a new asynchronous exchange with the client.
* @param sessionId the session ID
* @param session The server session representing a 1-1 interaction.
* @param clientCapabilities The client capabilities that define the supported
* features and functionality.
@ -61,11 +87,7 @@ public class McpAsyncServerExchange {
public McpAsyncServerExchange(String sessionId, McpLoggableSession session,
McpSchema.ClientCapabilities clientCapabilities, McpSchema.Implementation clientInfo,
McpTransportContext transportContext) {
this.sessionId = sessionId;
this.session = session;
this.clientCapabilities = clientCapabilities;
this.clientInfo = clientInfo;
this.transportContext = transportContext;
this(sessionId, session, clientCapabilities, clientInfo, transportContext, null);
}
/**
@ -152,6 +174,15 @@ public class McpAsyncServerExchange {
if (this.clientCapabilities.elicitation() == null) {
return Mono.error(new IllegalStateException("Client must be configured with elicitation capabilities"));
}
if (this.jsonSchemaValidator != null) {
try {
this.jsonSchemaValidator.assertConforms("ElicitRequest requestedSchema",
elicitRequest.requestedSchema());
}
catch (IllegalArgumentException e) {
return Mono.error(e);
}
}
return this.session.sendRequest(McpSchema.METHOD_ELICITATION_CREATE, elicitRequest,
ELICITATION_RESULT_TYPE_REF);
}

View file

@ -243,6 +243,8 @@ public interface McpServer {
var jsonSchemaValidator = (this.jsonSchemaValidator != null) ? this.jsonSchemaValidator
: McpJsonDefaults.getSchemaValidator();
validateAsyncToolSchemas(jsonSchemaValidator, this.tools);
return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs);
}
@ -269,6 +271,9 @@ public interface McpServer {
this.instructions);
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
: McpJsonDefaults.getSchemaValidator();
validateAsyncToolSchemas(jsonSchemaValidator, this.tools);
return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs);
}
@ -829,11 +834,14 @@ public interface McpServer {
McpServerFeatures.Async asyncFeatures = McpServerFeatures.Async.fromSync(syncFeatures,
this.immediateExecution);
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
: McpJsonDefaults.getSchemaValidator();
validateSyncToolSchemas(jsonSchemaValidator, this.tools);
var asyncServer = new McpAsyncServer(transportProvider,
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, requestTimeout,
uriTemplateManagerFactory,
jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(),
validateToolInputs);
uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs);
return new McpSyncServer(asyncServer, this.immediateExecution);
}
@ -862,6 +870,9 @@ public interface McpServer {
this.immediateExecution);
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
: McpJsonDefaults.getSchemaValidator();
validateSyncToolSchemas(jsonSchemaValidator, this.tools);
var asyncServer = new McpAsyncServer(transportProvider,
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, this.requestTimeout,
this.uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs);
@ -1898,10 +1909,13 @@ public interface McpServer {
public McpStatelessAsyncServer build() {
var features = new McpStatelessServerFeatures.Async(this.serverInfo, this.serverCapabilities, this.tools,
this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions);
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
: McpJsonDefaults.getSchemaValidator();
validateStatelessAsyncToolSchemas(jsonSchemaValidator, this.tools);
return new McpStatelessAsyncServer(transport, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
features, requestTimeout, uriTemplateManagerFactory,
jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(),
validateToolInputs);
features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs);
}
}
@ -2412,14 +2426,42 @@ public interface McpServer {
var syncFeatures = new McpStatelessServerFeatures.Sync(this.serverInfo, this.serverCapabilities, this.tools,
this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions);
var asyncFeatures = McpStatelessServerFeatures.Async.fromSync(syncFeatures, this.immediateExecution);
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
: McpJsonDefaults.getSchemaValidator();
validateStatelessSyncToolSchemas(jsonSchemaValidator, this.tools);
var asyncServer = new McpStatelessAsyncServer(transport,
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, requestTimeout,
uriTemplateManagerFactory,
this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(),
validateToolInputs);
uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs);
return new McpStatelessSyncServer(asyncServer, this.immediateExecution);
}
}
private static void validateAsyncToolSchemas(JsonSchemaValidator validator,
List<McpServerFeatures.AsyncToolSpecification> tools) {
tools.forEach(spec -> validateToolSchema(validator, spec.tool()));
}
private static void validateSyncToolSchemas(JsonSchemaValidator validator,
List<McpServerFeatures.SyncToolSpecification> tools) {
tools.forEach(spec -> validateToolSchema(validator, spec.tool()));
}
private static void validateStatelessAsyncToolSchemas(JsonSchemaValidator validator,
List<McpStatelessServerFeatures.AsyncToolSpecification> tools) {
tools.forEach(spec -> validateToolSchema(validator, spec.tool()));
}
private static void validateStatelessSyncToolSchemas(JsonSchemaValidator validator,
List<McpStatelessServerFeatures.SyncToolSpecification> tools) {
tools.forEach(spec -> validateToolSchema(validator, spec.tool()));
}
private static void validateToolSchema(JsonSchemaValidator validator, McpSchema.Tool tool) {
validator.assertConforms("Tool '" + tool.name() + "' inputSchema", tool.inputSchema());
validator.assertConforms("Tool '" + tool.name() + "' outputSchema", tool.outputSchema());
}
}

View file

@ -339,6 +339,15 @@ public class McpStatelessAsyncServer {
return Mono.error(new IllegalStateException("Server must be configured with tool capabilities"));
}
try {
var t = toolSpecification.tool();
this.jsonSchemaValidator.assertConforms("Tool '" + t.name() + "' inputSchema", t.inputSchema());
this.jsonSchemaValidator.assertConforms("Tool '" + t.name() + "' outputSchema", t.outputSchema());
}
catch (IllegalArgumentException e) {
return Mono.error(e);
}
var wrappedToolSpecification = withStructuredOutputHandling(this.jsonSchemaValidator, toolSpecification);
return Mono.defer(() -> {

View file

@ -4,6 +4,7 @@
package io.modelcontextprotocol.spec;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.server.McpNotificationHandler;
import io.modelcontextprotocol.server.McpRequestHandler;
@ -31,6 +32,31 @@ public class DefaultMcpStreamableServerSessionFactory implements McpStreamableSe
private final Function<String, Mono<Void>> onClose;
private final JsonSchemaValidator jsonSchemaValidator;
/**
* Constructs an instance.
* @param requestTimeout timeout for requests
* @param initRequestHandler initialization request handler
* @param requestHandlers map of MCP request handlers keyed by method name
* @param notificationHandlers map of MCP notification handlers keyed by method name
* @param onClose reactive callback invoked with the session ID when a session is
* closed
* @param jsonSchemaValidator optional validator threaded to sessions user-provided
* schema validation
*/
public DefaultMcpStreamableServerSessionFactory(Duration requestTimeout,
McpStreamableServerSession.InitRequestHandler initRequestHandler,
Map<String, McpRequestHandler<?>> requestHandlers, Map<String, McpNotificationHandler> notificationHandlers,
Function<String, Mono<Void>> onClose, JsonSchemaValidator jsonSchemaValidator) {
this.requestTimeout = requestTimeout;
this.initRequestHandler = initRequestHandler;
this.requestHandlers = requestHandlers;
this.notificationHandlers = notificationHandlers;
this.onClose = onClose;
this.jsonSchemaValidator = jsonSchemaValidator;
}
/**
* Constructs an instance.
* @param requestTimeout timeout for requests
@ -44,11 +70,7 @@ public class DefaultMcpStreamableServerSessionFactory implements McpStreamableSe
McpStreamableServerSession.InitRequestHandler initRequestHandler,
Map<String, McpRequestHandler<?>> requestHandlers, Map<String, McpNotificationHandler> notificationHandlers,
Function<String, Mono<Void>> onClose) {
this.requestTimeout = requestTimeout;
this.initRequestHandler = initRequestHandler;
this.requestHandlers = requestHandlers;
this.notificationHandlers = notificationHandlers;
this.onClose = onClose;
this(requestTimeout, initRequestHandler, requestHandlers, notificationHandlers, onClose, null);
}
/**
@ -73,9 +95,10 @@ public class DefaultMcpStreamableServerSessionFactory implements McpStreamableSe
public McpStreamableServerSession.McpStreamableServerSessionInit startSession(
McpSchema.InitializeRequest initializeRequest) {
String sessionId = UUID.randomUUID().toString();
return new McpStreamableServerSession.McpStreamableServerSessionInit(new McpStreamableServerSession(sessionId,
initializeRequest.capabilities(), initializeRequest.clientInfo(), requestTimeout, requestHandlers,
notificationHandlers, () -> this.onClose.apply(sessionId)),
return new McpStreamableServerSession.McpStreamableServerSessionInit(
new McpStreamableServerSession(sessionId, initializeRequest.capabilities(),
initializeRequest.clientInfo(), requestTimeout, requestHandlers, notificationHandlers,
() -> this.onClose.apply(sessionId), this.jsonSchemaValidator),
this.initRequestHandler.handle(initializeRequest));
}

View file

@ -11,7 +11,9 @@ import java.util.Map;
* defines a method to validate structured content based on the provided output schema.
*
* @author Christian Tzolov
* @deprecated Use {@link io.modelcontextprotocol.json.schema.JsonSchemaValidator}
*/
@Deprecated
public interface JsonSchemaValidator {
/**

View file

@ -46,6 +46,12 @@ public final class McpSchema {
public static final String FIRST_PAGE = null;
/**
* The JSON Schema 2020-12 meta-schema URI (SEP-1613). This is the default dialect for
* all schema objects in MCP when no explicit {@code $schema} field is present.
*/
public static final String JSON_SCHEMA_DIALECT_2020_12 = "https://json-schema.org/draft/2020-12/schema";
// ---------------------------
// Method Names
// ---------------------------
@ -2666,9 +2672,14 @@ public final class McpSchema {
* @param description A human-readable description of what the tool does. This can be
* used by clients to improve the LLM's understanding of available tools.
* @param inputSchema A JSON Schema object that describes the expected structure of
* the arguments when calling this tool. This allows clients to validate tool
* the arguments when calling this tool. Per SEP-1613, the dialect defaults to JSON
* Schema 2020-12 ({@link #JSON_SCHEMA_DIALECT_2020_12}) when no explicit
* {@code $schema} entry is present. To declare a different dialect, include a
* {@code "$schema"} key in the map. For tools with no parameters the spec recommends
* {@code {"type":"object","additionalProperties":false}}.
* @param outputSchema An optional JSON Schema object defining the structure of the
* tool's output returned in the structuredContent field of a CallToolResult.
* tool's output returned in the structuredContent field of a CallToolResult. Same
* dialect rules as {@code inputSchema}.
* @param annotations Optional additional tool information.
* @param meta See specification for notes on _meta usage
*/
@ -3692,7 +3703,10 @@ public final class McpSchema {
*
* @param message The message to present to the user
* @param requestedSchema A restricted subset of JSON Schema. Only top-level
* properties are allowed, without nesting
* properties are allowed, without nesting. Per SEP-1613, the dialect defaults to JSON
* Schema 2020-12 ({@link #JSON_SCHEMA_DIALECT_2020_12}) when no explicit
* {@code $schema} entry is present. To declare a different dialect, include a
* {@code "$schema"} key in the map.
* @param meta See specification for notes on _meta usage
* <p>
* Note: {@code message} and {@code requestedSchema} are required by the MCP

View file

@ -13,6 +13,7 @@ import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.server.McpAsyncServerExchange;
import io.modelcontextprotocol.server.McpInitRequestHandler;
import io.modelcontextprotocol.server.McpNotificationHandler;
@ -68,6 +69,36 @@ public class McpServerSession implements McpLoggableSession {
private final Supplier<Mono<Void>> onClose;
private final JsonSchemaValidator jsonSchemaValidator;
/**
* Creates a new server session with the given parameters and the transport to use.
* @param id session id
* @param requestTimeout duration to wait for request responses before timing out
* @param transport the transport to use
* @param initHandler called when a
* {@link io.modelcontextprotocol.spec.McpSchema.InitializeRequest} is received by the
* server
* @param requestHandlers map of request handlers to use
* @param notificationHandlers map of notification handlers to use
* @param onClose supplier of a reactive callback invoked when the session is closed
* @param jsonSchemaValidator optional validator threaded to exchanges for elicitation
* schema validation
*/
public McpServerSession(String id, Duration requestTimeout, McpServerTransport transport,
McpInitRequestHandler initHandler, Map<String, McpRequestHandler<?>> requestHandlers,
Map<String, McpNotificationHandler> notificationHandlers, Supplier<Mono<Void>> onClose,
JsonSchemaValidator jsonSchemaValidator) {
this.id = id;
this.requestTimeout = requestTimeout;
this.transport = transport;
this.initRequestHandler = initHandler;
this.requestHandlers = requestHandlers;
this.notificationHandlers = notificationHandlers;
this.onClose = onClose;
this.jsonSchemaValidator = jsonSchemaValidator;
}
/**
* Creates a new server session with the given parameters and the transport to use.
* @param id session id
@ -83,13 +114,7 @@ public class McpServerSession implements McpLoggableSession {
public McpServerSession(String id, Duration requestTimeout, McpServerTransport transport,
McpInitRequestHandler initHandler, Map<String, McpRequestHandler<?>> requestHandlers,
Map<String, McpNotificationHandler> notificationHandlers, Supplier<Mono<Void>> onClose) {
this.id = id;
this.requestTimeout = requestTimeout;
this.transport = transport;
this.initRequestHandler = initHandler;
this.requestHandlers = requestHandlers;
this.notificationHandlers = notificationHandlers;
this.onClose = onClose;
this(id, requestTimeout, transport, initHandler, requestHandlers, notificationHandlers, onClose, null);
}
/**
@ -300,7 +325,7 @@ public class McpServerSession implements McpLoggableSession {
// FIXME: The session ID passed here is not the same as the one in the
// legacy SSE transport.
exchangeSink.tryEmitValue(new McpAsyncServerExchange(this.id, this, clientCapabilities.get(),
clientInfo.get(), transportContext));
clientInfo.get(), transportContext, this.jsonSchemaValidator));
}
var handler = notificationHandlers.get(notification.method());
@ -322,7 +347,7 @@ public class McpServerSession implements McpLoggableSession {
*/
private McpAsyncServerExchange copyExchange(McpAsyncServerExchange exchange, McpTransportContext transportContext) {
return new McpAsyncServerExchange(exchange.sessionId(), this, exchange.getClientCapabilities(),
exchange.getClientInfo(), transportContext);
exchange.getClientInfo(), transportContext, this.jsonSchemaValidator);
}
record MethodNotFoundError(String method, String message, Object data) {

View file

@ -18,6 +18,7 @@ import org.slf4j.LoggerFactory;
import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.server.McpAsyncServerExchange;
import io.modelcontextprotocol.server.McpNotificationHandler;
import io.modelcontextprotocol.server.McpRequestHandler;
@ -64,6 +65,37 @@ public class McpStreamableServerSession implements McpLoggableSession {
private final Supplier<Mono<Void>> onClose;
private final JsonSchemaValidator jsonSchemaValidator;
/**
* Create an instance of the streamable session.
* @param id session ID
* @param clientCapabilities client capabilities
* @param clientInfo client info
* @param requestTimeout timeout to use for requests
* @param requestHandlers the map of MCP request handlers keyed by method name
* @param notificationHandlers the map of MCP notification handlers keyed by method
* name
* @param onClose supplier of a reactive callback invoked when the session is closed
* @param jsonSchemaValidator optional validator threaded to exchanges for elicitation
* schema validation
*/
public McpStreamableServerSession(String id, McpSchema.ClientCapabilities clientCapabilities,
McpSchema.Implementation clientInfo, Duration requestTimeout,
Map<String, McpRequestHandler<?>> requestHandlers, Map<String, McpNotificationHandler> notificationHandlers,
Supplier<Mono<Void>> onClose, JsonSchemaValidator jsonSchemaValidator) {
this.id = id;
this.missingMcpTransportSession = new MissingMcpTransportSession(id);
this.listeningStreamRef = new AtomicReference<>(this.missingMcpTransportSession);
this.clientCapabilities.lazySet(clientCapabilities);
this.clientInfo.lazySet(clientInfo);
this.requestTimeout = requestTimeout;
this.requestHandlers = requestHandlers;
this.notificationHandlers = notificationHandlers;
this.onClose = onClose;
this.jsonSchemaValidator = jsonSchemaValidator;
}
/**
* Create an instance of the streamable session.
* @param id session ID
@ -79,15 +111,7 @@ public class McpStreamableServerSession implements McpLoggableSession {
McpSchema.Implementation clientInfo, Duration requestTimeout,
Map<String, McpRequestHandler<?>> requestHandlers, Map<String, McpNotificationHandler> notificationHandlers,
Supplier<Mono<Void>> onClose) {
this.id = id;
this.missingMcpTransportSession = new MissingMcpTransportSession(id);
this.listeningStreamRef = new AtomicReference<>(this.missingMcpTransportSession);
this.clientCapabilities.lazySet(clientCapabilities);
this.clientInfo.lazySet(clientInfo);
this.requestTimeout = requestTimeout;
this.requestHandlers = requestHandlers;
this.notificationHandlers = notificationHandlers;
this.onClose = onClose;
this(id, clientCapabilities, clientInfo, requestTimeout, requestHandlers, notificationHandlers, onClose, null);
}
/**
@ -196,7 +220,7 @@ public class McpStreamableServerSession implements McpLoggableSession {
}
return requestHandler
.handle(new McpAsyncServerExchange(this.id, stream, clientCapabilities.get(), clientInfo.get(),
transportContext), jsonrpcRequest.params())
transportContext, this.jsonSchemaValidator), jsonrpcRequest.params())
.map(result -> McpSchema.JSONRPCResponse.result(jsonrpcRequest.id(), result))
.onErrorResume(e -> {
McpSchema.JSONRPCResponse.JSONRPCError jsonRpcError = (e instanceof McpError mcpError
@ -227,7 +251,8 @@ public class McpStreamableServerSession implements McpLoggableSession {
}
McpLoggableSession listeningStream = this.listeningStreamRef.get();
return notificationHandler.handle(new McpAsyncServerExchange(this.id, listeningStream,
this.clientCapabilities.get(), this.clientInfo.get(), transportContext), notification.params());
this.clientCapabilities.get(), this.clientInfo.get(), transportContext, this.jsonSchemaValidator),
notification.params());
});
}

View file

@ -10,6 +10,7 @@ import java.util.Arrays;
import java.util.List;
import java.util.Map;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpServerSession;
@ -461,6 +462,75 @@ class McpAsyncServerExchangeTests {
});
}
@Test
void testCreateElicitationWithInvalidRequestedSchema() {
McpSchema.ClientCapabilities capabilitiesWithElicitation = McpSchema.ClientCapabilities.builder()
.elicitation()
.build();
JsonSchemaValidator rejectingValidator = new JsonSchemaValidator() {
@Override
public ValidationResponse validate(Map<String, Object> schema, Object content) {
return ValidationResponse.asValid(null);
}
@Override
public ValidationResponse validateSchema(Map<String, Object> schema) {
return ValidationResponse.asInvalid("bad schema");
}
};
McpAsyncServerExchange exchangeWithValidator = new McpAsyncServerExchange("testSessionId", mockSession,
capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY, rejectingValidator);
McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest
.builder("Provide info", Map.of("type", "invalid-type"))
.build();
StepVerifier.create(exchangeWithValidator.createElicitation(elicitRequest)).verifyErrorSatisfies(error -> {
assertThat(error).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("SEP-1613")
.hasMessageContaining("ElicitRequest requestedSchema");
});
verify(mockSession, never()).sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), any(), any(TypeRef.class));
}
@Test
void testCreateElicitationWithValidSchemaPassesThroughToSession() {
McpSchema.ClientCapabilities capabilitiesWithElicitation = McpSchema.ClientCapabilities.builder()
.elicitation()
.build();
JsonSchemaValidator acceptingValidator = new JsonSchemaValidator() {
@Override
public ValidationResponse validate(Map<String, Object> schema, Object content) {
return ValidationResponse.asValid(null);
}
@Override
public ValidationResponse validateSchema(Map<String, Object> schema) {
return ValidationResponse.asValid(null);
}
};
McpAsyncServerExchange exchangeWithValidator = new McpAsyncServerExchange("testSessionId", mockSession,
capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY, acceptingValidator);
Map<String, Object> validSchema = Map.of("type", "object");
McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder("Provide info", validSchema).build();
when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(elicitRequest), any(TypeRef.class)))
.thenReturn(Mono.just(McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT).build()));
StepVerifier.create(exchangeWithValidator.createElicitation(elicitRequest)).assertNext(result -> {
assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT);
}).verifyComplete();
verify(mockSession, times(1)).sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(elicitRequest),
any(TypeRef.class));
}
// ---------------------------------------
// Create Message Tests
// ---------------------------------------

View file

@ -7,6 +7,8 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import com.networknt.schema.SchemaLocation;
import io.modelcontextprotocol.util.Assert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -19,6 +21,7 @@ import com.networknt.schema.SchemaRegistry;
import com.networknt.schema.dialect.Dialects;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.spec.McpSchema;
/**
* Default implementation of the {@link JsonSchemaValidator} interface. This class
@ -38,14 +41,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(new ObjectMapper());
}
public DefaultJsonSchemaValidator(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
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
@ -86,6 +93,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.objectMapper.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.jackson2;
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 org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
@ -805,4 +808,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,96 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.json.jackson2;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.json.schema.jackson2.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(new ObjectMapper());
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();
}
}

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();
}
}

View file

@ -1184,6 +1184,58 @@ public class McpSchemaTests {
assertThat(tool.annotations().returnDirect()).isFalse();
}
@Test
void testToolInputSchemaWithExplicitDialect() throws Exception {
Map<String, Object> inputSchema = new HashMap<>();
inputSchema.put("$schema", "http://json-schema.org/draft-07/schema#");
inputSchema.put("type", "object");
inputSchema.put("properties", Map.of("a", Map.of("type", "number")));
McpSchema.Tool tool = McpSchema.Tool.builder("calc", inputSchema).description("draft-07 tool").build();
String json = JSON_MAPPER.writeValueAsString(tool);
assertThatJson(json).inPath("$.inputSchema.$schema").isEqualTo("http://json-schema.org/draft-07/schema#");
McpSchema.Tool parsed = JSON_MAPPER.readValue(json, McpSchema.Tool.class);
assertThat(parsed.inputSchema()).containsEntry("$schema", "http://json-schema.org/draft-07/schema#");
}
@Test
void testToolOutputSchemaWithExplicitDialect() throws Exception {
Map<String, Object> inputSchema = Map.of("type", "object");
Map<String, Object> outputSchema = new HashMap<>();
outputSchema.put("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12);
outputSchema.put("type", "object");
outputSchema.put("properties", Map.of("count", Map.of("type", "integer")));
McpSchema.Tool tool = McpSchema.Tool.builder("counter", inputSchema).outputSchema(outputSchema).build();
String json = JSON_MAPPER.writeValueAsString(tool);
assertThatJson(json).inPath("$.outputSchema.$schema").isEqualTo(McpSchema.JSON_SCHEMA_DIALECT_2020_12);
McpSchema.Tool parsed = JSON_MAPPER.readValue(json, McpSchema.Tool.class);
assertThat(parsed.outputSchema()).containsEntry("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12);
}
@Test
void testToolPreserves2020_12Keywords() throws Exception {
Map<String, Object> inputSchema = 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);
McpSchema.Tool tool = McpSchema.Tool.builder("addr_tool", inputSchema).build();
McpSchema.Tool parsed = JSON_MAPPER.readValue(JSON_MAPPER.writeValueAsString(tool), McpSchema.Tool.class);
Map<String, Object> rt = parsed.inputSchema();
assertThat(rt).containsEntry("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12);
assertThat(rt).containsKey("$defs");
assertThat(rt).containsEntry("additionalProperties", false);
}
@Test
void testToolDeserializationWithoutOutputSchema() throws Exception {
String toolJson = """
@ -1567,6 +1619,24 @@ public class McpSchemaTests {
assertThat(request.progressToken()).isEqualTo("elicit-token-789");
}
@Test
void testElicitRequestSchemaWithExplicitDialect() throws Exception {
Map<String, Object> requestedSchema = new HashMap<>();
requestedSchema.put("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12);
requestedSchema.put("type", "object");
requestedSchema.put("properties", Map.of("name", Map.of("type", "string")));
requestedSchema.put("required", List.of("name"));
McpSchema.ElicitRequest request = McpSchema.ElicitRequest.builder("Please provide name", requestedSchema)
.build();
String json = JSON_MAPPER.writeValueAsString(request);
assertThatJson(json).inPath("$.requestedSchema.$schema").isEqualTo(McpSchema.JSON_SCHEMA_DIALECT_2020_12);
McpSchema.ElicitRequest parsed = JSON_MAPPER.readValue(json, McpSchema.ElicitRequest.class);
assertThat(parsed.requestedSchema()).containsEntry("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12);
}
// Pagination Tests
@Test