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]

Reply via email to