diff --git a/conformance-tests/VALIDATION_RESULTS.md b/conformance-tests/VALIDATION_RESULTS.md index e4ce396bc..f581c193c 100644 --- a/conformance-tests/VALIDATION_RESULTS.md +++ b/conformance-tests/VALIDATION_RESULTS.md @@ -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 diff --git a/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java b/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java index c8a0b8cbf..dafa60b45 100644 --- a/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java +++ b/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java @@ -9,6 +9,7 @@ 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 @@ private static List createToolSpecs() { }) .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) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java index 09fe604f4..7eed33942 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java @@ -13,6 +13,29 @@ */ 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 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 static ValidationResponse asInvalid(String message) { */ ValidationResponse validate(Map 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 schema) { + return ValidationResponse.asValid(null); + } + } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index ed74ecdce..2044d8b38 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -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 prepareNotificationHandlers(McpServerFeatures.Async features) { @@ -347,6 +348,15 @@ public Mono addTool(McpServerFeatures.AsyncToolSpecification toolSpecifica 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(() -> { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java index aaa643362..b3d55bc52 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java @@ -9,6 +9,7 @@ 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 CREATE_MESSAGE_RESULT_TYPE_REF = new TypeRef<>() { }; @@ -51,21 +54,40 @@ 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) { + 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. + * @param clientInfo The client implementation information. + * @param transportContext context associated with the client as extracted from the + * transport + */ + public McpAsyncServerExchange(String sessionId, McpLoggableSession session, + McpSchema.ClientCapabilities clientCapabilities, McpSchema.Implementation clientInfo, + McpTransportContext transportContext) { + this(sessionId, session, clientCapabilities, clientInfo, transportContext, null); } /** @@ -152,6 +174,15 @@ public Mono createElicitation(McpSchema.ElicitRequest el 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); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java index 9867fa038..a2333aedb 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java @@ -243,6 +243,8 @@ public McpAsyncServer build() { 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 McpAsyncServer build() { 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 McpSyncServer build() { 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 McpSyncServer build() { 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 StatelessAsyncSpecification jsonSchemaValidator(JsonSchemaValidator jsonS 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 McpStatelessSyncServer build() { 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 tools) { + tools.forEach(spec -> validateToolSchema(validator, spec.tool())); + } + + private static void validateSyncToolSchemas(JsonSchemaValidator validator, + List tools) { + tools.forEach(spec -> validateToolSchema(validator, spec.tool())); + } + + private static void validateStatelessAsyncToolSchemas(JsonSchemaValidator validator, + List tools) { + tools.forEach(spec -> validateToolSchema(validator, spec.tool())); + } + + private static void validateStatelessSyncToolSchemas(JsonSchemaValidator validator, + List 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()); + } + } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java index bf51662bf..3d7054cba 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java @@ -339,6 +339,15 @@ public Mono addTool(McpStatelessServerFeatures.AsyncToolSpecification tool 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(() -> { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/DefaultMcpStreamableServerSessionFactory.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/DefaultMcpStreamableServerSessionFactory.java index 65da43202..aa0843626 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/DefaultMcpStreamableServerSessionFactory.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/DefaultMcpStreamableServerSessionFactory.java @@ -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,8 @@ public class DefaultMcpStreamableServerSessionFactory implements McpStreamableSe private final Function> onClose; + private final JsonSchemaValidator jsonSchemaValidator; + /** * Constructs an instance. * @param requestTimeout timeout for requests @@ -39,16 +42,35 @@ public class DefaultMcpStreamableServerSessionFactory implements McpStreamableSe * @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> requestHandlers, Map notificationHandlers, - Function> onClose) { + Function> 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 + * @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 + */ + public DefaultMcpStreamableServerSessionFactory(Duration requestTimeout, + McpStreamableServerSession.InitRequestHandler initRequestHandler, + Map> requestHandlers, Map notificationHandlers, + Function> onClose) { + this(requestTimeout, initRequestHandler, requestHandlers, notificationHandlers, onClose, null); } /** @@ -73,9 +95,10 @@ public DefaultMcpStreamableServerSessionFactory(Duration requestTimeout, 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)); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/JsonSchemaValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/JsonSchemaValidator.java index 4a42c9ff3..87b08193c 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/JsonSchemaValidator.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/JsonSchemaValidator.java @@ -11,7 +11,9 @@ * 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 { /** diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index 6c7f56848..d883af252 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -46,6 +46,12 @@ private 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 ToolAnnotations build() { * @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 CreateMessageResult build() { * * @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 *

* Note: {@code message} and {@code requestedSchema} are required by the MCP diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java index 89c9bc5ac..4655167ab 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java @@ -13,6 +13,7 @@ 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,8 @@ public class McpServerSession implements McpLoggableSession { private final Supplier> onClose; + private final JsonSchemaValidator jsonSchemaValidator; + /** * Creates a new server session with the given parameters and the transport to use. * @param id session id @@ -79,10 +82,13 @@ public class McpServerSession implements McpLoggableSession { * @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> requestHandlers, - Map notificationHandlers, Supplier> onClose) { + Map notificationHandlers, Supplier> onClose, + JsonSchemaValidator jsonSchemaValidator) { this.id = id; this.requestTimeout = requestTimeout; this.transport = transport; @@ -90,6 +96,25 @@ public McpServerSession(String id, Duration requestTimeout, McpServerTransport t 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 + * @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 + */ + public McpServerSession(String id, Duration requestTimeout, McpServerTransport transport, + McpInitRequestHandler initHandler, Map> requestHandlers, + Map notificationHandlers, Supplier> onClose) { + this(id, requestTimeout, transport, initHandler, requestHandlers, notificationHandlers, onClose, null); } /** @@ -300,7 +325,7 @@ private Mono handleIncomingNotification(McpSchema.JSONRPCNotification noti // 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 @@ private Mono handleIncomingNotification(McpSchema.JSONRPCNotification noti */ 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) { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java index e9575de29..5bb5c3812 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java @@ -18,6 +18,7 @@ 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,8 @@ public class McpStreamableServerSession implements McpLoggableSession { private final Supplier> onClose; + private final JsonSchemaValidator jsonSchemaValidator; + /** * Create an instance of the streamable session. * @param id session ID @@ -74,11 +77,13 @@ public class McpStreamableServerSession implements McpLoggableSession { * @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> requestHandlers, Map notificationHandlers, - Supplier> onClose) { + Supplier> onClose, JsonSchemaValidator jsonSchemaValidator) { this.id = id; this.missingMcpTransportSession = new MissingMcpTransportSession(id); this.listeningStreamRef = new AtomicReference<>(this.missingMcpTransportSession); @@ -88,6 +93,25 @@ public McpStreamableServerSession(String id, McpSchema.ClientCapabilities client this.requestHandlers = requestHandlers; this.notificationHandlers = notificationHandlers; this.onClose = onClose; + this.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 + */ + public McpStreamableServerSession(String id, McpSchema.ClientCapabilities clientCapabilities, + McpSchema.Implementation clientInfo, Duration requestTimeout, + Map> requestHandlers, Map notificationHandlers, + Supplier> onClose) { + this(id, clientCapabilities, clientInfo, requestTimeout, requestHandlers, notificationHandlers, onClose, null); } /** @@ -196,7 +220,7 @@ public Mono responseStream(McpSchema.JSONRPCRequest jsonrpcRequest, McpStr } 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 Mono accept(McpSchema.JSONRPCNotification notification) { } 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()); }); } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java index e58e59e68..2eac7c54f 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java @@ -10,6 +10,7 @@ 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 @@ void testCreateElicitationWithSessionError() { }); } + @Test + void testCreateElicitationWithInvalidRequestedSchema() { + McpSchema.ClientCapabilities capabilitiesWithElicitation = McpSchema.ClientCapabilities.builder() + .elicitation() + .build(); + + JsonSchemaValidator rejectingValidator = new JsonSchemaValidator() { + @Override + public ValidationResponse validate(Map schema, Object content) { + return ValidationResponse.asValid(null); + } + + @Override + public ValidationResponse validateSchema(Map 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 schema, Object content) { + return ValidationResponse.asValid(null); + } + + @Override + public ValidationResponse validateSchema(Map schema) { + return ValidationResponse.asValid(null); + } + }; + + McpAsyncServerExchange exchangeWithValidator = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY, acceptingValidator); + + Map 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 // --------------------------------------- diff --git a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson2/DefaultJsonSchemaValidator.java b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson2/DefaultJsonSchemaValidator.java index e07bf1759..9975ce05f 100644 --- a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson2/DefaultJsonSchemaValidator.java +++ b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson2/DefaultJsonSchemaValidator.java @@ -7,6 +7,8 @@ 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.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 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 ValidationResponse validate(Map schema, Object structured } } + @Override + public ValidationResponse validateSchema(Map 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 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 diff --git a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson2/DefaultJsonSchemaValidatorTests.java b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson2/DefaultJsonSchemaValidatorTests.java index 5ae3fbed4..3cf59aa3c 100644 --- a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson2/DefaultJsonSchemaValidatorTests.java +++ b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson2/DefaultJsonSchemaValidatorTests.java @@ -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.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 @@ void testValidationResponseRecord() { assertNotEquals(response1, response2); } + @Test + void validatesSchemaWithExplicitDraft07Dialect() { + Map 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 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 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 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 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 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 schema = Map.of("type", "not-a-valid-type"); + + assertFalse(validator.validateSchema(schema).valid()); + } + + @Test + void validateSchemaRejectsSchemaWithWrongTypeForRequired() { + Map schema = Map.of("type", "object", "required", "should-be-an-array"); + + assertFalse(validator.validateSchema(schema).valid()); + } + + @Test + void validateSchemaSkipsDraft07SchemasWithExplicitDialect() { + Map 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 schema = Map.of("type", "object", "properties", Map.of("name", Map.of("type", "string"))); + + validator.assertConforms("Tool 'my-tool' inputSchema", schema); + } + + @Test + void assertConformsThrowsForInvalidSchema() { + Map 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"); + } + } diff --git a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson2/McpServerAddToolSchemaValidationTests.java b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson2/McpServerAddToolSchemaValidationTests.java new file mode 100644 index 000000000..d54b2a871 --- /dev/null +++ b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson2/McpServerAddToolSchemaValidationTests.java @@ -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(); + } + +} diff --git a/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/schema/jackson3/DefaultJsonSchemaValidator.java b/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/schema/jackson3/DefaultJsonSchemaValidator.java index 8c9b7ccdb..d8ad09303 100644 --- a/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/schema/jackson3/DefaultJsonSchemaValidator.java +++ b/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/schema/jackson3/DefaultJsonSchemaValidator.java @@ -8,10 +8,13 @@ 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 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 ValidationResponse validate(Map schema, Object structured } } + @Override + public ValidationResponse validateSchema(Map 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 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 diff --git a/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java index 37c52caf7..be01eb23c 100644 --- a/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java +++ b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java @@ -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.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 @@ void testValidationResponseRecord() { assertNotEquals(response1, response2); } + @Test + void validatesSchemaWithExplicitDraft07Dialect() { + Map 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 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 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 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 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 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 schema = Map.of("type", "not-a-valid-type"); + + assertFalse(validator.validateSchema(schema).valid()); + } + + @Test + void validateSchemaRejectsSchemaWithWrongTypeForRequired() { + Map schema = Map.of("type", "object", "required", "should-be-an-array"); + + assertFalse(validator.validateSchema(schema).valid()); + } + + @Test + void validateSchemaSkipsDraft07SchemasWithExplicitDialect() { + Map 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 schema = Map.of("type", "object", "properties", Map.of("name", Map.of("type", "string"))); + + validator.assertConforms("Tool 'my-tool' inputSchema", schema); + } + + @Test + void assertConformsThrowsForInvalidSchema() { + Map 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"); + } + } diff --git a/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpServerAddToolSchemaValidationTests.java b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpServerAddToolSchemaValidationTests.java new file mode 100644 index 000000000..be2ae3a91 --- /dev/null +++ b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpServerAddToolSchemaValidationTests.java @@ -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(); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index c83d0960b..31265ec6c 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -1184,6 +1184,58 @@ void testToolDeserialization() throws Exception { assertThat(tool.annotations().returnDirect()).isFalse(); } + @Test + void testToolInputSchemaWithExplicitDialect() throws Exception { + Map 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 inputSchema = Map.of("type", "object"); + Map 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 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 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 @@ void testElicitRequestWithMeta() throws Exception { assertThat(request.progressToken()).isEqualTo("elicit-token-789"); } + @Test + void testElicitRequestSchemaWithExplicitDialect() throws Exception { + Map 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