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:
parent
5d4a1cce42
commit
87e2c7d4de
20 changed files with 903 additions and 35 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() -> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() -> {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue