Description
Bug description
DefaultJsonSchemaValidator caches each compiled schema under generateCacheKey(schema), which falls back to String.valueOf(schema.hashCode()) when the schema has no $id. Two different schemas with the same hashCode() therefore share one cache entry, and whichever is validated first is used for both. A server validating tool inputs then checks a call to one tool against another tool's input schema:
- a valid call is rejected (
required property 'a' not found), and - an invalid call can be accepted.
Which tools are affected depends on the schemas and on the order in which tools are first called, so it can pass tests and fail in production, or the reverse.
Collisions are not rare for tool input schemas. Map.hashCode() sums key.hashCode() ^ value.hashCode() over the entries, and a property name contributes twice (XORed with its sub-schema under properties, and inside the required list), so much of its contribution cancels. Measured with {"type":"object","properties":{N:{"type":"string"}},"required":[N]} over the 702 one- and two-letter names N: only 44 distinct hash codes (17 with "type":"number"; one group holds 190 schemas). Every single-letter name collides with every other.
The code is the same on main and in v2.1.0, in both mcp-json-jackson2 and mcp-json-jackson3 (DefaultJsonSchemaValidator.generateCacheKey).
Environment
MCP Java SDK 2.0.0 (mcp-json-jackson3; also present in 2.1.0 and on main), networknt json-schema-validator 3.0.0, Java 21.0.8. Found in a server exposing two tools with one parameter each, named a and q: after the first had been called, every call to the second was refused.
Steps to reproduce
- Create one
DefaultJsonSchemaValidator, as a server does. - Validate
{"a": "x"}against{"type":"object","properties":{"a":{"type":"string"}},"required":["a"],"additionalProperties":false}. - Validate
{"b": "x"}against the same schema withbin place ofa.
Expected behavior
Step 3 is valid: each schema is validated as itself. A fresh validator does say valid; the shared one says invalid, with the first schema's errors.
Minimal Complete Reproducible example
import io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator;
import java.util.List;
import java.util.Map;
public class Repro {
static Map<String, Object> schema(String property) {
return Map.of("type", "object",
"properties", Map.of(property, Map.of("type", "string")),
"required", List.of(property),
"additionalProperties", false);
}
public static void main(String[] args) {
Map<String, Object> schemaA = schema("a");
Map<String, Object> schemaB = schema("b");
System.out.println("schemas equal: " + schemaA.equals(schemaB)
+ ", hashCodes equal: " + (schemaA.hashCode() == schemaB.hashCode()));
System.out.println("fresh validator, B with {b}: "
+ new DefaultJsonSchemaValidator().validate(schemaB, Map.of("b", "x")).valid());
DefaultJsonSchemaValidator v = new DefaultJsonSchemaValidator();
System.out.println("A with {a}: " + v.validate(schemaA, Map.of("a", "x")).valid());
var r = v.validate(schemaB, Map.of("b", "x"));
System.out.println("then B with {b} (valid for B): " + r.valid() + " " + (r.valid() ? "" : r.errorMessage()));
System.out.println("then B with {a} (invalid for B): " + v.validate(schemaB, Map.of("a", "x")).valid());
}
}Output:
schemas equal: false, hashCodes equal: true
fresh validator, B with {b}: true
A with {a}: true
then B with {b} (valid for B): false Validation failed: JSON schema validation errors: [: required property 'a' not found, : property 'b' is not defined in the schema and the schema does not allow additional properties]
then B with {a} (invalid for B): true
Possible fix
Key the cache by the schema's content rather than its hash code, e.g. its serialized JSON (or String.valueOf(schema) for the nested Map/List values here), so that two keys are equal only when the schemas are. A server can work around it today by passing McpServer...jsonSchemaValidator(...) a DefaultJsonSchemaValidator subclass that overrides the protected generateCacheKey that way.
Activity
Shanmukh-l33t commentedon Nov 11, 2026
Reproduced on
main(33e1581) and fixed on a branch — sharing the analysis and approach here.Reproduction confirmed. With one shared
DefaultJsonSchemaValidator, the two schemas from the report (propertyavsb,"additionalProperties": false) areequals() == falsebut have equalhashCode()(511136268 for both, on any JVM —Map.hashCode()is content-derived). After validating{"a":"x"}against schema A, validating{"b":"x"}against schema B returns:and
{"a":"x"}(invalid for B) is accepted. Both failure modes from the report occur: false reject and false accept.Root cause.
DefaultJsonSchemaValidator.generateCacheKeykeys the compiled-schema cache onString.valueOf(schema.hashCode())for schemas without$id, and on the$idvalue alone otherwise. Both are collision-prone:Map.hashCode()sumskey.hashCode() ^ value.hashCode()terms, so structurally similar schemas collide easily (as the report measured), and two distinct schemas may also share an$id. First validator for a key wins, so every later schema with the same key is checked against the wrong compiled schema.Fix approach. Key the cache on the schema's canonical serialization: recursively sort map entries, then take the mapper's JSON string as the key. Two schemas then share a cache entry if and only if their content is equal. The cache's purpose (avoid recompiling with networknt on every
validate()call) is unchanged — equal schemas still compile once, regardless of map iteration order. Applied to bothmcp-json-jackson2andmcp-json-jackson3.generateCacheKeykeeps itsprotectedsignature, so the subclass workaround mentioned in the report still compiles.Test evidence (JDK 17,
./mvnw test -pl mcp-json-jackson2,mcp-json-jackson3):testDistinctSchemasWithCollidingHashCodesDoNotShareCacheEntryasserts the collision first (!schemaA.equals(schemaB)and equal hash codes), then the cross-contamination. On unmodifiedmain: RED —mcp-json-jackson3Tests run: 47, Failures: 1andmcp-json-jackson2Tests run: 47, Failures: 1, both failing exactly at theExpected {b=x} to be valid against schema Bassertion with the error above.mcp-json-jackson3Tests run: 47, Failures: 0, Errors: 0andmcp-json-jackson2Tests run: 47, Failures: 0, Errors: 0(all pre-existing tests included).testEqualSchemasShareCacheEntry(green on both main and branch) pinning that equal schemas still share one compiled entry (getCacheSize() == 1).Tests-first branch (regression tests commit, then fix commit): main...Shanmukh-l33t:fix/json-schema-validator-cache-key — happy to reshape if a maintainer wants a different key strategy (e.g. keep the
$idfast path and verify content on hit).AI-assistance disclosure: the analysis, tests and fix were prepared with the help of an AI coding agent.