This is an automated email from the ASF dual-hosted git repository.
FreeOnePlus pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git
The following commit(s) were added to refs/heads/master by this push:
new 537eeb9 feat: validate bounded tool schemas (#143)
537eeb9 is described below
commit 537eeb94752fc1d429afc02e8df35c581c5b075e
Author: Yijia Su <[email protected]>
AuthorDate: Thu Jul 30 10:53:37 2026 +0800
feat: validate bounded tool schemas (#143)
---
CHANGELOG.md | 2 +
README.md | 23 +
doris_mcp_server/protocol.py | 69 ++-
doris_mcp_server/schema_validation.py | 577 +++++++++++++++++++++++++
pyproject.toml | 2 +
test/integration/test_real_doris_transports.py | 12 +
test/protocol/schema_validation_server.py | 170 ++++++++
test/protocol/test_mcp_v2_protocol.py | 161 +++++++
test/protocol/test_schema_validation.py | 240 ++++++++++
test/tools/test_tools_manager.py | 14 +
uv.lock | 16 +
11 files changed, 1281 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d43ae69..65dfb8c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -52,6 +52,8 @@ under **Unreleased** until a new version is selected and
published.
- Recorded `subscriptions/listen` as intentionally unavailable until a real
cross-worker Doris change-event source exists, with discovery kept
capability-honest.
+- Added bounded JSON Schema 2020-12 validation for tool definitions, arguments,
+ and declared structured outputs without external reference fetching.
- Positioned Dynamic Client Registration as a compatibility fallback behind
preconfigured clients and Client ID Metadata Documents.
- Persisted static bearer tokens as self-describing SHA-256/SHA-512 digests,
diff --git a/README.md b/README.md
index 3d82c4c..6d401a2 100644
--- a/README.md
+++ b/README.md
@@ -575,6 +575,29 @@ enforce this boundary. See
[the subscription decision
record](docs/decisions/0001-subscriptions-require-change-events.md)
for the conditions required before the capability can be enabled.
+### Tool JSON Schema Validation
+
+Tool `inputSchema` and `outputSchema` use JSON Schema 2020-12. The server
+validates every visible tool definition before advertising or executing it,
+then validates each call's arguments before invoking Doris. Invalid arguments
+return `Invalid Params` without echoing rejected values. Successful structured
+results are also checked whenever a tool declares `outputSchema`; a server-side
+schema mismatch is hidden behind `Internal error`.
+
+Schemas are self-contained. Only same-document `$ref` and `$dynamicRef`
+fragments are accepted; the server never fetches a schema over HTTP, from a
+file URI, or from a relative URI. Recursive references are rejected by the
+bounded validation policy. The default hard limits per schema are 64 KiB,
+2,048 nodes, depth 32, 64 composition branches, and 64 references. Each input
+or structured output is limited to 1 MiB, 10,000 nodes, depth 32, and 262,144
+characters per string. At most 16 validation violations are reported, and
+reports contain only instance paths and failed keywords.
+
+These checks run in the shared protocol handler, so Streamable HTTP, modern
+stdio, and legacy stdio use the same enforcement. MCP 2026-07-28 clients can
+also receive array, string, number, or boolean `structuredContent` when a
+matching `outputSchema` is declared.
+
### Migrating from MCP 2025-11-25
1. Upgrade the client to a `2026-07-28`-capable MCP SDK.
diff --git a/doris_mcp_server/protocol.py b/doris_mcp_server/protocol.py
index 082778d..9eccec9 100644
--- a/doris_mcp_server/protocol.py
+++ b/doris_mcp_server/protocol.py
@@ -61,6 +61,13 @@ from .pagination import (
PaginationPage,
paginate,
)
+from .schema_validation import (
+ DEFAULT_SCHEMA_LIMITS,
+ SchemaLimits,
+ ToolArgumentsValidationError,
+ ToolOutputValidationError,
+ ToolSchemaGuard,
+)
from .utils.redaction import (
redact_error_payload,
redact_sensitive_text,
@@ -147,9 +154,7 @@ def _decode_structured_tool_result(payload: str) ->
tuple[Any | None, bool]:
except (TypeError, json.JSONDecodeError):
return None, False
- if not isinstance(decoded, dict):
- return None, False
- return decoded, "error" in decoded
+ return decoded, isinstance(decoded, dict) and "error" in decoded
def _sanitize_manager_error_payload(
@@ -221,6 +226,24 @@ def _paginate_list_or_raise(
) from exc
+def _tools_for_protocol(
+ tools: Sequence[Tool],
+ protocol_version: str,
+) -> list[Tool]:
+ """Hide non-object output schemas from protocol eras that cannot encode
them."""
+ if protocol_version == LATEST_PROTOCOL_VERSION:
+ return list(tools)
+ return [
+ (
+ tool.model_copy(update={"output_schema": None})
+ if tool.output_schema is not None
+ and tool.output_schema.get("type") != "object"
+ else tool
+ )
+ for tool in tools
+ ]
+
+
def create_doris_mcp_server(
*,
resources_manager: ResourcesManager,
@@ -230,12 +253,14 @@ def create_doris_mcp_server(
version: str,
logger: logging.Logger,
list_page_size: int = DEFAULT_LIST_PAGE_SIZE,
+ schema_limits: SchemaLimits = DEFAULT_SCHEMA_LIMITS,
required_client_capabilities: Mapping[str, ClientCapabilities] | None =
None,
required_tool_capabilities: Mapping[str, ClientCapabilities] | None = None,
) -> Server:
"""Create the one low-level SDK v2 server used by every transport."""
if not 1 <= list_page_size <= MAX_LIST_PAGE_SIZE:
raise ValueError(f"list_page_size must be in the range
1-{MAX_LIST_PAGE_SIZE}")
+ schema_guard = ToolSchemaGuard(schema_limits)
async def list_resources(
ctx: ServerRequestContext,
@@ -294,9 +319,10 @@ def create_doris_mcp_server(
ctx: ServerRequestContext,
params: PaginatedRequestParams | None,
) -> ListToolsResult:
- del ctx
authorize_operation(get_current_auth_context(), "list_tools")
tools = await tools_manager.list_tools()
+ schema_guard.compile_catalog(tools)
+ tools = _tools_for_protocol(tools, ctx.protocol_version)
page = _paginate_list_or_raise(
tools,
collection="tools",
@@ -314,8 +340,27 @@ def create_doris_mcp_server(
ctx: ServerRequestContext,
params: CallToolRequestParams,
) -> CallToolResult:
- del ctx
arguments = params.arguments or {}
+ tools = await tools_manager.list_tools()
+ compiled_schema = schema_guard.compile_catalog(tools).get(params.name)
+ if compiled_schema is not None:
+ try:
+ compiled_schema.validate_arguments(arguments)
+ except ToolArgumentsValidationError as exc:
+ data: dict[str, Any] = {
+ "name": redact_sensitive_text(params.name),
+ "violations": [
+ violation.as_dict()
+ for violation in exc.violations
+ ],
+ }
+ if exc.truncated:
+ data["truncated"] = True
+ raise MCPError(
+ code=INVALID_PARAMS,
+ message="Tool arguments do not match input schema",
+ data=data,
+ ) from exc
try:
payload = await tools_manager.call_tool(params.name, arguments)
except OperationAuthorizationError:
@@ -331,6 +376,20 @@ def create_doris_mcp_server(
payload, structured_content, is_error =
_sanitize_manager_error_payload(
payload
)
+ if compiled_schema is not None and not is_error:
+ try:
+ compiled_schema.validate_output(structured_content)
+ except ToolOutputValidationError:
+ logger.exception(
+ "Tool %s returned structured content that violates
outputSchema",
+ params.name,
+ )
+ raise
+ if (
+ ctx.protocol_version != LATEST_PROTOCOL_VERSION
+ and not isinstance(structured_content, dict)
+ ):
+ structured_content = None
return CallToolResult(
content=[TextContent(type="text", text=payload)],
structured_content=structured_content,
diff --git a/doris_mcp_server/schema_validation.py
b/doris_mcp_server/schema_validation.py
new file mode 100644
index 0000000..5359b70
--- /dev/null
+++ b/doris_mcp_server/schema_validation.py
@@ -0,0 +1,577 @@
+# 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
+ )
diff --git a/pyproject.toml b/pyproject.toml
index 2a1193f..a739c6c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -64,6 +64,7 @@ dependencies = [
# Configuration and serialization
"pydantic>=2.5.0",
"pydantic-settings>=2.1.0",
+ "jsonschema>=4.23.0,<5.0.0",
"toml>=0.10.0",
"PyYAML>=6.0.0",
"python-dotenv>=1.0.0",
@@ -304,4 +305,5 @@ dev = [
"mypy>=1.16.0",
"pandas-stubs>=2.2.0",
"ruff>=0.11.13",
+ "types-jsonschema>=4.23.0",
]
diff --git a/test/integration/test_real_doris_transports.py
b/test/integration/test_real_doris_transports.py
index 0db2d2b..76d1157 100644
--- a/test/integration/test_real_doris_transports.py
+++ b/test/integration/test_real_doris_transports.py
@@ -480,6 +480,18 @@ async def
test_real_doris_protocol_lists_paginate_without_loss(
assert unsupported.value.code == -32601
assert unsupported.value.message == "Method not found"
+ with pytest.raises(MCPError) as invalid_arguments:
+ await client.call_tool(
+ "exec_query",
+ {"sql": ["SELECT 1"]},
+ )
+ assert invalid_arguments.value.code == -32602
+ assert invalid_arguments.value.message == (
+ "Tool arguments do not match input schema"
+ )
+ assert invalid_arguments.value.data["name"] == "exec_query"
+ assert invalid_arguments.value.data["violations"]
+
for list_method, result_field, identifier in (
(
client.list_resources,
diff --git a/test/protocol/schema_validation_server.py
b/test/protocol/schema_validation_server.py
new file mode 100644
index 0000000..5a93ca6
--- /dev/null
+++ b/test/protocol/schema_validation_server.py
@@ -0,0 +1,170 @@
+# 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.
+"""Standalone stdio fixture for bounded JSON Schema validation."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+from typing import Any
+
+from mcp.server.stdio import stdio_server
+from mcp.types import GetPromptResult, Prompt, Resource, Tool
+
+from doris_mcp_server import __version__
+from doris_mcp_server.protocol import create_doris_mcp_server
+from doris_mcp_server.schema_validation import JSON_SCHEMA_2020_12
+
+SCHEMA_GUARD_INPUT = {
+ "$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"},
+ },
+ "required": ["selector"],
+ "additionalProperties": False,
+}
+BOOLEAN_OUTPUT = {
+ "$schema": JSON_SCHEMA_2020_12,
+ "type": "object",
+ "properties": {"accepted": {"type": "boolean"}},
+ "required": ["accepted"],
+ "additionalProperties": False,
+}
+
+
+class EmptyResourcesManager:
+ async def list_resources(self) -> list[Resource]:
+ return []
+
+ async def read_resource(self, uri: str) -> str:
+ return json.dumps({"uri": uri})
+
+
+class SchemaToolsManager:
+ async def list_tools(self) -> list[Tool]:
+ return [
+ Tool(
+ name="schema_guard",
+ description="Exercise full JSON Schema 2020-12 input
validation.",
+ input_schema=SCHEMA_GUARD_INPUT,
+ output_schema=BOOLEAN_OUTPUT,
+ ),
+ Tool(
+ name="bad_output",
+ description="Exercise server output-schema enforcement.",
+ input_schema={
+ "type": "object",
+ "additionalProperties": False,
+ },
+ output_schema=BOOLEAN_OUTPUT,
+ ),
+ Tool(
+ name="array_output",
+ description="Exercise non-object 2026-07-28 structured
content.",
+ input_schema={
+ "type": "object",
+ "additionalProperties": False,
+ },
+ output_schema={
+ "$schema": JSON_SCHEMA_2020_12,
+ "type": "array",
+ "items": {"type": "integer"},
+ },
+ ),
+ Tool(
+ name="echo",
+ description="Verify process recovery after schema errors.",
+ input_schema={
+ "type": "object",
+ "properties": {"value": {"type": "string"}},
+ "required": ["value"],
+ "additionalProperties": False,
+ },
+ ),
+ ]
+
+ async def call_tool(self, name: str, arguments: dict[str, Any]) -> str:
+ if name == "schema_guard":
+ return json.dumps({"accepted": True})
+ if name == "bad_output":
+ return json.dumps({"accepted": "not-a-boolean"})
+ if name == "array_output":
+ return json.dumps([1, 2, 3])
+ return json.dumps({"value": arguments["value"]})
+
+
+class EmptyPromptsManager:
+ async def list_prompts(self) -> list[Prompt]:
+ return []
+
+ async def get_prompt(
+ self,
+ name: str,
+ arguments: dict[str, Any],
+ ) -> GetPromptResult:
+ del name, arguments
+ raise AssertionError("schema fixture has no prompts")
+
+
+def create_schema_validation_server():
+ return create_doris_mcp_server(
+ resources_manager=EmptyResourcesManager(),
+ tools_manager=SchemaToolsManager(),
+ prompts_manager=EmptyPromptsManager(),
+ name="doris-mcp-schema-validation-test",
+ version=__version__,
+ logger=logging.getLogger(__name__),
+ )
+
+
+async def main() -> None:
+ server = create_schema_validation_server()
+ async with stdio_server() as (read_stream, write_stream):
+ await server.run(
+ read_stream,
+ write_stream,
+ server.create_initialization_options(),
+ )
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/test/protocol/test_mcp_v2_protocol.py
b/test/protocol/test_mcp_v2_protocol.py
index e23212c..d6e0e1e 100644
--- a/test/protocol/test_mcp_v2_protocol.py
+++ b/test/protocol/test_mcp_v2_protocol.py
@@ -49,6 +49,7 @@ from doris_mcp_server.protocol import (
create_doris_mcp_server,
create_transport_security,
)
+from test.protocol.schema_validation_server import
create_schema_validation_server
from test.protocol.stdio_capability_server import OneToolManager as
ProfileToolManager
REQUIRED_EXTENSION = "io.apache.doris/read"
@@ -499,6 +500,102 @@ async def
test_http_does_not_advertise_or_serve_subscriptions_without_change_sou
assert recovered.json()["result"]["resultType"] == "complete"
[email protected]
+async def test_http_enforces_bounded_2020_12_tool_schemas_and_recovers():
+ server = create_schema_validation_server()
+ app = server.streamable_http_app(
+ json_response=True,
+ stateless_http=True,
+ host="127.0.0.1",
+ transport_security=create_transport_security("127.0.0.1"),
+ )
+
+ async with (
+ app.router.lifespan_context(app),
+ httpx2.ASGITransport(app) as transport,
+ httpx2.AsyncClient(
+ transport=transport,
+ base_url="http://127.0.0.1:3000",
+ ) as client,
+ ):
+ listed = await client.post(
+ "/mcp",
+ json=modern_request(1, "tools/list"),
+ headers=modern_headers("tools/list"),
+ )
+ assert listed.status_code == 200
+ tools = {
+ tool["name"]: tool
+ for tool in listed.json()["result"]["tools"]
+ }
+ assert tools["schema_guard"]["inputSchema"]["$schema"].endswith(
+ "/2020-12/schema"
+ )
+ assert "oneOf" in
tools["schema_guard"]["inputSchema"]["$defs"]["selector"]
+
+ secret = "must-not-echo-schema-secret"
+ rejected = await client.post(
+ "/mcp",
+ json=modern_tool_request(
+ 2,
+ "schema_guard",
+ {"selector": {"id": secret}},
+ ),
+ headers=modern_tool_headers("schema_guard"),
+ )
+ assert rejected.status_code == 400
+ assert rejected.json()["error"]["code"] == -32602
+ assert rejected.json()["error"]["message"] == (
+ "Tool arguments do not match input schema"
+ )
+ assert secret not in rejected.text
+ assert rejected.json()["error"]["data"]["violations"]
+
+ accepted = await client.post(
+ "/mcp",
+ json=modern_tool_request(
+ 3,
+ "schema_guard",
+ {"selector": {"id": 7}},
+ ),
+ headers=modern_tool_headers("schema_guard"),
+ )
+ assert accepted.status_code == 200
+ assert accepted.json()["result"]["structuredContent"] == {
+ "accepted": True
+ }
+
+ array_output = await client.post(
+ "/mcp",
+ json=modern_tool_request(4, "array_output", {}),
+ headers=modern_tool_headers("array_output"),
+ )
+ assert array_output.status_code == 200
+ assert array_output.json()["result"]["structuredContent"] == [1, 2, 3]
+
+ bad_output = await client.post(
+ "/mcp",
+ json=modern_tool_request(5, "bad_output", {}),
+ headers=modern_tool_headers("bad_output"),
+ )
+ assert bad_output.status_code == 200
+ assert bad_output.json()["error"] == {
+ "code": -32603,
+ "message": "Internal server error",
+ }
+ assert "not-a-boolean" not in bad_output.text
+
+ recovered = await client.post(
+ "/mcp",
+ json=modern_tool_request(6, "echo", {"value": "alive"}),
+ headers=modern_tool_headers("echo"),
+ )
+ assert recovered.status_code == 200
+ assert recovered.json()["result"]["structuredContent"] == {
+ "value": "alive"
+ }
+
+
@pytest.mark.asyncio
async def test_http_rejects_untrusted_origin_and_legacy_adapter_is_stateless():
app = DorisMCPHTTPTransport(
@@ -1146,6 +1243,70 @@ async def
test_true_subprocess_stdio_does_not_advertise_or_serve_subscriptions()
assert recovered.result_type == "complete"
[email protected]
+async def test_true_subprocess_stdio_enforces_tool_schemas_for_both_eras():
+ server_script = Path(__file__).with_name("schema_validation_server.py")
+ server_params = StdioServerParameters(
+ command=sys.executable,
+ args=[str(server_script)],
+ )
+
+ async with Client(stdio_client(server_params)) as modern:
+ tools = {
+ tool.name: tool
+ for tool in (await modern.list_tools(cache_mode="bypass")).tools
+ }
+ assert tools["schema_guard"].input_schema["$schema"].endswith(
+ "/2020-12/schema"
+ )
+ secret = "must-not-echo-stdio-schema-secret"
+ with pytest.raises(MCPError) as invalid:
+ await modern.call_tool(
+ "schema_guard",
+ {"selector": {"id": secret}},
+ )
+ assert invalid.value.code == -32602
+ assert invalid.value.message == "Tool arguments do not match input
schema"
+ assert secret not in repr(invalid.value.data)
+
+ accepted = await modern.call_tool(
+ "schema_guard",
+ {"selector": {"name": "orders"}},
+ )
+ assert accepted.structured_content == {"accepted": True}
+
+ array_output = await modern.call_tool("array_output", {})
+ assert array_output.structured_content == [1, 2, 3]
+
+ with pytest.raises(MCPError) as bad_output:
+ await modern.call_tool("bad_output", {})
+ assert bad_output.value.code == -32603
+ assert bad_output.value.message == "Internal server error"
+
+ recovered = await modern.call_tool("echo", {"value": "alive"})
+ assert recovered.structured_content == {"value": "alive"}
+
+ async with Client(stdio_client(server_params), mode="legacy") as legacy:
+ legacy_tools = {
+ tool.name: tool
+ for tool in (await legacy.list_tools()).tools
+ }
+ assert legacy_tools["array_output"].output_schema is None
+
+ with pytest.raises(MCPError) as invalid:
+ await legacy.call_tool(
+ "schema_guard",
+ {"selector": {"id": "wrong-type"}},
+ )
+ assert invalid.value.code == -32602
+
+ accepted = await legacy.call_tool(
+ "schema_guard",
+ {"selector": {"id": 9}},
+ )
+ assert accepted.structured_content == {"accepted": True}
+
+
@pytest.mark.asyncio
async def test_stdio_enforces_tool_specific_client_capabilities_and_recovers():
server_script = Path(__file__).with_name("conformance_server.py")
diff --git a/test/protocol/test_schema_validation.py
b/test/protocol/test_schema_validation.py
new file mode 100644
index 0000000..f082f93
--- /dev/null
+++ b/test/protocol/test_schema_validation.py
@@ -0,0 +1,240 @@
+# 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"}])
+
+
[email protected](
+ "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))
+
+
[email protected](
+ ("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)
diff --git a/test/tools/test_tools_manager.py b/test/tools/test_tools_manager.py
index aedbd28..ca01dd7 100644
--- a/test/tools/test_tools_manager.py
+++ b/test/tools/test_tools_manager.py
@@ -24,6 +24,7 @@ from unittest.mock import AsyncMock, Mock, patch
import pytest
+from doris_mcp_server.schema_validation import ToolSchemaGuard
from doris_mcp_server.tools.tools_manager import DorisToolsManager
from doris_mcp_server.utils.config import DorisConfig
@@ -65,6 +66,9 @@ class TestDorisToolsManager:
# Create a proper mock connection manager
mock_connection_manager = Mock()
mock_connection_manager.get_connection = AsyncMock()
+ mock_connection_manager.config.adbc.default_max_rows = 1000
+ mock_connection_manager.config.adbc.default_timeout = 30
+ mock_connection_manager.config.adbc.default_return_format = "dict"
return DorisToolsManager(mock_connection_manager)
@pytest.mark.asyncio
@@ -270,3 +274,13 @@ class TestDorisToolsManager:
# Required fields should be defined
if 'required' in tool.input_schema:
assert isinstance(tool.input_schema['required'], list)
+
+ @pytest.mark.asyncio
+ async def test_all_production_tool_schemas_compile_as_bounded_2020_12(
+ self,
+ tools_manager,
+ ):
+ tools = await tools_manager.list_tools()
+ compiled = ToolSchemaGuard().compile_catalog(tools)
+
+ assert set(compiled) == {tool.name for tool in tools}
diff --git a/uv.lock b/uv.lock
index 415c1dc..3ee19f8 100644
--- a/uv.lock
+++ b/uv.lock
@@ -616,6 +616,7 @@ dependencies = [
{ name = "fastapi", version = "0.140.13", source = { registry =
"https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" },
{ name = "filelock" },
{ name = "httpx" },
+ { name = "jsonschema" },
{ name = "mcp" },
{ name = "numpy" },
{ name = "orjson" },
@@ -693,6 +694,7 @@ dev = [
{ name = "mypy" },
{ name = "pandas-stubs" },
{ name = "ruff" },
+ { name = "types-jsonschema" },
]
[package.metadata]
@@ -717,6 +719,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.26.0" },
{ name = "isort", marker = "extra == 'dev'", specifier = ">=5.13.0" },
{ name = "jaeger-client", marker = "extra == 'monitoring'", specifier =
">=4.8.0" },
+ { name = "jsonschema", specifier = ">=4.23.0,<5.0.0" },
{ name = "mcp", specifier = ">=2.0.0,<2.1.0" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" },
{ name = "myst-parser", marker = "extra == 'dev'", specifier = ">=2.0.0" },
@@ -777,6 +780,7 @@ dev = [
{ name = "mypy", specifier = ">=1.16.0" },
{ name = "pandas-stubs", specifier = ">=2.2.0" },
{ name = "ruff", specifier = ">=0.11.13" },
+ { name = "types-jsonschema", specifier = ">=4.23.0" },
]
[[package]]
@@ -2950,6 +2954,18 @@ wheels = [
{ url =
"https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl",
hash =
"sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size
= 46317, upload-time = "2025-05-26T14:30:30.523Z" },
]
+[[package]]
+name = "types-jsonschema"
+version = "4.26.0.20260518"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "referencing" },
+]
+sdist = { url =
"https://files.pythonhosted.org/packages/dd/46/73b6a5d61a61015c4248030a8cb07e5bdddb4041430fae9e585a68692578/types_jsonschema-4.26.0.20260518.tar.gz",
hash =
"sha256:e1dd53dc97a64f5eccdd6fa9839666e09bb500a8ebba2db6fdaf1789faea81a6", size
= 16638, upload-time = "2026-05-18T06:06:44.106Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/07/d5/134f8a147dcecda10db7f60cfc6af0578a25a5c53c87b3907a64385e0184/types_jsonschema-4.26.0.20260518-py3-none-any.whl",
hash =
"sha256:30b30a518c7fe335df85c919fcbcc631b69c03d4a4b5b632fa916bea03065307", size
= 16072, upload-time = "2026-05-18T06:06:43.264Z" },
+]
+
[[package]]
name = "typing-extensions"
version = "4.16.0"
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]