blob: 5359b7045d5ff40ccea34cbfaafc69861aaad6dc [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Bounded JSON Schema 2020-12 validation for MCP tool definitions and calls."""
from __future__ import annotations
import hashlib
import json
from collections import OrderedDict
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any
from urllib.parse import unquote
from jsonschema import Draft202012Validator, FormatChecker
from jsonschema.exceptions import SchemaError
from mcp.types import Tool
JSON_SCHEMA_2020_12 = "https://json-schema.org/draft/2020-12/schema"
_SUPPORTED_DIALECTS = frozenset(
{
JSON_SCHEMA_2020_12,
f"{JSON_SCHEMA_2020_12}#",
}
)
_REFERENCE_KEYWORDS = frozenset({"$ref", "$dynamicRef"})
_COMBINATOR_KEYWORDS = frozenset({"allOf", "anyOf", "oneOf"})
@dataclass(frozen=True)
class SchemaLimits:
"""Hard limits applied before a JSON Schema validator is constructed."""
max_schema_bytes: int = 65_536
max_schema_nodes: int = 2_048
max_schema_depth: int = 32
max_combinator_branches: int = 64
max_references: int = 64
max_enum_values: int = 256
max_pattern_length: int = 512
max_instance_bytes: int = 1_048_576
max_instance_nodes: int = 10_000
max_instance_depth: int = 32
max_instance_string_length: int = 262_144
max_validation_errors: int = 16
max_cached_schemas: int = 256
DEFAULT_SCHEMA_LIMITS = SchemaLimits()
class ToolSchemaDefinitionError(ValueError):
"""A server-owned tool definition is invalid or exceeds policy."""
@dataclass(frozen=True)
class SchemaViolation:
"""A value failed one schema keyword without echoing the value."""
instance_path: str
keyword: str
def as_dict(self) -> dict[str, str]:
return {
"instancePath": self.instance_path,
"keyword": self.keyword,
}
class ToolArgumentsValidationError(ValueError):
"""Tool arguments do not satisfy the advertised input schema."""
def __init__(
self,
violations: Sequence[SchemaViolation],
*,
truncated: bool = False,
) -> None:
super().__init__("Tool arguments do not match input schema")
self.violations = tuple(violations)
self.truncated = truncated
class ToolOutputValidationError(ValueError):
"""A successful tool result does not satisfy its output schema."""
@dataclass(frozen=True)
class _SchemaMetrics:
graph: Mapping[int, frozenset[int]]
references: tuple[tuple[Mapping[str, Any], str, str], ...]
@dataclass(frozen=True)
class CompiledToolSchema:
"""Compiled validators for one input/output schema pair."""
input_validator: Draft202012Validator
output_validator: Draft202012Validator | None
limits: SchemaLimits
def validate_arguments(self, arguments: Mapping[str, Any]) -> None:
_validate_instance_limits(arguments, self.limits)
violations, truncated = _collect_violations(
self.input_validator,
arguments,
self.limits.max_validation_errors,
)
if violations:
raise ToolArgumentsValidationError(
violations,
truncated=truncated,
)
def validate_output(self, output: Any) -> None:
if self.output_validator is None:
return
if output is None:
raise ToolOutputValidationError(
"Tool declared outputSchema but returned no structured content"
)
try:
_validate_instance_limits(output, self.limits)
except ToolArgumentsValidationError as exc:
raise ToolOutputValidationError(
"Tool structured content exceeds output limits"
) from exc
violations, _ = _collect_violations(
self.output_validator,
output,
self.limits.max_validation_errors,
)
if violations:
raise ToolOutputValidationError(
"Tool structured content does not match output schema"
)
class ToolSchemaGuard:
"""Compile, cache, and apply schemas for the visible tool catalog."""
def __init__(self, limits: SchemaLimits = DEFAULT_SCHEMA_LIMITS) -> None:
self.limits = limits
self._cache: OrderedDict[str, CompiledToolSchema] = OrderedDict()
def compile_catalog(self, tools: Sequence[Tool]) -> dict[str, CompiledToolSchema]:
compiled: dict[str, CompiledToolSchema] = {}
for tool in tools:
if tool.name in compiled:
raise ToolSchemaDefinitionError(
f"Tool catalog contains duplicate name {tool.name!r}"
)
compiled[tool.name] = self.compile_tool(tool)
return compiled
def compile_tool(self, tool: Tool) -> CompiledToolSchema:
cache_key = _schema_cache_key(tool.input_schema, tool.output_schema)
cached = self._cache.get(cache_key)
if cached is not None:
self._cache.move_to_end(cache_key)
return cached
try:
input_validator = _compile_schema(
tool.input_schema,
limits=self.limits,
require_object_root=True,
)
output_validator = (
_compile_schema(
tool.output_schema,
limits=self.limits,
require_object_root=False,
)
if tool.output_schema is not None
else None
)
except ToolSchemaDefinitionError as exc:
raise ToolSchemaDefinitionError(
f"Invalid schema for tool {tool.name!r}: {exc}"
) from exc
compiled = CompiledToolSchema(
input_validator=input_validator,
output_validator=output_validator,
limits=self.limits,
)
self._cache[cache_key] = compiled
self._cache.move_to_end(cache_key)
while len(self._cache) > self.limits.max_cached_schemas:
self._cache.popitem(last=False)
return compiled
def _schema_cache_key(
input_schema: Mapping[str, Any],
output_schema: Mapping[str, Any] | None,
) -> str:
try:
payload = json.dumps(
[input_schema, output_schema],
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
).encode("utf-8")
except (TypeError, ValueError, RecursionError) as exc:
raise ToolSchemaDefinitionError(
"schema must be an acyclic JSON value"
) from exc
return hashlib.sha256(payload).hexdigest()
def _compile_schema(
schema: Mapping[str, Any],
*,
limits: SchemaLimits,
require_object_root: bool,
) -> Draft202012Validator:
if require_object_root and schema.get("type") != "object":
raise ToolSchemaDefinitionError(
'inputSchema must declare type: "object" at the root'
)
dialect = schema.get("$schema")
if dialect is not None and dialect not in _SUPPORTED_DIALECTS:
raise ToolSchemaDefinitionError(
"only the JSON Schema 2020-12 dialect is supported"
)
metrics = _measure_schema(schema, limits)
graph = _validate_local_references(schema, metrics)
_reject_reference_cycles(graph)
try:
Draft202012Validator.check_schema(schema)
except SchemaError as exc:
path = _json_pointer(exc.absolute_path)
location = path or "/"
raise ToolSchemaDefinitionError(
f"schema is not valid JSON Schema 2020-12 at {location}"
) from exc
return Draft202012Validator(
schema,
format_checker=FormatChecker(),
)
def _measure_schema(
schema: Mapping[str, Any],
limits: SchemaLimits,
) -> _SchemaMetrics:
stack: list[tuple[Any, int, int | None]] = [(schema, 0, None)]
seen_containers: set[int] = set()
graph: dict[int, set[int]] = {}
references: list[tuple[Mapping[str, Any], str, str]] = []
node_count = 0
combinator_branches = 0
reference_count = 0
while stack:
value, depth, parent_id = stack.pop()
node_count += 1
if node_count > limits.max_schema_nodes:
raise ToolSchemaDefinitionError(
f"schema exceeds {limits.max_schema_nodes} nodes"
)
if depth > limits.max_schema_depth:
raise ToolSchemaDefinitionError(
f"schema exceeds depth {limits.max_schema_depth}"
)
if isinstance(value, str):
continue
if isinstance(value, Mapping):
container_id = id(value)
if container_id in seen_containers:
raise ToolSchemaDefinitionError(
"schema must not reuse or cycle Python containers"
)
seen_containers.add(container_id)
graph.setdefault(container_id, set())
if parent_id is not None:
graph.setdefault(parent_id, set()).add(container_id)
for keyword in _REFERENCE_KEYWORDS:
if keyword not in value:
continue
reference = value[keyword]
if not isinstance(reference, str):
raise ToolSchemaDefinitionError(
f"{keyword} must be a string"
)
if reference and not reference.startswith("#"):
raise ToolSchemaDefinitionError(
f"{keyword} must be a same-document reference"
)
reference_count += 1
if reference_count > limits.max_references:
raise ToolSchemaDefinitionError(
f"schema exceeds {limits.max_references} references"
)
references.append((value, keyword, reference))
for keyword in _COMBINATOR_KEYWORDS:
branches = value.get(keyword)
if isinstance(branches, list):
combinator_branches += len(branches)
if combinator_branches > limits.max_combinator_branches:
raise ToolSchemaDefinitionError(
"schema exceeds "
f"{limits.max_combinator_branches} combinator branches"
)
enum_values = value.get("enum")
if isinstance(enum_values, list) and len(enum_values) > limits.max_enum_values:
raise ToolSchemaDefinitionError(
f"schema enum exceeds {limits.max_enum_values} values"
)
pattern = value.get("pattern")
if isinstance(pattern, str) and len(pattern) > limits.max_pattern_length:
raise ToolSchemaDefinitionError(
f"schema pattern exceeds {limits.max_pattern_length} characters"
)
pattern_properties = value.get("patternProperties")
if isinstance(pattern_properties, Mapping):
for pattern in pattern_properties:
if (
isinstance(pattern, str)
and len(pattern) > limits.max_pattern_length
):
raise ToolSchemaDefinitionError(
"schema patternProperties key exceeds "
f"{limits.max_pattern_length} characters"
)
for child in reversed(tuple(value.values())):
stack.append((child, depth + 1, container_id))
continue
if isinstance(value, list):
container_id = id(value)
if container_id in seen_containers:
raise ToolSchemaDefinitionError(
"schema must not reuse or cycle Python containers"
)
seen_containers.add(container_id)
graph.setdefault(container_id, set())
if parent_id is not None:
graph.setdefault(parent_id, set()).add(container_id)
for child in reversed(value):
stack.append((child, depth + 1, container_id))
try:
encoded = json.dumps(
schema,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
).encode("utf-8")
except (TypeError, ValueError, RecursionError) as exc:
raise ToolSchemaDefinitionError(
"schema must be an acyclic JSON value"
) from exc
if len(encoded) > limits.max_schema_bytes:
raise ToolSchemaDefinitionError(
f"schema exceeds {limits.max_schema_bytes} bytes"
)
return _SchemaMetrics(
graph={key: frozenset(value) for key, value in graph.items()},
references=tuple(references),
)
def _validate_local_references(
root: Mapping[str, Any],
metrics: _SchemaMetrics,
) -> Mapping[int, frozenset[int]]:
anchors: dict[str, Any] = {}
stack: list[Any] = [root]
while stack:
value = stack.pop()
if isinstance(value, Mapping):
for keyword in ("$anchor", "$dynamicAnchor"):
anchor = value.get(keyword)
if not isinstance(anchor, str):
continue
if anchor in anchors and anchors[anchor] is not value:
raise ToolSchemaDefinitionError(
f"schema contains duplicate anchor {anchor!r}"
)
anchors[anchor] = value
stack.extend(value.values())
elif isinstance(value, list):
stack.extend(value)
mutable_graph = {
key: set(targets)
for key, targets in metrics.graph.items()
}
for source, keyword, reference in metrics.references:
target = _resolve_local_reference(root, anchors, reference)
if not isinstance(target, Mapping | bool):
raise ToolSchemaDefinitionError(
f"{keyword} target must be a schema"
)
if isinstance(target, Mapping):
mutable_graph.setdefault(id(source), set()).add(id(target))
return {
key: frozenset(value)
for key, value in mutable_graph.items()
}
def _resolve_local_reference(
root: Mapping[str, Any],
anchors: Mapping[str, Any],
reference: str,
) -> Any:
if reference in {"", "#"}:
return root
fragment = unquote(reference[1:])
if not fragment.startswith("/"):
try:
return anchors[fragment]
except KeyError as exc:
raise ToolSchemaDefinitionError(
f"schema reference points to unknown anchor {fragment!r}"
) from exc
current: Any = root
for raw_token in fragment[1:].split("/"):
token = raw_token.replace("~1", "/").replace("~0", "~")
try:
if isinstance(current, Mapping):
current = current[token]
elif isinstance(current, list):
current = current[int(token)]
else:
raise KeyError(token)
except (KeyError, IndexError, TypeError, ValueError) as exc:
raise ToolSchemaDefinitionError(
f"schema reference points to missing JSON Pointer {reference!r}"
) from exc
return current
def _reject_reference_cycles(graph: Mapping[int, frozenset[int]]) -> None:
visiting: set[int] = set()
visited: set[int] = set()
def visit(node: int) -> None:
if node in visiting:
raise ToolSchemaDefinitionError(
"recursive local references exceed the bounded validation policy"
)
if node in visited:
return
visiting.add(node)
for target in graph.get(node, ()):
visit(target)
visiting.remove(node)
visited.add(node)
for node in graph:
visit(node)
def _validate_instance_limits(instance: Any, limits: SchemaLimits) -> None:
stack: list[tuple[Any, int]] = [(instance, 0)]
seen_containers: set[int] = set()
node_count = 0
while stack:
value, depth = stack.pop()
node_count += 1
if node_count > limits.max_instance_nodes:
raise ToolArgumentsValidationError(
[SchemaViolation("", "maxInstanceNodes")]
)
if depth > limits.max_instance_depth:
raise ToolArgumentsValidationError(
[SchemaViolation("", "maxInstanceDepth")]
)
if isinstance(value, str) and len(value) > limits.max_instance_string_length:
raise ToolArgumentsValidationError(
[SchemaViolation("", "maxStringLength")]
)
if isinstance(value, Mapping):
container_id = id(value)
if container_id in seen_containers:
raise ToolArgumentsValidationError(
[SchemaViolation("", "jsonValue")]
)
seen_containers.add(container_id)
for key, child in value.items():
if not isinstance(key, str):
raise ToolArgumentsValidationError(
[SchemaViolation("", "jsonObjectKey")]
)
stack.append((child, depth + 1))
elif isinstance(value, list):
container_id = id(value)
if container_id in seen_containers:
raise ToolArgumentsValidationError(
[SchemaViolation("", "jsonValue")]
)
seen_containers.add(container_id)
for child in value:
stack.append((child, depth + 1))
try:
encoded = json.dumps(
instance,
ensure_ascii=False,
separators=(",", ":"),
allow_nan=False,
).encode("utf-8")
except (TypeError, ValueError, RecursionError) as exc:
raise ToolArgumentsValidationError(
[SchemaViolation("", "jsonValue")]
) from exc
if len(encoded) > limits.max_instance_bytes:
raise ToolArgumentsValidationError(
[SchemaViolation("", "maxInstanceBytes")]
)
def _collect_violations(
validator: Draft202012Validator,
instance: Any,
limit: int,
) -> tuple[list[SchemaViolation], bool]:
violations: list[SchemaViolation] = []
truncated = False
for error in validator.iter_errors(instance):
if len(violations) >= limit:
truncated = True
break
keyword = (
error.validator
if isinstance(error.validator, str)
else "schema"
)
violations.append(
SchemaViolation(
instance_path=_json_pointer(error.absolute_path),
keyword=keyword,
)
)
return violations, truncated
def _json_pointer(path: Sequence[Any]) -> str:
if not path:
return ""
return "".join(
f"/{str(token).replace('~', '~0').replace('/', '~1')}"
for token in path
)