blob: f082f93a2e3ec4c74495f22af93c2571248de42b [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.
"""JSON Schema 2020-12 definition and resource-boundary tests."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from mcp.types import Tool
from doris_mcp_server.schema_validation import (
JSON_SCHEMA_2020_12,
SchemaLimits,
ToolArgumentsValidationError,
ToolOutputValidationError,
ToolSchemaDefinitionError,
ToolSchemaGuard,
)
def _advanced_tool(
*,
input_schema: dict | None = None,
output_schema: dict | None = None,
) -> Tool:
return Tool(
name="select_rows",
description="Select rows by identifier or name.",
input_schema=input_schema
or {
"$schema": JSON_SCHEMA_2020_12,
"type": "object",
"$defs": {
"selector": {
"oneOf": [
{
"type": "object",
"properties": {"id": {"type": "integer"}},
"required": ["id"],
"additionalProperties": False,
},
{
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
}
},
"required": ["name"],
"additionalProperties": False,
},
]
}
},
"properties": {
"selector": {"$ref": "#/$defs/selector"},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
},
},
"required": ["selector"],
"additionalProperties": False,
},
output_schema=output_schema
or {
"$schema": JSON_SCHEMA_2020_12,
"type": "array",
"items": {
"type": "object",
"properties": {"id": {"type": "integer"}},
"required": ["id"],
"additionalProperties": False,
},
},
)
def test_full_2020_12_schema_validates_inputs_and_outputs_without_value_echo():
compiled = ToolSchemaGuard().compile_tool(_advanced_tool())
compiled.validate_arguments({"selector": {"id": 7}, "limit": 10})
compiled.validate_arguments({"selector": {"name": "orders"}})
compiled.validate_output([{"id": 7}])
with pytest.raises(ToolArgumentsValidationError) as invalid:
compiled.validate_arguments(
{
"selector": {"name": ""},
"limit": 101,
"secret": "must-not-echo",
}
)
assert invalid.value.violations
assert all(
set(violation.as_dict()) == {"instancePath", "keyword"}
for violation in invalid.value.violations
)
assert "must-not-echo" not in repr(
[violation.as_dict() for violation in invalid.value.violations]
)
with pytest.raises(ToolOutputValidationError):
compiled.validate_output([{"id": "not-an-integer"}])
@pytest.mark.parametrize(
"schema, expected",
[
({"properties": {}}, 'type: "object"'),
(
{
"$schema": "https://json-schema.org/draft/2019-09/schema",
"type": "object",
},
"only the JSON Schema 2020-12 dialect",
),
(
{
"type": "object",
"required": "not-an-array",
},
"not valid JSON Schema 2020-12",
),
(
{
"type": "object",
"properties": {"value": {"$ref": "#/$defs/missing"}},
},
"missing JSON Pointer",
),
],
)
def test_invalid_server_owned_schema_is_rejected(
schema: dict,
expected: str,
):
with pytest.raises(ToolSchemaDefinitionError, match=expected):
ToolSchemaGuard().compile_tool(_advanced_tool(input_schema=schema))
@pytest.mark.parametrize(
("keyword", "reference"),
[
("$ref", "https://example.invalid/schema.json"),
("$ref", "file:///etc/passwd"),
("$ref", "other-schema.json#/$defs/value"),
("$dynamicRef", "https://example.invalid/schema.json#value"),
],
)
def test_external_references_are_rejected_before_network_access(
keyword: str,
reference: str,
):
schema = {
"type": "object",
"properties": {
"value": {
keyword: reference,
}
},
}
with (
patch("urllib.request.urlopen") as urlopen,
pytest.raises(ToolSchemaDefinitionError, match="same-document"),
):
ToolSchemaGuard().compile_tool(_advanced_tool(input_schema=schema))
urlopen.assert_not_called()
def test_recursive_local_reference_is_rejected_by_bounded_policy():
schema = {
"type": "object",
"$defs": {
"node": {
"type": "object",
"properties": {
"next": {"$ref": "#/$defs/node"},
},
}
},
"properties": {
"root": {"$ref": "#/$defs/node"},
},
}
with pytest.raises(ToolSchemaDefinitionError, match="recursive local"):
ToolSchemaGuard().compile_tool(_advanced_tool(input_schema=schema))
def test_schema_and_instance_complexity_limits_fail_closed():
branch_limited = ToolSchemaGuard(
SchemaLimits(max_combinator_branches=1)
)
with pytest.raises(ToolSchemaDefinitionError, match="combinator branches"):
branch_limited.compile_tool(_advanced_tool())
instance_limited = ToolSchemaGuard(
SchemaLimits(
max_instance_bytes=64,
max_instance_nodes=8,
max_instance_depth=3,
max_instance_string_length=8,
)
).compile_tool(
Tool(
name="bounded",
input_schema={
"type": "object",
"properties": {
"value": {"type": "string"},
},
},
)
)
with pytest.raises(ToolArgumentsValidationError) as oversized:
instance_limited.validate_arguments({"value": "x" * 9})
assert oversized.value.violations[0].keyword == "maxStringLength"
def test_output_schema_requires_structured_content():
compiled = ToolSchemaGuard().compile_tool(_advanced_tool())
with pytest.raises(ToolOutputValidationError, match="no structured content"):
compiled.validate_output(None)