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 47f1664  feat: complete Catalog domain context and metadata (#171)
47f1664 is described below

commit 47f1664cb29437a0362e80cf70ab248f71eb446a
Author: Yijia Su <[email protected]>
AuthorDate: Fri Jul 31 12:43:56 2026 +0800

    feat: complete Catalog domain context and metadata (#171)
---
 README.md                                      |  18 +
 doris_mcp_server/state_handles.py              |   5 +-
 doris_mcp_server/tools/capability_detector.py  |  17 +
 doris_mcp_server/tools/capability_registry.py  |   9 +-
 doris_mcp_server/tools/catalog_handlers.py     | 217 ++++++++
 doris_mcp_server/tools/domain_catalog.py       |  34 +-
 doris_mcp_server/tools/domain_dispatcher.py    | 409 +++++++++++++--
 doris_mcp_server/tools/doris_feature_matrix.py |   2 +-
 doris_mcp_server/tools/tools_manager.py        | 142 ++----
 doris_mcp_server/utils/catalog_metadata.py     | 661 +++++++++++++++++++++++++
 test/protocol/test_state_handles.py            |  14 +
 test/tools/test_capability_detector.py         |  24 +
 test/tools/test_capability_registry.py         |  38 ++
 test/tools/test_domain_catalog.py              |  11 +
 test/tools/test_domain_dispatcher.py           | 397 ++++++++++++++-
 test/tools/test_doris_feature_matrix.py        |  18 +
 test/tools/test_tools_manager.py               | 120 +++++
 test/utils/test_catalog_metadata.py            | 369 ++++++++++++++
 18 files changed, 2325 insertions(+), 180 deletions(-)

diff --git a/README.md b/README.md
index 050e98c..b7e9a79 100644
--- a/README.md
+++ b/README.md
@@ -772,6 +772,24 @@ The Doris MCP Server supports **catalog federation**, 
enabling interaction with
 *   **Cross-Catalog SQL Queries**: Use
     `doris_query.execute_query` with three-part table naming.
 *   **Catalog Discovery**: Use `doris_catalog.list_catalogs`.
+*   **Typed Object Discovery**: `list_tables` distinguishes tables, views, and
+    materialized views. Its `page` value is an opaque, expiring continuation
+    handle bound to the exact request and authorization principal.
+*   **Deterministic Table Context**: `get_table_context` always reads `schema`
+    first and can add `comments`, `indexes`, and `basic`. Every returned 
section
+    contains `status`, `data`, `warnings`, and `source`; an unsupported 
optional
+    section produces a partial result instead of discarding the available
+    schema.
+*   **Bounded Size Metadata**: `get_table_size` reads table row and byte
+    statistics and optionally partition statistics. If partition metadata is
+    unavailable, the table-level result remains available with
+    `status: "partial"`.
+
+Catalog execution failures use the normal child error envelope. The
+`error.details.reason_code` value distinguishes
+`CATALOG_OBJECT_NOT_FOUND`, `CATALOG_PERMISSION_DENIED`,
+`CATALOG_SECTION_UNSUPPORTED`, and `CATALOG_BACKEND_UNAVAILABLE` without
+returning raw backend error text.
 
 #### Three-Part Naming Requirement:
 
diff --git a/doris_mcp_server/state_handles.py 
b/doris_mcp_server/state_handles.py
index 938daae..4001374 100644
--- a/doris_mcp_server/state_handles.py
+++ b/doris_mcp_server/state_handles.py
@@ -137,13 +137,16 @@ def _encode_base64url(value: bytes) -> str:
 def _decode_base64url(value: str) -> bytes:
     try:
         padding = "=" * (-len(value) % 4)
-        return base64.b64decode(
+        decoded = base64.b64decode(
             (value + padding).encode("ascii"),
             altchars=b"-_",
             validate=True,
         )
     except (UnicodeEncodeError, binascii.Error) as exc:
         raise StateHandleError("invalid") from exc
+    if _encode_base64url(decoded) != value:
+        raise StateHandleError("invalid")
+    return decoded
 
 
 def _validate_claim(name: str, value: str) -> None:
diff --git a/doris_mcp_server/tools/capability_detector.py 
b/doris_mcp_server/tools/capability_detector.py
index 007d1b8..8c3dd25 100644
--- a/doris_mcp_server/tools/capability_detector.py
+++ b/doris_mcp_server/tools/capability_detector.py
@@ -149,6 +149,23 @@ _DOMAIN_PROBES: Mapping[str, tuple[tuple[str, tuple[str, 
...]], ...]] = {
             ),
             ("table_metadata_readable",),
         ),
+        (
+            (
+                "SELECT COLUMN_NAME, DATA_TYPE "
+                "FROM information_schema.columns LIMIT 1"
+            ),
+            (
+                "information_schema.columns",
+                "table_context_sections_readable",
+            ),
+        ),
+        (
+            (
+                "SELECT PARTITION_NAME, TABLE_ROWS, DATA_LENGTH "
+                "FROM information_schema.partitions LIMIT 1"
+            ),
+            ("table_partition_statistics_readable",),
+        ),
     ),
     "doris_query": (
         (
diff --git a/doris_mcp_server/tools/capability_registry.py 
b/doris_mcp_server/tools/capability_registry.py
index fc6cb75..80104a9 100644
--- a/doris_mcp_server/tools/capability_registry.py
+++ b/doris_mcp_server/tools/capability_registry.py
@@ -21,6 +21,7 @@ from __future__ import annotations
 import asyncio
 import hashlib
 import json
+import re
 from collections.abc import Callable, Coroutine, Mapping, Sequence
 from dataclasses import dataclass
 from datetime import UTC, datetime, timedelta
@@ -418,7 +419,7 @@ class CapabilityEvaluator:
                     limitations=(),
                 )
             evidence_sources.extend(probe.evidence_sources)
-            evidence_sources.append(probe.probe_id)
+            evidence_sources.append(_public_evidence_source_id(probe.probe_id))
             if probe.status is CapabilityProbeStatus.MISCONFIGURED:
                 return _CandidateFailure(
                     status=AvailabilityStatus.MISCONFIGURED,
@@ -882,6 +883,12 @@ def _normalized_source_ids(values: tuple[str, ...]) -> 
tuple[str, ...]:
     return _ordered_unique([value.lower() for value in values])
 
 
+def _public_evidence_source_id(value: str) -> str:
+    """Normalize private probe keys into public Manifest identifiers."""
+    normalized = re.sub(r"[^a-z0-9_]+", "_", value.lower()).strip("_")
+    return normalized or "runtime_probe"
+
+
 def _select_failure(
     failures: list[_CandidateFailure],
 ) -> _CandidateFailure:
diff --git a/doris_mcp_server/tools/catalog_handlers.py 
b/doris_mcp_server/tools/catalog_handlers.py
new file mode 100644
index 0000000..248426c
--- /dev/null
+++ b/doris_mcp_server/tools/catalog_handlers.py
@@ -0,0 +1,217 @@
+# 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.
+
+"""Catalog handler routes shared by formal and hidden legacy bindings."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+
+from ..utils.analysis_tools import SQLAnalyzer
+from ..utils.catalog_metadata import (
+    CatalogMetadataFailure,
+    DorisCatalogMetadataReader,
+)
+from ..utils.data_quality_tools import DataQualityTools
+from ..utils.db import DorisConnectionManager
+from ..utils.schema_extractor import MetadataExtractor
+
+
+class CatalogToolHandlersMixin:
+    """Keep Catalog-domain routing outside the manager control plane."""
+
+    metadata_extractor: MetadataExtractor
+    catalog_metadata: DorisCatalogMetadataReader
+    sql_analyzer: SQLAnalyzer
+    data_quality_tools: DataQualityTools
+
+    def _initialize_catalog_handlers(
+        self,
+        connection_manager: DorisConnectionManager,
+    ) -> None:
+        self.catalog_metadata = DorisCatalogMetadataReader(connection_manager)
+
+    @staticmethod
+    def _required_string(arguments: dict[str, Any], name: str) -> str:
+        raise NotImplementedError
+
+    async def _get_table_schema_tool(
+        self,
+        arguments: dict[str, Any],
+    ) -> dict[str, Any]:
+        table_name = self._required_string(arguments, "table_name")
+        db_name = arguments.get("db_name")
+        catalog_name = arguments.get("catalog_name")
+        if arguments.get("_formal_catalog"):
+            return await self.catalog_metadata.get_table_schema(
+                table_name=table_name,
+                database_name=self._required_string(arguments, "db_name"),
+                catalog_name=catalog_name,
+            )
+        return await self.metadata_extractor.get_table_schema_for_mcp(
+            table_name,
+            db_name,
+            catalog_name,
+        )
+
+    async def _get_db_table_list_tool(
+        self,
+        arguments: dict[str, Any],
+    ) -> dict[str, Any]:
+        db_name = arguments.get("db_name")
+        catalog_name = arguments.get("catalog_name")
+        if arguments.get("_formal_catalog"):
+            return await self.catalog_metadata.list_tables(
+                database_name=self._required_string(arguments, "db_name"),
+                catalog_name=catalog_name,
+            )
+        return await self.metadata_extractor.get_db_table_list_for_mcp(
+            db_name,
+            catalog_name,
+        )
+
+    async def _get_db_list_tool(
+        self,
+        arguments: dict[str, Any],
+    ) -> dict[str, Any]:
+        catalog_name = arguments.get("catalog_name")
+        if arguments.get("_formal_catalog"):
+            return await self.catalog_metadata.list_databases(
+                catalog_name=catalog_name,
+            )
+        return await self.metadata_extractor.get_db_list_for_mcp(catalog_name)
+
+    async def _get_table_comment_tool(
+        self,
+        arguments: dict[str, Any],
+    ) -> dict[str, Any]:
+        table_name = self._required_string(arguments, "table_name")
+        db_name = arguments.get("db_name")
+        catalog_name = arguments.get("catalog_name")
+        if arguments.get("_formal_catalog"):
+            return await self.catalog_metadata.get_table_comment(
+                table_name=table_name,
+                database_name=self._required_string(arguments, "db_name"),
+                catalog_name=catalog_name,
+            )
+        return await self.metadata_extractor.get_table_comment_for_mcp(
+            table_name,
+            db_name,
+            catalog_name,
+        )
+
+    async def _get_table_column_comments_tool(
+        self,
+        arguments: dict[str, Any],
+    ) -> dict[str, Any]:
+        table_name = self._required_string(arguments, "table_name")
+        db_name = arguments.get("db_name")
+        catalog_name = arguments.get("catalog_name")
+        if arguments.get("_formal_catalog"):
+            return await self.catalog_metadata.get_column_comments(
+                table_name=table_name,
+                database_name=self._required_string(arguments, "db_name"),
+                catalog_name=catalog_name,
+            )
+        return await self.metadata_extractor.get_table_column_comments_for_mcp(
+            table_name,
+            db_name,
+            catalog_name,
+        )
+
+    async def _get_table_indexes_tool(
+        self,
+        arguments: dict[str, Any],
+    ) -> dict[str, Any]:
+        table_name = self._required_string(arguments, "table_name")
+        db_name = arguments.get("db_name")
+        catalog_name = arguments.get("catalog_name")
+        if arguments.get("_formal_catalog"):
+            return await self.catalog_metadata.get_table_indexes(
+                table_name=table_name,
+                database_name=self._required_string(arguments, "db_name"),
+                catalog_name=catalog_name,
+            )
+        return await self.metadata_extractor.get_table_indexes_for_mcp(
+            table_name,
+            db_name,
+            catalog_name,
+        )
+
+    async def _get_catalog_list_tool(
+        self,
+        arguments: dict[str, Any],
+    ) -> dict[str, Any]:
+        if arguments.get("_formal_catalog"):
+            return await self.catalog_metadata.list_catalogs()
+        return await self.metadata_extractor.get_catalog_list_for_mcp()
+
+    async def _get_table_data_size_tool(
+        self,
+        arguments: dict[str, Any],
+    ) -> dict[str, Any]:
+        db_name = arguments.get("db_name")
+        table_name = arguments.get("table_name")
+        single_replica = arguments.get("single_replica", False)
+        if arguments.get("_formal_catalog"):
+            return await self.catalog_metadata.get_table_size(
+                table_name=self._required_string(arguments, "table_name"),
+                database_name=self._required_string(arguments, "db_name"),
+                catalog_name=arguments.get("catalog_name"),
+                include_partitions=bool(
+                    arguments.get("include_partitions", False)
+                ),
+            )
+        return await self.sql_analyzer.get_table_data_size(
+            db_name,
+            table_name,
+            single_replica,
+        )
+
+    async def _get_table_basic_info_tool(
+        self,
+        arguments: dict[str, Any],
+    ) -> dict[str, Any]:
+        try:
+            table_name = self._required_string(arguments, "table_name")
+            catalog_name = arguments.get("catalog_name")
+            db_name = arguments.get("db_name")
+            if arguments.get("_formal_catalog"):
+                return await self.catalog_metadata.get_table_basic(
+                    table_name=table_name,
+                    database_name=self._required_string(
+                        arguments,
+                        "db_name",
+                    ),
+                    catalog_name=catalog_name,
+                )
+            return await self.data_quality_tools.get_table_basic_info(
+                table_name=table_name,
+                catalog_name=catalog_name,
+                db_name=db_name,
+            )
+        except CatalogMetadataFailure:
+            raise
+        except Exception as exc:
+            return {
+                "error": str(exc),
+                "analysis_type": "table_basic_info",
+                "timestamp": datetime.now().isoformat(),
+            }
+
+
+__all__ = ["CatalogToolHandlersMixin"]
diff --git a/doris_mcp_server/tools/domain_catalog.py 
b/doris_mcp_server/tools/domain_catalog.py
index 3c9f706..59b4993 100644
--- a/doris_mcp_server/tools/domain_catalog.py
+++ b/doris_mcp_server/tools/domain_catalog.py
@@ -596,14 +596,31 @@ _QUERY_OUTPUT = _result_schema(
         "additionalProperties": False,
     }
 )
+_TABLE_CONTEXT_SECTION_OUTPUT = {
+    "type": "object",
+    "properties": {
+        "status": {
+            "type": "string",
+            "enum": ["success", "partial", "unavailable"],
+        },
+        "data": {"type": "object", "additionalProperties": True},
+        "warnings": {
+            "type": "array",
+            "items": {"type": "string"},
+        },
+        "source": {"type": "string"},
+    },
+    "required": ["status", "data", "warnings", "source"],
+    "additionalProperties": False,
+}
 _TABLE_CONTEXT_OUTPUT = _result_schema(
     {
         "type": "object",
         "properties": {
-            "schema": {"type": "object", "additionalProperties": True},
-            "comments": {"type": "object", "additionalProperties": True},
-            "indexes": {"type": "object", "additionalProperties": True},
-            "basic": {"type": "object", "additionalProperties": True},
+            "schema": _TABLE_CONTEXT_SECTION_OUTPUT,
+            "comments": _TABLE_CONTEXT_SECTION_OUTPUT,
+            "indexes": _TABLE_CONTEXT_SECTION_OUTPUT,
+            "basic": _TABLE_CONTEXT_SECTION_OUTPUT,
         },
         "required": ["schema"],
         "additionalProperties": False,
@@ -829,7 +846,8 @@ DOMAIN_DEFINITIONS = (
                 "doris_catalog",
                 "list_tables",
                 "List tables",
-                "List visible tables, views, and materialized views.",
+                "List visible tables, views, and materialized views with "
+                "deterministic filtering and opaque continuation cursors.",
                 _input_schema(
                     {
                         "catalog": _string("Catalog name."),
@@ -850,7 +868,8 @@ DOMAIN_DEFINITIONS = (
                 "get_table_context",
                 "Get table context",
                 "Read schema, comments, indexes, and basic table information "
-                "through a deterministic four-section composition.",
+                "through a deterministic four-section composition. Each "
+                "section reports its own status, warnings, and source.",
                 _input_schema(
                     {
                         "catalog": _string("Catalog name."),
@@ -871,7 +890,8 @@ DOMAIN_DEFINITIONS = (
                 "doris_catalog",
                 "get_table_size",
                 "Get table size",
-                "Read table and optional partition size and row statistics.",
+                "Read table and optional partition size and row statistics. "
+                "Unsupported partition metadata returns a partial result.",
                 _input_schema(
                     {
                         "catalog": _string("Catalog name."),
diff --git a/doris_mcp_server/tools/domain_dispatcher.py 
b/doris_mcp_server/tools/domain_dispatcher.py
index a037452..7927efd 100644
--- a/doris_mcp_server/tools/domain_dispatcher.py
+++ b/doris_mcp_server/tools/domain_dispatcher.py
@@ -19,6 +19,8 @@
 from __future__ import annotations
 
 import fnmatch
+import hashlib
+import hmac
 import json
 import math
 import secrets
@@ -39,6 +41,8 @@ from ..schema_validation import (
     ToolOutputValidationError,
     ToolSchemaGuard,
 )
+from ..state_handles import StateHandleCodec, StateHandleError
+from ..utils.catalog_metadata import CatalogMetadataFailure
 from ..utils.logger import get_audit_logger, get_logger
 from . import domain_catalog as domain_catalog_module
 from .domain_catalog import (
@@ -99,6 +103,10 @@ class ChildArgumentsAdapterError(ValueError):
     """Raised when formal arguments cannot use the active handler adapter."""
 
 
+_COLLECTION_PAGE_SIZE = 100
+_TABLE_CONTEXT_SECTION_ORDER = ("schema", "comments", "indexes", "basic")
+
+
 class ChildHandlerOwner(Protocol):
     """Structural owner of the existing read-only Doris handler methods."""
 
@@ -264,6 +272,7 @@ class DomainDispatcher:
         *,
         catalog: DorisDomainCatalog | None = None,
         schema_guard: ToolSchemaGuard | None = None,
+        state_handle_codec: StateHandleCodec | None = None,
     ) -> None:
         catalog = catalog or domain_catalog_module.DORIS_DOMAIN_CATALOG
         catalog.validate_integrity()
@@ -271,6 +280,9 @@ class DomainDispatcher:
         self._manifest_service = manifest_service
         self._catalog = catalog
         self._schema_guard = schema_guard or ToolSchemaGuard()
+        self._state_handle_codec = state_handle_codec or StateHandleCodec(
+            secrets.token_urlsafe(32)
+        )
         self._flat_children: dict[str, tuple[str, ChildToolDefinition]] = {}
         self._schemas: dict[str, CompiledToolSchema] = {}
         self._bindings = _build_bindings(owner, catalog)
@@ -525,6 +537,7 @@ class DomainDispatcher:
                     raw,
                     arguments,
                     adapter_warnings,
+                    auth_context,
                 )
             else:
                 data = await self._call_table_context(
@@ -552,6 +565,33 @@ class DomainDispatcher:
                     "violations": violations,
                 },
             )
+        except CatalogMetadataFailure as exc:
+            self._audit(feature_id, arguments, "error", started)
+            if exc.reason_code == "CATALOG_ARGUMENT_INVALID":
+                return self._error(
+                    domain.name,
+                    DomainErrorCode.CHILD_ARGUMENTS_INVALID,
+                    str(exc),
+                    child_tool=child.name,
+                    manifest_version=manifest.manifest_version,
+                    details={
+                        "rediscover": False,
+                        "reason_code": exc.reason_code,
+                    },
+                )
+            return self._error(
+                domain.name,
+                DomainErrorCode.CHILD_EXECUTION_FAILED,
+                str(exc),
+                child_tool=child.name,
+                manifest_version=manifest.manifest_version,
+                retryable=exc.retryable,
+                details={
+                    "rediscover": False,
+                    "reason_code": exc.reason_code,
+                    "status_code": exc.status_code,
+                },
+            )
         except ToolOutputValidationError:
             logger.exception("Formal child output validation failed for %s", 
feature_id)
             self._audit(
@@ -631,7 +671,7 @@ class DomainDispatcher:
         if raw.get("success") is False or (
             "error" in raw and raw.get("success") is not True
         ):
-            raise RuntimeError("child handler reported failure")
+            raise _failure_from_legacy_response(raw)
         return raw, warnings
 
     async def _call_table_context(
@@ -656,6 +696,7 @@ class DomainDispatcher:
             "catalog_name": arguments.get("catalog"),
             "db_name": arguments.get("database"),
             "table_name": arguments.get("table"),
+            "_formal_catalog": True,
         }
         legacy_arguments = {
             key: value
@@ -663,32 +704,71 @@ class DomainDispatcher:
             if value is not None
         }
 
-        data: dict[str, Any] = {}
+        section_states: dict[str, dict[str, Any]] = {}
         evidence: list[dict[str, Any]] = []
         warnings: list[str] = []
-        comments: dict[str, Any] = {}
         for migration in migrations:
             section = cast(str, migration.target_section)
             if section not in effective_sections:
                 continue
             handler = getattr(self._owner, migration.legacy_handler_name)
-            raw = await handler(dict(legacy_arguments))
-            success = isinstance(raw, dict) and not (
-                raw.get("success") is False
-                or ("error" in raw and raw.get("success") is not True)
+            state = section_states.setdefault(
+                section,
+                {
+                    "data": {},
+                    "sources": [],
+                    "warnings": [],
+                    "successes": 0,
+                    "failures": 0,
+                },
             )
+            failure: CatalogMetadataFailure | None = None
+            try:
+                raw = await handler(dict(legacy_arguments))
+                if not isinstance(raw, dict):
+                    raise TypeError("child handler must return an object")
+                if raw.get("success") is False or (
+                    "error" in raw and raw.get("success") is not True
+                ):
+                    failure = _failure_from_legacy_response(raw)
+            except CatalogMetadataFailure as exc:
+                raw = {}
+                failure = exc
+            except Exception:
+                raw = {}
+                failure = CatalogMetadataFailure(
+                    "Doris catalog metadata execution failed.",
+                    reason_code="CATALOG_EXECUTION_FAILED",
+                    status_code=502,
+                )
+
+            success = failure is None
             evidence.append(
                 {
                     "section": section,
                     "handler": migration.legacy_handler_name,
                     "success": success,
+                    "reason_code": (
+                        None if failure is None else failure.reason_code
+                    ),
                 }
             )
-            if not success:
+            state["sources"].append(migration.legacy_handler_name)
+            if failure is not None:
                 if section == "schema":
-                    raise RuntimeError("required schema section failed")
-                warnings.append(f"Optional {section} section is unavailable.")
+                    raise failure
+                state["failures"] += 1
+                warning = (
+                    f"Optional {section} section is unavailable "
+                    f"({failure.reason_code})."
+                )
+                state["warnings"].append(warning)
+                warnings.append(warning)
                 continue
+            state["successes"] += 1
+            handler_warnings = _legacy_warnings(raw)
+            state["warnings"].extend(handler_warnings)
+            warnings.extend(handler_warnings)
             value = _unwrap_legacy_result(raw)
             if section == "comments":
                 comment_key = (
@@ -697,18 +777,53 @@ class DomainDispatcher:
                     == "get_table_column_comments"
                     else "table"
                 )
-                comments[comment_key] = _as_object(value)
+                cast(dict[str, Any], state["data"])[comment_key] = _as_object(
+                    value
+                )
             else:
-                data[section] = _as_object(value)
-        if comments:
-            data["comments"] = comments
-        if "schema" not in data:
+                state["data"] = _as_object(value)
+
+        schema_state = section_states.get("schema")
+        if not schema_state or not schema_state["successes"]:
             raise RuntimeError("required schema section was not executed")
+        data: dict[str, Any] = {}
+        for section in _TABLE_CONTEXT_SECTION_ORDER:
+            if section not in effective_sections:
+                continue
+            section_state = section_states.get(section)
+            if section_state is None:
+                continue
+            section_warnings = list(
+                dict.fromkeys(section_state["warnings"])
+            )
+            if not section_state["successes"]:
+                status = "unavailable"
+            elif section_state["failures"] or section_warnings:
+                status = "partial"
+            else:
+                status = "success"
+            data[section] = {
+                "status": status,
+                "data": section_state["data"],
+                "warnings": section_warnings,
+                "source": ",".join(section_state["sources"]),
+            }
         return {
             "status": "partial" if warnings else "success",
             "data": data,
-            "warnings": warnings,
-            "metadata": {"sections": sorted(effective_sections)},
+            "warnings": list(dict.fromkeys(warnings)),
+            "metadata": {
+                "sections": [
+                    section
+                    for section in _TABLE_CONTEXT_SECTION_ORDER
+                    if section in effective_sections
+                ],
+                "requested_sections": [
+                    section
+                    for section in _TABLE_CONTEXT_SECTION_ORDER
+                    if section in requested_sections
+                ],
+            },
             "evidence": evidence,
         }
 
@@ -718,6 +833,7 @@ class DomainDispatcher:
         raw: dict[str, Any],
         arguments: dict[str, Any],
         adapter_warnings: tuple[str, ...],
+        auth_context: Any | None = None,
     ) -> dict[str, Any]:
         output = cast(
             dict[str, Any],
@@ -732,6 +848,7 @@ class DomainDispatcher:
             data_schema.get("properties", {}),
         )
         warnings = list(adapter_warnings)
+        warnings.extend(_legacy_warnings(raw))
         metadata = _legacy_metadata(raw)
 
         if {"items", "next_cursor", "truncated"} <= set(data_properties):
@@ -748,19 +865,69 @@ class DomainDispatcher:
                     for item in items
                     if fnmatch.fnmatchcase(str(item.get("name", "")), pattern)
                 ]
-            if arguments.get("page") is not None:
-                warnings.append(
-                    "Pagination is not supported by the active handler."
-                )
-            if arguments.get("types") is not None:
-                warnings.append(
-                    "Object type filtering is not supported by the active 
handler."
+            if child.name == "list_catalogs" and not arguments.get(
+                "include_external",
+                True,
+            ):
+                items = [
+                    item
+                    for item in items
+                    if item.get("scope") == "internal"
+                    or item.get("name") == "internal"
+                ]
+            requested_types = arguments.get("types")
+            if isinstance(requested_types, list):
+                allowed_types = {
+                    str(item) for item in requested_types
+                }
+                items = [
+                    item
+                    for item in items
+                    if str(item.get("type", "table")) in allowed_types
+                ]
+            items.sort(
+                key=lambda item: (
+                    str(item.get("name", "")).casefold(),
+                    str(item.get("type", "")).casefold(),
                 )
+            )
+            total_items = len(items)
+            scope = _collection_cursor_scope(child, arguments)
+            snapshot = _collection_snapshot(items)
+            offset = _decode_collection_cursor(
+                arguments.get("page"),
+                expected_scope=scope,
+                expected_snapshot=snapshot,
+                child=child,
+                auth_context=auth_context,
+                handle_codec=self._state_handle_codec,
+            )
+            page_items = items[offset : offset + _COLLECTION_PAGE_SIZE]
+            next_offset = offset + len(page_items)
+            truncated = next_offset < total_items
             child_data: dict[str, Any] = {
-                "items": items,
-                "next_cursor": None,
-                "truncated": False,
+                "items": page_items,
+                "next_cursor": (
+                    _encode_collection_cursor(
+                        next_offset,
+                        scope,
+                        snapshot,
+                        child=child,
+                        auth_context=auth_context,
+                        handle_codec=self._state_handle_codec,
+                    )
+                    if truncated
+                    else None
+                ),
+                "truncated": truncated,
             }
+            metadata.update(
+                {
+                    "page_size": _COLLECTION_PAGE_SIZE,
+                    "returned_items": len(page_items),
+                    "total_visible_items": total_items,
+                }
+            )
         elif {"columns", "rows", "row_count", "truncated"} <= set(
             data_properties
         ):
@@ -886,8 +1053,9 @@ def _adapt_arguments(
     elif adapter_name == "adapt:catalog_object_names":
         for unsupported in ("pattern", "types", "page"):
             prepared.pop(unsupported, None)
+        prepared["_formal_catalog"] = True
     elif adapter_name == "adapt:remove_random_string":
-        prepared = {}
+        prepared = {"_formal_catalog": True}
     elif adapter_name == "adapt:explain_arguments":
         level = prepared.pop("level", None)
         if level == "costs":
@@ -906,10 +1074,7 @@ def _adapt_arguments(
             )
     elif adapter_name == "adapt:table_size_arguments":
         prepared["single_replica"] = False
-        if prepared.pop("include_partitions", False):
-            warnings.append(
-                "Partition detail is not supported by the active handler."
-            )
+        prepared["_formal_catalog"] = True
     elif adapter_name == "adapt:monitoring_arguments":
         if prepared:
             warnings.append(
@@ -1038,6 +1203,186 @@ def _legacy_metadata(raw: Mapping[str, Any]) -> 
dict[str, Any]:
     return {}
 
 
+def _legacy_warnings(raw: Mapping[str, Any]) -> list[str]:
+    warnings = raw.get("warnings")
+    if not isinstance(warnings, list):
+        return []
+    return [str(item) for item in warnings]
+
+
+def _failure_from_legacy_response(
+    raw: Mapping[str, Any],
+) -> CatalogMetadataFailure:
+    raw_code = str(raw.get("error_code", "")).upper()
+    raw_status = raw.get("status_code")
+    status_code = int(raw_status) if isinstance(raw_status, int) else 500
+    message = str(raw.get("error", "")).lower()
+    if (
+        "NOT_VISIBLE" in raw_code
+        or "NOT_FOUND" in raw_code
+        or status_code == 404
+        or any(
+            marker in message
+            for marker in (
+                "does not exist",
+                "doesn't exist",
+                "not found",
+                "unknown table",
+                "unknown database",
+            )
+        )
+    ):
+        return CatalogMetadataFailure(
+            "The requested Doris metadata object was not found.",
+            reason_code="CATALOG_OBJECT_NOT_FOUND",
+            status_code=404,
+        )
+    if (
+        "PERMISSION" in raw_code
+        or "DENIED" in raw_code
+        or status_code == 403
+        or any(
+            marker in message
+            for marker in (
+                "access denied",
+                "permission denied",
+                "not authorized",
+                "privilege",
+            )
+        )
+    ):
+        return CatalogMetadataFailure(
+            "Doris denied access to the requested catalog metadata.",
+            reason_code="CATALOG_PERMISSION_DENIED",
+            status_code=403,
+        )
+    if (
+        "UNSUPPORTED" in raw_code
+        or status_code == 501
+        or "not supported" in message
+        or "unsupported" in message
+    ):
+        return CatalogMetadataFailure(
+            "The requested Doris metadata section is unsupported.",
+            reason_code="CATALOG_SECTION_UNSUPPORTED",
+            status_code=501,
+        )
+    if status_code in {502, 503, 504}:
+        return CatalogMetadataFailure(
+            "Doris catalog metadata is temporarily unavailable.",
+            reason_code="CATALOG_BACKEND_UNAVAILABLE",
+            status_code=status_code,
+            retryable=True,
+        )
+    return CatalogMetadataFailure(
+        "Doris catalog metadata execution failed.",
+        reason_code="CATALOG_EXECUTION_FAILED",
+        status_code=status_code,
+    )
+
+
+def _collection_cursor_scope(
+    child: ChildToolDefinition,
+    arguments: Mapping[str, Any],
+) -> str:
+    payload = {
+        "child": child.name,
+        "arguments": {
+            key: value
+            for key, value in arguments.items()
+            if key != "page"
+        },
+    }
+    canonical = json.dumps(
+        payload,
+        ensure_ascii=True,
+        separators=(",", ":"),
+        sort_keys=True,
+    )
+    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
+
+
+def _collection_snapshot(items: list[dict[str, Any]]) -> str:
+    identifiers = [
+        {
+            "name": str(item.get("name", "")),
+            "type": str(item.get("type", "")),
+        }
+        for item in items
+    ]
+    canonical = json.dumps(
+        identifiers,
+        ensure_ascii=True,
+        separators=(",", ":"),
+        sort_keys=True,
+    )
+    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
+
+
+def _encode_collection_cursor(
+    offset: int,
+    scope: str,
+    snapshot: str,
+    *,
+    child: ChildToolDefinition,
+    auth_context: Any | None,
+    handle_codec: StateHandleCodec,
+) -> str:
+    return handle_codec.issue(
+        kind="child-list-page",
+        scope=f"child:page:doris_catalog:{child.name}",
+        resource=f"mcp://doris_catalog/{child.name}/{scope}",
+        state={
+            "offset": offset,
+            "snapshot": snapshot,
+        },
+        auth_context=auth_context,
+    )
+
+
+def _decode_collection_cursor(
+    value: Any,
+    *,
+    expected_scope: str,
+    expected_snapshot: str,
+    child: ChildToolDefinition,
+    auth_context: Any | None,
+    handle_codec: StateHandleCodec,
+) -> int:
+    if value is None:
+        return 0
+    if not isinstance(value, str) or not value:
+        raise ChildArgumentsAdapterError("page cursor must be a non-empty 
string")
+    try:
+        claims = handle_codec.resolve(
+            value,
+            expected_kind="child-list-page",
+            expected_scope=f"child:page:doris_catalog:{child.name}",
+            expected_resource=(
+                f"mcp://doris_catalog/{child.name}/{expected_scope}"
+            ),
+            auth_context=auth_context,
+        )
+    except StateHandleError as exc:
+        raise ChildArgumentsAdapterError(
+            f"page cursor is invalid ({exc.reason})"
+        ) from exc
+    if set(claims.state) != {"offset", "snapshot"}:
+        raise ChildArgumentsAdapterError("page cursor is invalid")
+    offset = claims.state.get("offset")
+    snapshot = claims.state.get("snapshot")
+    if (
+        not isinstance(offset, int)
+        or isinstance(offset, bool)
+        or offset < 0
+        or not isinstance(snapshot, str)
+    ):
+        raise ChildArgumentsAdapterError("page cursor is invalid")
+    if not hmac.compare_digest(snapshot, expected_snapshot):
+        raise ChildArgumentsAdapterError("page cursor is stale")
+    return offset
+
+
 def _as_object(value: Any) -> dict[str, Any]:
     if isinstance(value, dict):
         return dict(value)
diff --git a/doris_mcp_server/tools/doris_feature_matrix.py 
b/doris_mcp_server/tools/doris_feature_matrix.py
index bd5f4b9..decbfec 100644
--- a/doris_mcp_server/tools/doris_feature_matrix.py
+++ b/doris_mcp_server/tools/doris_feature_matrix.py
@@ -948,7 +948,7 @@ FEATURE_DEFINITIONS = (
     _feature(
         "doris_catalog",
         "get_table_size",
-        A,
+        M,
         _variant(
             "partition_statistics",
             probes=("table_partition_statistics_readable",),
diff --git a/doris_mcp_server/tools/tools_manager.py 
b/doris_mcp_server/tools/tools_manager.py
index 77d6f86..8d2d072 100644
--- a/doris_mcp_server/tools/tools_manager.py
+++ b/doris_mcp_server/tools/tools_manager.py
@@ -19,6 +19,7 @@ Apache Doris MCP Tools Manager
 Responsible for tool registration, management, scheduling and routing, does 
not contain specific business logic implementation
 """
 
+import secrets
 from collections.abc import Iterable
 from datetime import datetime
 from typing import Any
@@ -27,6 +28,7 @@ from mcp.types import Tool
 
 from ..auth.operation_policy import authorize_operation
 from ..result_limits import configured_default_result_rows
+from ..state_handles import StateHandleCodec
 from ..utils.adbc_query_tools import DorisADBCQueryTools
 from ..utils.analysis_tools import MemoryTracker, SQLAnalyzer, TableAnalyzer
 from ..utils.data_exploration_tools import DataExplorationTools
@@ -47,6 +49,7 @@ from .capability_registry import (
     CapabilityProviderRegistry,
     CapabilityRegistry,
 )
+from .catalog_handlers import CatalogToolHandlersMixin
 from .domain_catalog import CURRENT_FLAT_TOOL_NAMES
 from .domain_dispatcher import (
     BoundHandlerAvailabilityProvider,
@@ -67,7 +70,10 @@ from .tool_registry import ToolRegistryError
 logger = get_logger(__name__)
 
 
-class DorisToolsManager(DomainManifestManagerMixin):
+class DorisToolsManager(
+    CatalogToolHandlersMixin,
+    DomainManifestManagerMixin,
+):
     """Apache Doris Tools Manager"""
 
     def __init__(
@@ -87,6 +93,7 @@ class DorisToolsManager(DomainManifestManagerMixin):
         self.metadata_extractor = MetadataExtractor(
             connection_manager=connection_manager
         )
+        self._initialize_catalog_handlers(connection_manager)
         self.monitoring_tools = DorisMonitoringTools(connection_manager)
         self.memory_tracker = MemoryTracker(connection_manager)
 
@@ -151,9 +158,29 @@ class DorisToolsManager(DomainManifestManagerMixin):
         self._tool_exposure_mode = ToolExposureMode.parse(
             tool_exposure_mode or configured_mode
         )
+        state_handle_secret = getattr(
+            config,
+            "mcp_state_handle_secret",
+            None,
+        )
+        if not isinstance(state_handle_secret, str | bytes) or len(
+            state_handle_secret
+        ) < 32:
+            state_handle_secret = secrets.token_urlsafe(32)
+        state_handle_ttl = getattr(
+            config,
+            "mcp_state_handle_ttl_seconds",
+            300,
+        )
+        if not isinstance(state_handle_ttl, int):
+            state_handle_ttl = 300
         self._domain_dispatcher = DomainDispatcher(
             self,
             self._domain_manifest_service,
+            state_handle_codec=StateHandleCodec(
+                state_handle_secret,
+                default_ttl_seconds=state_handle_ttl,
+            ),
         )
         self._validate_custom_tool_names()
 
@@ -287,75 +314,6 @@ class DorisToolsManager(DomainManifestManagerMixin):
             max_bytes=max_bytes,
         )
 
-    async def _get_table_schema_tool(self, arguments: dict[str, Any]) -> 
dict[str, Any]:
-        """Get table schema tool routing"""
-        table_name = self._required_string(arguments, "table_name")
-        db_name = arguments.get("db_name")
-        catalog_name = arguments.get("catalog_name")
-
-        # Delegate to metadata extractor for processing
-        return await self.metadata_extractor.get_table_schema_for_mcp(
-            table_name, db_name, catalog_name
-        )
-
-    async def _get_db_table_list_tool(
-        self, arguments: dict[str, Any]
-    ) -> dict[str, Any]:
-        """Get database table list tool routing"""
-        db_name = arguments.get("db_name")
-        catalog_name = arguments.get("catalog_name")
-
-        # Delegate to metadata extractor for processing
-        return await self.metadata_extractor.get_db_table_list_for_mcp(
-            db_name, catalog_name
-        )
-
-    async def _get_db_list_tool(self, arguments: dict[str, Any]) -> dict[str, 
Any]:
-        """Get database list tool routing"""
-        catalog_name = arguments.get("catalog_name")
-
-        # Delegate to metadata extractor for processing
-        return await self.metadata_extractor.get_db_list_for_mcp(catalog_name)
-
-    async def _get_table_comment_tool(
-        self, arguments: dict[str, Any]
-    ) -> dict[str, Any]:
-        """Get table comment tool routing"""
-        table_name = self._required_string(arguments, "table_name")
-        db_name = arguments.get("db_name")
-        catalog_name = arguments.get("catalog_name")
-
-        # Delegate to metadata extractor for processing
-        return await self.metadata_extractor.get_table_comment_for_mcp(
-            table_name, db_name, catalog_name
-        )
-
-    async def _get_table_column_comments_tool(
-        self, arguments: dict[str, Any]
-    ) -> dict[str, Any]:
-        """Get table column comments tool routing"""
-        table_name = self._required_string(arguments, "table_name")
-        db_name = arguments.get("db_name")
-        catalog_name = arguments.get("catalog_name")
-
-        # Delegate to metadata extractor for processing
-        return await self.metadata_extractor.get_table_column_comments_for_mcp(
-            table_name, db_name, catalog_name
-        )
-
-    async def _get_table_indexes_tool(
-        self, arguments: dict[str, Any]
-    ) -> dict[str, Any]:
-        """Get table indexes tool routing"""
-        table_name = self._required_string(arguments, "table_name")
-        db_name = arguments.get("db_name")
-        catalog_name = arguments.get("catalog_name")
-
-        # Delegate to metadata extractor for processing
-        return await self.metadata_extractor.get_table_indexes_for_mcp(
-            table_name, db_name, catalog_name
-        )
-
     async def _get_recent_audit_logs_tool(
         self, arguments: dict[str, Any]
     ) -> dict[str, Any]:
@@ -366,14 +324,6 @@ class DorisToolsManager(DomainManifestManagerMixin):
         # Delegate to metadata extractor for processing
         return await 
self.metadata_extractor.get_recent_audit_logs_for_mcp(days, limit)
 
-    async def _get_catalog_list_tool(self, arguments: dict[str, Any]) -> 
dict[str, Any]:
-        """Get catalog list tool routing"""
-        # random_string parameter is required in the source project, but not 
actually used in business logic
-        # Here we ignore it and directly call business logic
-
-        # Delegate to metadata extractor for processing
-        return await self.metadata_extractor.get_catalog_list_for_mcp()
-
     async def _get_sql_explain_tool(self, arguments: dict[str, Any]) -> 
dict[str, Any]:
         """SQL Explain tool routing"""
         sql = self._required_string(arguments, "sql")
@@ -398,19 +348,6 @@ class DorisToolsManager(DomainManifestManagerMixin):
             sql, db_name, catalog_name, timeout
         )
 
-    async def _get_table_data_size_tool(
-        self, arguments: dict[str, Any]
-    ) -> dict[str, Any]:
-        """Table data size tool routing"""
-        db_name = arguments.get("db_name")
-        table_name = arguments.get("table_name")
-        single_replica = arguments.get("single_replica", False)
-
-        # Delegate to SQL analyzer for processing
-        return await self.sql_analyzer.get_table_data_size(
-            db_name, table_name, single_replica
-        )
-
     async def _get_monitoring_metrics_tool(
         self, arguments: dict[str, Any]
     ) -> dict[str, Any]:
@@ -538,29 +475,6 @@ class DorisToolsManager(DomainManifestManagerMixin):
 
     # ==================== v0.5.0 Advanced Analytics Tools Private Methods 
====================
 
-    async def _get_table_basic_info_tool(
-        self, arguments: dict[str, Any]
-    ) -> dict[str, Any]:
-        """Get table basic information tool routing"""
-        try:
-            table_name = self._required_string(arguments, "table_name")
-            catalog_name = arguments.get("catalog_name")
-            db_name = arguments.get("db_name")
-
-            # Delegate to atomic data quality tools
-            result = await self.data_quality_tools.get_table_basic_info(
-                table_name=table_name, catalog_name=catalog_name, 
db_name=db_name
-            )
-
-            return result
-
-        except Exception as e:
-            return {
-                "error": str(e),
-                "analysis_type": "table_basic_info",
-                "timestamp": datetime.now().isoformat(),
-            }
-
     async def _analyze_columns_tool(self, arguments: dict[str, Any]) -> 
dict[str, Any]:
         """Analyze columns tool routing"""
         try:
diff --git a/doris_mcp_server/utils/catalog_metadata.py 
b/doris_mcp_server/utils/catalog_metadata.py
new file mode 100644
index 0000000..3e8b4ae
--- /dev/null
+++ b/doris_mcp_server/utils/catalog_metadata.py
@@ -0,0 +1,661 @@
+# 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.
+
+"""Strict, read-only Doris catalog metadata access for formal domain tools."""
+
+from __future__ import annotations
+
+import uuid
+from collections.abc import Mapping
+from typing import Any
+
+from .db import DorisConnectionManager
+from .security import get_current_auth_context
+from .sql_security_utils import (
+    SQLSecurityError,
+    quote_identifier,
+    validate_identifier,
+)
+
+
+class CatalogMetadataFailure(RuntimeError):
+    """A sanitized catalog failure that remains machine distinguishable."""
+
+    def __init__(
+        self,
+        message: str,
+        *,
+        reason_code: str,
+        status_code: int,
+        retryable: bool = False,
+    ) -> None:
+        super().__init__(message)
+        self.reason_code = reason_code
+        self.status_code = status_code
+        self.retryable = retryable
+
+
+class DorisCatalogMetadataReader:
+    """Read formal Catalog-domain metadata without swallowing Doris 
failures."""
+
+    def __init__(self, connection_manager: DorisConnectionManager) -> None:
+        self._connection_manager = connection_manager
+        self._session_id = f"formal_catalog_{uuid.uuid4().hex[:8]}"
+
+    async def list_catalogs(self) -> dict[str, Any]:
+        """Return visible catalogs with their reported provider type."""
+        rows = await self._execute("SHOW CATALOGS")
+        items: list[dict[str, Any]] = []
+        for row in rows:
+            name = _row_value(row, "CatalogName")
+            if name is None:
+                values = list(row.values())
+                name = values[1] if len(values) > 1 else values[0] if values 
else None
+            if name is None:
+                continue
+            catalog_name = str(name)
+            reported_type = _row_value(row, "Type")
+            scope = "internal" if catalog_name == "internal" else "external"
+            items.append(
+                {
+                    "name": catalog_name,
+                    "type": (
+                        str(reported_type).lower()
+                        if reported_type not in (None, "")
+                        else scope
+                    ),
+                    "scope": scope,
+                    "is_current": _as_bool(_row_value(row, "IsCurrent")),
+                }
+            )
+        return _success(items, "SHOW CATALOGS")
+
+    async def list_databases(
+        self,
+        *,
+        catalog_name: str | None,
+    ) -> dict[str, Any]:
+        """Return visible databases in one catalog."""
+        effective_catalog = catalog_name or "internal"
+        if catalog_name and catalog_name != "internal":
+            safe_catalog = _quote(catalog_name, "catalog name")
+            sql = f"SHOW DATABASES FROM {safe_catalog}"
+        else:
+            sql = "SHOW DATABASES"
+        rows = await self._execute(sql)
+        items = [
+            {
+                "name": str(name),
+                "catalog": effective_catalog,
+            }
+            for row in rows
+            if (name := _first_value(row)) is not None
+        ]
+        return _success(items, "SHOW DATABASES")
+
+    async def list_tables(
+        self,
+        *,
+        database_name: str,
+        catalog_name: str | None,
+    ) -> dict[str, Any]:
+        """Return visible tables and views with stable normalized object 
types."""
+        relation = _qualified_name(catalog_name, database_name)
+        rows = await self._execute(f"SHOW FULL TABLES FROM {relation}")
+        items: list[dict[str, Any]] = []
+        for row in rows:
+            name = next(
+                (
+                    value
+                    for key, value in row.items()
+                    if str(key).lower().startswith("tables_in_")
+                ),
+                _first_value(row),
+            )
+            if name is None:
+                continue
+            raw_type = _row_value(row, "Table_type", "TABLE_TYPE")
+            items.append(
+                {
+                    "name": str(name),
+                    "type": _normalize_table_type(raw_type),
+                    "raw_type": "" if raw_type is None else str(raw_type),
+                    "catalog": catalog_name or "internal",
+                    "database": database_name,
+                    "storage_format": _optional_string(
+                        _row_value(row, "Storage_format")
+                    ),
+                    "inverted_index_storage_format": _optional_string(
+                        _row_value(row, "Inverted_index_storage_format")
+                    ),
+                }
+            )
+        return _success(items, "SHOW FULL TABLES")
+
+    async def get_table_schema(
+        self,
+        *,
+        table_name: str,
+        database_name: str,
+        catalog_name: str | None,
+    ) -> dict[str, Any]:
+        """Return a strict schema section and prove that the table is 
visible."""
+        relation = _qualified_name(catalog_name, database_name, table_name)
+        rows = await self._execute(f"SHOW FULL COLUMNS FROM {relation}")
+        if not rows:
+            raise _object_not_found()
+        columns = [
+            {
+                "column_name": _optional_string(_row_value(row, "Field")),
+                "data_type": _optional_string(_row_value(row, "Type")),
+                "is_nullable": str(_row_value(row, "Null") or "NO").upper()
+                == "YES",
+                "default_value": _row_value(row, "Default"),
+                "comment": _optional_string(_row_value(row, "Comment")),
+                "key": _optional_string(_row_value(row, "Key")),
+                "extra": _optional_string(_row_value(row, "Extra")),
+            }
+            for row in rows
+        ]
+        return _success(
+            {
+                "catalog": catalog_name or "internal",
+                "database": database_name,
+                "table": table_name,
+                "columns": columns,
+                "column_count": len(columns),
+            },
+            "SHOW FULL COLUMNS",
+        )
+
+    async def get_table_comment(
+        self,
+        *,
+        table_name: str,
+        database_name: str,
+        catalog_name: str | None,
+    ) -> dict[str, Any]:
+        """Return the table comment from the catalog-local information 
schema."""
+        relation = _information_schema_relation(catalog_name, "tables")
+        # SQL sink audit: relation is assembled only from quote_identifier
+        # outputs; filter values stay bound before _execute reaches Doris.
+        rows = await self._execute(
+            (
+                "SELECT TABLE_COMMENT "
+                f"FROM {relation} "  # nosec B608
+                "WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s"
+            ),
+            (database_name, table_name),
+        )
+        if not rows:
+            raise _object_not_found()
+        return _success(
+            {
+                "comment": _optional_string(
+                    _row_value(rows[0], "TABLE_COMMENT")
+                )
+            },
+            "information_schema.tables",
+        )
+
+    async def get_column_comments(
+        self,
+        *,
+        table_name: str,
+        database_name: str,
+        catalog_name: str | None,
+    ) -> dict[str, Any]:
+        """Return ordered column comments from the catalog-local metadata."""
+        relation = _information_schema_relation(catalog_name, "columns")
+        # SQL sink audit: relation is assembled only from quote_identifier
+        # outputs; filter values stay bound before _execute reaches Doris.
+        rows = await self._execute(
+            (
+                "SELECT COLUMN_NAME, COLUMN_COMMENT "
+                f"FROM {relation} "  # nosec B608
+                "WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s "
+                "ORDER BY ORDINAL_POSITION"
+            ),
+            (database_name, table_name),
+        )
+        return _success(
+            {
+                str(name): _optional_string(
+                    _row_value(row, "COLUMN_COMMENT")
+                )
+                for row in rows
+                if (name := _row_value(row, "COLUMN_NAME")) is not None
+            },
+            "information_schema.columns",
+        )
+
+    async def get_table_indexes(
+        self,
+        *,
+        table_name: str,
+        database_name: str,
+        catalog_name: str | None,
+    ) -> dict[str, Any]:
+        """Return table indexes, preserving multi-column index order."""
+        relation = _qualified_name(catalog_name, database_name, table_name)
+        rows = await self._execute(f"SHOW INDEX FROM {relation}")
+        indexes: dict[str, dict[str, Any]] = {}
+        for row in rows:
+            raw_name = _row_value(row, "Key_name", "KEY_NAME")
+            if raw_name is None:
+                continue
+            name = str(raw_name)
+            index = indexes.setdefault(
+                name,
+                {
+                    "name": name,
+                    "columns": [],
+                    "unique": _as_int(_row_value(row, "Non_unique"), 1) == 0,
+                    "type": _optional_string(
+                        _row_value(row, "Index_type", "INDEX_TYPE")
+                    ),
+                },
+            )
+            column_name = _row_value(row, "Column_name", "COLUMN_NAME")
+            if column_name is not None:
+                index["columns"].append(str(column_name))
+        return _success(
+            {"items": list(indexes.values()), "index_count": len(indexes)},
+            "SHOW INDEX",
+        )
+
+    async def get_table_basic(
+        self,
+        *,
+        table_name: str,
+        database_name: str,
+        catalog_name: str | None,
+    ) -> dict[str, Any]:
+        """Return stable basic metadata without scanning table data."""
+        row = await self._get_table_metadata_row(
+            table_name=table_name,
+            database_name=database_name,
+            catalog_name=catalog_name,
+        )
+        return _success(
+            _normalize_basic_row(
+                row,
+                catalog_name=catalog_name,
+                database_name=database_name,
+                table_name=table_name,
+            ),
+            "information_schema.tables",
+        )
+
+    async def get_table_size(
+        self,
+        *,
+        table_name: str,
+        database_name: str,
+        catalog_name: str | None,
+        include_partitions: bool,
+    ) -> dict[str, Any]:
+        """Return table statistics and optional partition statistics."""
+        row = await self._get_table_metadata_row(
+            table_name=table_name,
+            database_name=database_name,
+            catalog_name=catalog_name,
+        )
+        data_size = _optional_int(_row_value(row, "DATA_LENGTH"))
+        index_size = _optional_int(_row_value(row, "INDEX_LENGTH"))
+        result: dict[str, Any] = {
+            "catalog": catalog_name or "internal",
+            "database": database_name,
+            "table": table_name,
+            "row_count": _optional_int(_row_value(row, "TABLE_ROWS")),
+            "data_size_bytes": data_size,
+            "index_size_bytes": index_size,
+            "total_size_bytes": (
+                data_size + index_size
+                if data_size is not None and index_size is not None
+                else data_size
+            ),
+            "partitions": [],
+        }
+        warnings: list[str] = []
+        metadata: dict[str, Any] = {
+            "source": "information_schema.tables",
+            "partition_status": "not_requested",
+        }
+        if include_partitions:
+            try:
+                partition_rows = await self._get_partition_rows(
+                    table_name=table_name,
+                    database_name=database_name,
+                    catalog_name=catalog_name,
+                )
+            except CatalogMetadataFailure as exc:
+                warnings.append(
+                    "Partition statistics are unavailable for the requested "
+                    f"table ({exc.reason_code})."
+                )
+                metadata["partition_status"] = "unavailable"
+                metadata["partition_reason_code"] = exc.reason_code
+            else:
+                result["partitions"] = [
+                    {
+                        "name": _optional_string(
+                            _row_value(partition, "PARTITION_NAME")
+                        ),
+                        "ordinal_position": _optional_int(
+                            _row_value(
+                                partition,
+                                "PARTITION_ORDINAL_POSITION",
+                            )
+                        ),
+                        "row_count": _optional_int(
+                            _row_value(partition, "TABLE_ROWS")
+                        ),
+                        "data_size_bytes": _optional_int(
+                            _row_value(partition, "DATA_LENGTH")
+                        ),
+                        "index_size_bytes": _optional_int(
+                            _row_value(partition, "INDEX_LENGTH")
+                        ),
+                    }
+                    for partition in partition_rows
+                ]
+                metadata["partition_status"] = "available"
+                metadata["source"] = (
+                    "information_schema.tables,"
+                    "information_schema.partitions"
+                )
+        return _success(
+            result,
+            metadata["source"],
+            warnings=warnings,
+            metadata=metadata,
+        )
+
+    async def _get_table_metadata_row(
+        self,
+        *,
+        table_name: str,
+        database_name: str,
+        catalog_name: str | None,
+    ) -> Mapping[str, Any]:
+        relation = _information_schema_relation(catalog_name, "tables")
+        # SQL sink audit: relation is assembled only from quote_identifier
+        # outputs; filter values stay bound before _execute reaches Doris.
+        rows = await self._execute(
+            (
+                "SELECT TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE, "
+                "ENGINE, TABLE_ROWS, AVG_ROW_LENGTH, DATA_LENGTH, 
INDEX_LENGTH, "
+                "CREATE_TIME, UPDATE_TIME, TABLE_COMMENT "
+                f"FROM {relation} "  # nosec B608
+                "WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s"
+            ),
+            (database_name, table_name),
+        )
+        if not rows:
+            raise _object_not_found()
+        return rows[0]
+
+    async def _get_partition_rows(
+        self,
+        *,
+        table_name: str,
+        database_name: str,
+        catalog_name: str | None,
+    ) -> list[Mapping[str, Any]]:
+        relation = _information_schema_relation(catalog_name, "partitions")
+        # SQL sink audit: relation is assembled only from quote_identifier
+        # outputs; filter values stay bound before _execute reaches Doris.
+        return await self._execute(
+            (
+                "SELECT PARTITION_NAME, PARTITION_ORDINAL_POSITION, 
TABLE_ROWS, "
+                f"DATA_LENGTH, INDEX_LENGTH FROM {relation} "  # nosec B608
+                "WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s "
+                "ORDER BY PARTITION_ORDINAL_POSITION"
+            ),
+            (database_name, table_name),
+        )
+
+    async def _execute(
+        self,
+        sql: str,
+        params: tuple[Any, ...] | None = None,
+    ) -> list[Mapping[str, Any]]:
+        try:
+            result = await self._connection_manager.execute_query(
+                self._session_id,
+                sql,
+                params,
+                get_current_auth_context(),
+            )
+        except Exception as exc:
+            raise _classify_failure(exc) from exc
+        return [
+            row
+            for row in (result.data or [])
+            if isinstance(row, Mapping)
+        ]
+
+
+def _success(
+    result: Any,
+    source: str,
+    *,
+    warnings: list[str] | None = None,
+    metadata: Mapping[str, Any] | None = None,
+) -> dict[str, Any]:
+    response_metadata = dict(metadata or {})
+    response_metadata.setdefault("source", source)
+    return {
+        "success": True,
+        "result": result,
+        "warnings": list(warnings or ()),
+        "metadata": response_metadata,
+    }
+
+
+def _classify_failure(exc: Exception) -> CatalogMetadataFailure:
+    if isinstance(exc, CatalogMetadataFailure):
+        return exc
+    if isinstance(exc, SQLSecurityError):
+        return CatalogMetadataFailure(
+            "Catalog arguments contain an invalid Doris identifier.",
+            reason_code="CATALOG_ARGUMENT_INVALID",
+            status_code=400,
+        )
+
+    numeric_code = next(
+        (
+            value
+            for value in getattr(exc, "args", ())
+            if isinstance(value, int)
+        ),
+        None,
+    )
+    message = str(exc).lower()
+    if numeric_code in {1044, 1045, 1142, 1227} or any(
+        marker in message
+        for marker in (
+            "access denied",
+            "permission denied",
+            "not authorized",
+            "privilege",
+        )
+    ):
+        return CatalogMetadataFailure(
+            "Doris denied access to the requested catalog metadata.",
+            reason_code="CATALOG_PERMISSION_DENIED",
+            status_code=403,
+        )
+    if numeric_code in {1049, 1146} or any(
+        marker in message
+        for marker in (
+            "doesn't exist",
+            "does not exist",
+            "unknown database",
+            "unknown table",
+            "not found",
+        )
+    ):
+        return _object_not_found()
+    if numeric_code in {1064, 1109} or any(
+        marker in message
+        for marker in (
+            "not supported",
+            "unsupported",
+            "unknown system table",
+        )
+    ):
+        return CatalogMetadataFailure(
+            "The requested Doris metadata section is unsupported.",
+            reason_code="CATALOG_SECTION_UNSUPPORTED",
+            status_code=501,
+        )
+    if isinstance(exc, TimeoutError | ConnectionError | OSError):
+        return CatalogMetadataFailure(
+            "Doris catalog metadata is temporarily unavailable.",
+            reason_code="CATALOG_BACKEND_UNAVAILABLE",
+            status_code=503,
+            retryable=True,
+        )
+    return CatalogMetadataFailure(
+        "Doris catalog metadata execution failed.",
+        reason_code="CATALOG_EXECUTION_FAILED",
+        status_code=502,
+    )
+
+
+def _object_not_found() -> CatalogMetadataFailure:
+    return CatalogMetadataFailure(
+        "The requested Doris metadata object was not found.",
+        reason_code="CATALOG_OBJECT_NOT_FOUND",
+        status_code=404,
+    )
+
+
+def _quote(value: str, label: str) -> str:
+    try:
+        validate_identifier(value, label)
+        return quote_identifier(value, label)
+    except SQLSecurityError as exc:
+        raise _classify_failure(exc) from exc
+
+
+def _qualified_name(
+    catalog_name: str | None,
+    database_name: str,
+    table_name: str | None = None,
+) -> str:
+    parts = []
+    if catalog_name and catalog_name != "internal":
+        parts.append(_quote(catalog_name, "catalog name"))
+    parts.append(_quote(database_name, "database name"))
+    if table_name is not None:
+        parts.append(_quote(table_name, "table name"))
+    return ".".join(parts)
+
+
+def _information_schema_relation(
+    catalog_name: str | None,
+    table_name: str,
+) -> str:
+    parts = []
+    if catalog_name and catalog_name != "internal":
+        parts.append(_quote(catalog_name, "catalog name"))
+    parts.extend(
+        (
+            quote_identifier("information_schema", "database name"),
+            _quote(table_name, "metadata table name"),
+        )
+    )
+    return ".".join(parts)
+
+
+def _row_value(row: Mapping[str, Any], *names: str) -> Any | None:
+    lowered = {str(key).lower(): value for key, value in row.items()}
+    for name in names:
+        if name.lower() in lowered:
+            return lowered[name.lower()]
+    return None
+
+
+def _first_value(row: Mapping[str, Any]) -> Any | None:
+    return next(iter(row.values()), None)
+
+
+def _optional_string(value: Any) -> str:
+    return "" if value is None else str(value)
+
+
+def _optional_int(value: Any) -> int | None:
+    if value in (None, ""):
+        return None
+    try:
+        return int(value)
+    except (TypeError, ValueError):
+        return None
+
+
+def _as_int(value: Any, default: int) -> int:
+    parsed = _optional_int(value)
+    return default if parsed is None else parsed
+
+
+def _as_bool(value: Any) -> bool:
+    if isinstance(value, bool):
+        return value
+    return str(value or "").strip().lower() in {"1", "true", "yes"}
+
+
+def _normalize_table_type(value: Any) -> str:
+    normalized = str(value or "").strip().upper().replace("-", " ")
+    if "MATERIALIZED" in normalized:
+        return "materialized_view"
+    if "VIEW" in normalized:
+        return "view"
+    return "table"
+
+
+def _normalize_basic_row(
+    row: Mapping[str, Any],
+    *,
+    catalog_name: str | None,
+    database_name: str,
+    table_name: str,
+) -> dict[str, Any]:
+    return {
+        "catalog": catalog_name or "internal",
+        "database": database_name,
+        "table": table_name,
+        "type": _normalize_table_type(_row_value(row, "TABLE_TYPE")),
+        "raw_type": _optional_string(_row_value(row, "TABLE_TYPE")),
+        "engine": _optional_string(_row_value(row, "ENGINE")),
+        "row_count": _optional_int(_row_value(row, "TABLE_ROWS")),
+        "average_row_length": _optional_int(
+            _row_value(row, "AVG_ROW_LENGTH")
+        ),
+        "data_size_bytes": _optional_int(_row_value(row, "DATA_LENGTH")),
+        "index_size_bytes": _optional_int(_row_value(row, "INDEX_LENGTH")),
+        "created_at": _optional_string(_row_value(row, "CREATE_TIME")),
+        "updated_at": _optional_string(_row_value(row, "UPDATE_TIME")),
+        "comment": _optional_string(_row_value(row, "TABLE_COMMENT")),
+    }
+
+
+__all__ = [
+    "CatalogMetadataFailure",
+    "DorisCatalogMetadataReader",
+]
diff --git a/test/protocol/test_state_handles.py 
b/test/protocol/test_state_handles.py
index f9c1196..1fb004c 100644
--- a/test/protocol/test_state_handles.py
+++ b/test/protocol/test_state_handles.py
@@ -209,6 +209,20 @@ def 
test_handle_rejects_expiry_and_tampering_before_exposing_state():
             auth_context=_auth_context(),
         )
 
+    alphabet = 
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
+    parts = handle.split(".")
+    tail_index = alphabet.index(parts[2][-1])
+    noncanonical_tail = alphabet[(tail_index & ~0b11) | ((tail_index + 1) & 
0b11)]
+    parts[2] = parts[2][:-1] + noncanonical_tail
+    with pytest.raises(StateHandleError, match="invalid"):
+        codec.resolve(
+            ".".join(parts),
+            expected_kind="list-page",
+            expected_scope="tools:list",
+            expected_resource="mcp://tools",
+            auth_context=_auth_context(),
+        )
+
 
 def test_handle_enforces_secret_ttl_claim_and_payload_limits():
     with pytest.raises(ValueError, match="at least 32 bytes"):
diff --git a/test/tools/test_capability_detector.py 
b/test/tools/test_capability_detector.py
index fab456b..3c0afbe 100644
--- a/test/tools/test_capability_detector.py
+++ b/test/tools/test_capability_detector.py
@@ -97,6 +97,22 @@ class _ProbeConnection:
             ): [
                 {"table_metadata_probe": 1}
             ],
+            (
+                "SELECT COLUMN_NAME, DATA_TYPE "
+                "FROM information_schema.columns LIMIT 1"
+            ): [
+                {"COLUMN_NAME": "id", "DATA_TYPE": "bigint"}
+            ],
+            (
+                "SELECT PARTITION_NAME, TABLE_ROWS, DATA_LENGTH "
+                "FROM information_schema.partitions LIMIT 1"
+            ): [
+                {
+                    "PARTITION_NAME": "p1",
+                    "TABLE_ROWS": 1,
+                    "DATA_LENGTH": 8,
+                }
+            ],
         }
         return SimpleNamespace(
             data=self.row_overrides.get(sql, rows.get(sql, []))
@@ -166,6 +182,14 @@ async def 
test_detector_builds_version_vector_and_extends_domains_lazily() -> No
         catalog.probe("database_metadata_readable").status
         is CapabilityProbeStatus.SUPPORTED
     )
+    assert (
+        catalog.probe("table_context_sections_readable").status
+        is CapabilityProbeStatus.SUPPORTED
+    )
+    assert (
+        catalog.probe("table_partition_statistics_readable").status
+        is CapabilityProbeStatus.SUPPORTED
+    )
     assert connection.statements.count("SELECT @@version_comment;") == 1
 
 
diff --git a/test/tools/test_capability_registry.py 
b/test/tools/test_capability_registry.py
index f5fa7ea..0f5fd3d 100644
--- a/test/tools/test_capability_registry.py
+++ b/test/tools/test_capability_registry.py
@@ -193,6 +193,44 @@ def 
test_evaluator_requires_version_probes_handler_and_call_permission() -> None
     assert allowed.callable is True
 
 
+def test_evaluator_normalizes_system_object_probe_evidence_for_manifest() -> 
None:
+    evaluator = CapabilityEvaluator(
+        matrix=DORIS_FEATURE_MATRIX,
+        bound_handlers=_BoundHandlers(
+            "doris_catalog.get_table_context"
+        ),  # type: ignore[arg-type]
+    )
+    domain = DORIS_DOMAIN_CATALOG.resolve_domain("doris_catalog")
+    child = DORIS_DOMAIN_CATALOG.resolve_child(
+        "doris_catalog",
+        "get_table_context",
+    )
+    probes = {
+        probe_id: CapabilityProbeEvidence(
+            probe_id=probe_id,
+            status=CapabilityProbeStatus.SUPPORTED,
+            reason_code="TEST_PROBE_SUPPORTED",
+            evidence_sources=("test_probe",),
+        )
+        for probe_id in (
+            "information_schema.columns",
+            "table_context_sections_readable",
+        )
+    }
+
+    availability = evaluator.evaluate(
+        snapshot=_snapshot(probes=probes),
+        providers=CapabilityProviderRegistry({}).snapshot(),
+        domain=domain,
+        child=child,
+        auth_context=None,
+    )
+
+    assert availability.callable is True
+    assert "information_schema_columns" in availability.evidence_sources
+    assert "information_schema.columns" not in availability.evidence_sources
+
+
 def test_evaluator_distinguishes_provider_misconfiguration() -> None:
     bound = _BoundHandlers("doris_query.diagnose_query_performance")
     evaluator = CapabilityEvaluator(
diff --git a/test/tools/test_domain_catalog.py 
b/test/tools/test_domain_catalog.py
index aa6de8e..6a05b78 100644
--- a/test/tools/test_domain_catalog.py
+++ b/test/tools/test_domain_catalog.py
@@ -226,7 +226,18 @@ def 
test_table_context_has_four_sections_and_five_deterministic_steps() -> None:
 
     assert plan is not None
     section_enum = 
_wire_input(child)["properties"]["sections"]["items"]["enum"]
+    output = child.to_wire()["output_schema"]
+    data_schema = output["properties"]["data"]
     assert tuple(section_enum) == ("schema", "comments", "indexes", "basic")
+    for section in ("schema", "comments", "indexes", "basic"):
+        section_schema = data_schema["properties"][section]
+        assert section_schema["required"] == [
+            "status",
+            "data",
+            "warnings",
+            "source",
+        ]
+        assert section_schema["additionalProperties"] is False
     assert tuple(step.name for step in plan.steps) == (
         "schema",
         "table_comments",
diff --git a/test/tools/test_domain_dispatcher.py 
b/test/tools/test_domain_dispatcher.py
index cad865c..6a51825 100644
--- a/test/tools/test_domain_dispatcher.py
+++ b/test/tools/test_domain_dispatcher.py
@@ -807,19 +807,33 @@ async def 
test_table_context_composite_merges_selected_sections() -> None:
     assert result.mode == "result"
     child_result = cast_mapping(result.to_wire()["data"])
     assert child_result["status"] == "partial"
-    assert child_result["data"]["schema"] == {"columns": ["id"]}
-    assert child_result["data"]["comments"] == {
+    assert child_result["data"]["schema"] == {
+        "status": "success",
+        "data": {"columns": ["id"]},
+        "warnings": [],
+        "source": "_get_table_schema_tool",
+    }
+    assert child_result["data"]["comments"]["data"] == {
         "table": {"comment": "orders"},
         "columns": {"id": "key"},
     }
-    assert "indexes" not in child_result["data"]
-    assert child_result["data"]["basic"]["row_count"] == 1
+    assert child_result["data"]["indexes"]["status"] == "unavailable"
+    assert child_result["data"]["indexes"]["data"] == {}
+    assert (
+        child_result["data"]["indexes"]["warnings"]
+        == [
+            "Optional indexes section is unavailable "
+            "(CATALOG_EXECUTION_FAILED)."
+        ]
+    )
+    assert child_result["data"]["basic"]["data"]["row_count"] == 1
     assert len(child_result["evidence"]) == 5
     manager._get_table_schema_tool.assert_awaited_once_with(
         {
             "catalog_name": "internal",
             "db_name": "analytics",
             "table_name": "orders",
+            "_formal_catalog": True,
         }
     )
 
@@ -848,6 +862,40 @@ async def 
test_table_context_required_schema_failure_is_execution_error() -> Non
     assert result.error.code is DomainErrorCode.CHILD_EXECUTION_FAILED
 
 
[email protected]
+async def test_table_context_required_schema_distinguishes_missing_object() -> 
None:
+    manager = _manager("doris_catalog.get_table_context")
+    manager._get_table_schema_tool = AsyncMock(
+        return_value={
+            "success": False,
+            "error": "not visible",
+            "error_code": "DORIS_METADATA_NOT_VISIBLE",
+            "status_code": 404,
+        }
+    )
+
+    result = await manager.domain_dispatcher.call_domain(
+        "doris_catalog",
+        {
+            "child_tool": "get_table_context",
+            "arguments": {
+                "database": "analytics",
+                "table": "missing",
+                "sections": ["schema"],
+            },
+        },
+        None,
+    )
+
+    assert result.mode == "error"
+    assert result.error.code is DomainErrorCode.CHILD_EXECUTION_FAILED
+    assert (
+        result.error.details["reason_code"]
+        == "CATALOG_OBJECT_NOT_FOUND"
+    )
+    assert result.error.details["status_code"] == 404
+
+
 @pytest.mark.asyncio
 async def test_table_context_skips_unrequested_optional_sections() -> None:
     manager = _manager("doris_catalog.get_table_context")
@@ -869,7 +917,14 @@ async def 
test_table_context_skips_unrequested_optional_sections() -> None:
     )
 
     assert result.mode == "result"
-    assert result.to_wire()["data"]["data"] == {"schema": {"columns": ["id"]}}
+    assert result.to_wire()["data"]["data"] == {
+        "schema": {
+            "status": "success",
+            "data": {"columns": ["id"]},
+            "warnings": [],
+            "source": "_get_table_schema_tool",
+        }
+    }
 
 
 @pytest.mark.asyncio
@@ -903,13 +958,27 @@ async def 
test_table_context_includes_schema_for_comments_only_selection() -> No
     assert result.mode == "result"
     child_result = cast_mapping(result.to_wire()["data"])
     assert child_result["data"] == {
-        "schema": {"columns": ["id"]},
+        "schema": {
+            "status": "success",
+            "data": {"columns": ["id"]},
+            "warnings": [],
+            "source": "_get_table_schema_tool",
+        },
         "comments": {
-            "table": {"comment": "orders"},
-            "columns": {"id": "key"},
+            "status": "success",
+            "data": {
+                "table": {"comment": "orders"},
+                "columns": {"id": "key"},
+            },
+            "warnings": [],
+            "source": (
+                "_get_table_comment_tool,"
+                "_get_table_column_comments_tool"
+            ),
         },
     }
-    assert child_result["metadata"]["sections"] == ["comments", "schema"]
+    assert child_result["metadata"]["sections"] == ["schema", "comments"]
+    assert child_result["metadata"]["requested_sections"] == ["comments"]
     manager._get_table_indexes_tool.assert_not_awaited()
     manager._get_table_basic_info_tool.assert_not_awaited()
 
@@ -946,7 +1015,7 @@ async def 
test_table_context_internal_guards_reject_invalid_composition() -> Non
         )
 
 
-def test_collection_result_normalization_filters_and_warns() -> None:
+def test_collection_result_normalization_filters_and_paginates() -> None:
     manager = _manager()
     child = DORIS_DOMAIN_CATALOG.resolve_child(
         "doris_catalog",
@@ -958,26 +1027,300 @@ def 
test_collection_result_normalization_filters_and_warns() -> None:
         {
             "success": True,
             "result": [
-                "internal",
-                {"name": "iceberg"},
-                {"name": "hive"},
+                {"name": "internal", "scope": "internal"},
+                *[
+                    {"name": f"external_{index:03d}", "scope": "external"}
+                    for index in range(101)
+                ],
             ],
         },
         {
-            "pattern": "i*",
-            "page": "cursor",
-            "types": ["internal", "hms"],
+            "pattern": "*",
+            "include_external": True,
+        },
+        (),
+    )
+
+    assert result["status"] == "success"
+    assert len(result["data"]["items"]) == 100
+    assert result["data"]["truncated"] is True
+    assert isinstance(result["data"]["next_cursor"], str)
+
+    next_page = manager.domain_dispatcher._normalize_child_result(
+        child,
+        {
+            "success": True,
+            "result": [
+                {"name": "internal", "scope": "internal"},
+                *[
+                    {"name": f"external_{index:03d}", "scope": "external"}
+                    for index in range(101)
+                ],
+            ],
+        },
+        {
+            "pattern": "*",
+            "include_external": True,
+            "page": result["data"]["next_cursor"],
+        },
+        (),
+    )
+    assert len(next_page["data"]["items"]) == 2
+    assert next_page["data"]["next_cursor"] is None
+    assert next_page["data"]["truncated"] is False
+
+    internal_only = manager.domain_dispatcher._normalize_child_result(
+        child,
+        {
+            "success": True,
+            "result": [
+                {"name": "internal", "scope": "internal"},
+                {"name": "iceberg", "scope": "external"},
+            ],
+        },
+        {"include_external": False},
+        (),
+    )
+    assert internal_only["data"]["items"] == [
+        {"name": "internal", "scope": "internal"}
+    ]
+
+
+def test_table_collection_filters_exact_normalized_object_types() -> None:
+    manager = _manager()
+    child = DORIS_DOMAIN_CATALOG.resolve_child(
+        "doris_catalog",
+        "list_tables",
+    )
+
+    result = manager.domain_dispatcher._normalize_child_result(
+        child,
+        {
+            "success": True,
+            "result": [
+                {"name": "orders", "type": "table"},
+                {"name": "orders_view", "type": "view"},
+                {"name": "orders_mv", "type": "materialized_view"},
+            ],
+        },
+        {
+            "database": "analytics",
+            "types": ["view", "materialized_view"],
         },
         (),
     )
 
-    assert result["status"] == "partial"
     assert result["data"]["items"] == [
-        {"name": "internal"},
-        {"name": "iceberg"},
+        {"name": "orders_mv", "type": "materialized_view"},
+        {"name": "orders_view", "type": "view"},
     ]
-    assert result["data"]["next_cursor"] is None
-    assert len(result["warnings"]) == 2
+
+
+def test_collection_cursor_is_bound_to_the_exact_request() -> None:
+    manager = _manager()
+    child = DORIS_DOMAIN_CATALOG.resolve_child(
+        "doris_catalog",
+        "list_tables",
+    )
+    raw = {
+        "success": True,
+        "result": [
+            {"name": f"table_{index:03d}", "type": "table"}
+            for index in range(101)
+        ],
+    }
+    first = manager.domain_dispatcher._normalize_child_result(
+        child,
+        raw,
+        {"database": "analytics"},
+        (),
+    )
+
+    with pytest.raises(
+        ChildArgumentsAdapterError,
+        match="invalid",
+    ):
+        manager.domain_dispatcher._normalize_child_result(
+            child,
+            raw,
+            {
+                "database": "other_database",
+                "page": first["data"]["next_cursor"],
+            },
+            (),
+        )
+
+
+def test_collection_cursor_is_bound_to_the_authorization_principal() -> None:
+    manager = _manager()
+    child = DORIS_DOMAIN_CATALOG.resolve_child(
+        "doris_catalog",
+        "list_tables",
+    )
+    raw = {
+        "success": True,
+        "result": [
+            {"name": f"table_{index:03d}", "type": "table"}
+            for index in range(101)
+        ],
+    }
+    alice = AuthContext(auth_method="basic", user_id="alice")
+    bob = AuthContext(auth_method="basic", user_id="bob")
+    first = manager.domain_dispatcher._normalize_child_result(
+        child,
+        raw,
+        {"database": "analytics"},
+        (),
+        alice,
+    )
+
+    with pytest.raises(ChildArgumentsAdapterError, match="invalid"):
+        manager.domain_dispatcher._normalize_child_result(
+            child,
+            raw,
+            {
+                "database": "analytics",
+                "page": first["data"]["next_cursor"],
+            },
+            (),
+            bob,
+        )
+
+
[email protected]
[email protected](
+    ("raw", "reason_code"),
+    [
+        (
+            {
+                "success": False,
+                "error": "hidden backend message",
+                "error_code": "DORIS_METADATA_PERMISSION_DENIED",
+                "status_code": 403,
+            },
+            "CATALOG_PERMISSION_DENIED",
+        ),
+        (
+            {
+                "success": False,
+                "error": "table does not exist",
+                "error_code": "DORIS_METADATA_NOT_VISIBLE",
+                "status_code": 404,
+            },
+            "CATALOG_OBJECT_NOT_FOUND",
+        ),
+    ],
+)
+async def test_catalog_atomic_failures_have_stable_reason_codes(
+    raw: dict[str, Any],
+    reason_code: str,
+) -> None:
+    manager = _manager("doris_catalog.list_tables")
+    manager._get_db_table_list_tool = AsyncMock(return_value=raw)
+
+    result = await manager.domain_dispatcher.call_domain(
+        "doris_catalog",
+        {
+            "child_tool": "list_tables",
+            "arguments": {"database": "analytics"},
+        },
+        None,
+    )
+
+    assert result.mode == "error"
+    assert result.error.code is DomainErrorCode.CHILD_EXECUTION_FAILED
+    assert result.error.details["reason_code"] == reason_code
+    assert "hidden backend message" not in result.error.message
+
+
[email protected]
+async def test_table_size_preserves_partition_degradation_as_partial_result() 
-> None:
+    manager = _manager("doris_catalog.get_table_size")
+    manager._get_table_data_size_tool = AsyncMock(
+        return_value={
+            "success": True,
+            "result": {
+                "row_count": 10,
+                "data_size_bytes": 100,
+                "partitions": [],
+            },
+            "warnings": [
+                "Partition statistics are unavailable "
+                "(CATALOG_SECTION_UNSUPPORTED)."
+            ],
+            "metadata": {
+                "partition_status": "unavailable",
+                "partition_reason_code": "CATALOG_SECTION_UNSUPPORTED",
+            },
+        }
+    )
+
+    result = await manager.domain_dispatcher.call_domain(
+        "doris_catalog",
+        {
+            "child_tool": "get_table_size",
+            "arguments": {
+                "database": "analytics",
+                "table": "orders",
+                "include_partitions": True,
+            },
+        },
+        None,
+    )
+
+    assert result.mode == "result"
+    child_result = result.to_wire()["data"]
+    assert child_result["status"] == "partial"
+    assert child_result["data"]["row_count"] == 10
+    assert child_result["metadata"]["partition_status"] == "unavailable"
+    manager._get_table_data_size_tool.assert_awaited_once_with(
+        {
+            "db_name": "analytics",
+            "table_name": "orders",
+            "include_partitions": True,
+            "single_replica": False,
+            "_formal_catalog": True,
+        }
+    )
+
+
[email protected]
+async def test_table_context_marks_unsupported_optional_section() -> None:
+    manager = _manager("doris_catalog.get_table_context")
+    manager._get_table_schema_tool = AsyncMock(
+        return_value={"success": True, "result": {"columns": ["id"]}}
+    )
+    manager._get_table_indexes_tool = AsyncMock(
+        return_value={
+            "success": False,
+            "error": "unsupported",
+            "error_code": "CATALOG_SECTION_UNSUPPORTED",
+            "status_code": 501,
+        }
+    )
+
+    result = await manager.domain_dispatcher.call_domain(
+        "doris_catalog",
+        {
+            "child_tool": "get_table_context",
+            "arguments": {
+                "database": "analytics",
+                "table": "orders",
+                "sections": ["indexes"],
+            },
+        },
+        None,
+    )
+
+    assert result.mode == "result"
+    indexes = result.to_wire()["data"]["data"]["indexes"]
+    assert indexes["status"] == "unavailable"
+    assert indexes["data"] == {}
+    assert "CATALOG_SECTION_UNSUPPORTED" in indexes["warnings"][0]
+    assert (
+        result.to_wire()["data"]["evidence"][1]["reason_code"]
+        == "CATALOG_SECTION_UNSUPPORTED"
+    )
 
 
 def test_query_result_normalization_handles_malformed_legacy_shapes() -> None:
@@ -1058,13 +1401,17 @@ def 
test_detail_result_normalization_adds_formal_evidence() -> None:
                 "types": ["table"],
                 "page": "cursor",
             },
-            {"catalog_name": "internal", "db_name": "db"},
+            {
+                "catalog_name": "internal",
+                "db_name": "db",
+                "_formal_catalog": True,
+            },
             0,
         ),
         (
             "adapt:remove_random_string",
             {"include_external": True},
-            {},
+            {"_formal_catalog": True},
             0,
         ),
         (
@@ -1083,9 +1430,11 @@ def 
test_detail_result_normalization_adds_formal_evidence() -> None:
             {
                 "db_name": "db",
                 "table_name": "t",
+                "include_partitions": True,
                 "single_replica": False,
+                "_formal_catalog": True,
             },
-            1,
+            0,
         ),
         (
             "adapt:monitoring_arguments",
diff --git a/test/tools/test_doris_feature_matrix.py 
b/test/tools/test_doris_feature_matrix.py
index e9b86a1..33a66fc 100644
--- a/test/tools/test_doris_feature_matrix.py
+++ b/test/tools/test_doris_feature_matrix.py
@@ -411,6 +411,24 @@ def 
test_master_fe_scope_does_not_inherit_an_unrelated_old_be_version() -> None:
     assert result.compatible is True
 
 
+def test_table_size_uses_master_fe_metadata_with_unknown_backend_version() -> 
None:
+    result = DORIS_FEATURE_MATRIX.evaluate(
+        domain="doris_catalog",
+        child_name="get_table_size",
+        versions=DorisClusterVersionVector.from_comments(
+            master_fe="Doris version doris-4.0.5",
+            backends=(
+                "Doris version doris-4.0.5",
+                "",
+            ),
+        ),
+    )
+
+    assert result.version_scope is CapabilityVersionScope.MASTER_FE
+    assert result.effective_version == "4.0.5"
+    assert result.compatible is True
+
+
 @pytest.mark.parametrize(
     "child_name",
     [
diff --git a/test/tools/test_tools_manager.py b/test/tools/test_tools_manager.py
index 25ed764..dc9cf5a 100644
--- a/test/tools/test_tools_manager.py
+++ b/test/tools/test_tools_manager.py
@@ -219,6 +219,126 @@ class TestDorisToolsManager:
         assert result == expected
         mock_execute.assert_awaited_once_with()
 
+    @pytest.mark.asyncio
+    async def test_formal_catalog_discovery_routes_to_strict_reader(
+        self,
+        tools_manager,
+    ):
+        expected = {"success": True, "result": [{"name": "orders"}]}
+        tools_manager.catalog_metadata.list_catalogs = AsyncMock(
+            return_value=expected
+        )
+        tools_manager.catalog_metadata.list_databases = AsyncMock(
+            return_value=expected
+        )
+        tools_manager.catalog_metadata.list_tables = AsyncMock(
+            return_value=expected
+        )
+
+        catalogs = await tools_manager._get_catalog_list_tool(
+            {"_formal_catalog": True}
+        )
+        databases = await tools_manager._get_db_list_tool(
+            {"catalog_name": "lake", "_formal_catalog": True}
+        )
+        tables = await tools_manager._get_db_table_list_tool(
+            {
+                "catalog_name": "lake",
+                "db_name": "analytics",
+                "_formal_catalog": True,
+            }
+        )
+
+        assert catalogs == databases == tables == expected
+        tools_manager.catalog_metadata.list_catalogs.assert_awaited_once_with()
+        tools_manager.catalog_metadata.list_databases.assert_awaited_once_with(
+            catalog_name="lake"
+        )
+        tools_manager.catalog_metadata.list_tables.assert_awaited_once_with(
+            database_name="analytics",
+            catalog_name="lake",
+        )
+
+    @pytest.mark.asyncio
+    async def test_formal_table_context_routes_all_five_section_handlers(
+        self,
+        tools_manager,
+    ):
+        expected = {"success": True, "result": {}}
+        tools_manager.catalog_metadata.get_table_schema = AsyncMock(
+            return_value=expected
+        )
+        tools_manager.catalog_metadata.get_table_comment = AsyncMock(
+            return_value=expected
+        )
+        tools_manager.catalog_metadata.get_column_comments = AsyncMock(
+            return_value=expected
+        )
+        tools_manager.catalog_metadata.get_table_indexes = AsyncMock(
+            return_value=expected
+        )
+        tools_manager.catalog_metadata.get_table_basic = AsyncMock(
+            return_value=expected
+        )
+        arguments = {
+            "catalog_name": "internal",
+            "db_name": "analytics",
+            "table_name": "orders",
+            "_formal_catalog": True,
+        }
+
+        results = [
+            await tools_manager._get_table_schema_tool(dict(arguments)),
+            await tools_manager._get_table_comment_tool(dict(arguments)),
+            await tools_manager._get_table_column_comments_tool(
+                dict(arguments)
+            ),
+            await tools_manager._get_table_indexes_tool(dict(arguments)),
+            await tools_manager._get_table_basic_info_tool(dict(arguments)),
+        ]
+
+        assert results == [expected] * 5
+        for method in (
+            tools_manager.catalog_metadata.get_table_schema,
+            tools_manager.catalog_metadata.get_table_comment,
+            tools_manager.catalog_metadata.get_column_comments,
+            tools_manager.catalog_metadata.get_table_indexes,
+            tools_manager.catalog_metadata.get_table_basic,
+        ):
+            method.assert_awaited_once_with(
+                table_name="orders",
+                database_name="analytics",
+                catalog_name="internal",
+            )
+
+    @pytest.mark.asyncio
+    async def test_formal_table_size_preserves_partition_request(
+        self,
+        tools_manager,
+    ):
+        expected = {"success": True, "result": {"row_count": 1}}
+        tools_manager.catalog_metadata.get_table_size = AsyncMock(
+            return_value=expected
+        )
+
+        result = await tools_manager._get_table_data_size_tool(
+            {
+                "catalog_name": "internal",
+                "db_name": "analytics",
+                "table_name": "orders",
+                "include_partitions": True,
+                "_formal_catalog": True,
+            }
+        )
+
+        assert result == expected
+        tools_manager.catalog_metadata.get_table_size.assert_awaited_once_with(
+            table_name="orders",
+            database_name="analytics",
+            catalog_name="internal",
+            include_partitions=True,
+        )
+
 
     @pytest.mark.asyncio
     async def test_invalid_tool_name(self, tools_manager):
diff --git a/test/utils/test_catalog_metadata.py 
b/test/utils/test_catalog_metadata.py
new file mode 100644
index 0000000..b110045
--- /dev/null
+++ b/test/utils/test_catalog_metadata.py
@@ -0,0 +1,369 @@
+# 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.
+
+"""Production contract tests for strict formal Catalog metadata reads."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+
+from doris_mcp_server.utils.catalog_metadata import (
+    CatalogMetadataFailure,
+    DorisCatalogMetadataReader,
+)
+
+
+class _ConnectionManager:
+    def __init__(self, *responses: Any) -> None:
+        self.responses = list(responses)
+        self.calls: list[tuple[str, tuple[Any, ...] | None]] = []
+
+    async def execute_query(
+        self,
+        _session_id: str,
+        sql: str,
+        params: tuple[Any, ...] | None,
+        _auth_context: Any,
+    ) -> SimpleNamespace:
+        self.calls.append((sql, params))
+        response = self.responses.pop(0)
+        if isinstance(response, Exception):
+            raise response
+        return SimpleNamespace(data=response)
+
+
+def _reader(*responses: Any) -> tuple[DorisCatalogMetadataReader, 
_ConnectionManager]:
+    manager = _ConnectionManager(*responses)
+    return (
+        DorisCatalogMetadataReader(manager),  # type: ignore[arg-type]
+        manager,
+    )
+
+
[email protected]
+async def test_discovery_reads_rich_catalog_database_and_table_metadata() -> 
None:
+    reader, manager = _reader(
+        [
+            {
+                "CatalogId": 0,
+                "CatalogName": "internal",
+                "Type": "internal",
+                "IsCurrent": "Yes",
+            },
+            {
+                "CatalogId": 1,
+                "CatalogName": "lake",
+                "Type": "iceberg",
+                "IsCurrent": "No",
+            },
+        ],
+        [{"Database": "analytics"}],
+        [
+            {
+                "Tables_in_analytics": "orders",
+                "Table_type": "BASE TABLE",
+                "Storage_format": "V2",
+                "Inverted_index_storage_format": "V1",
+            },
+            {
+                "Tables_in_analytics": "orders_view",
+                "Table_type": "VIEW",
+                "Storage_format": "NONE",
+                "Inverted_index_storage_format": "NONE",
+            },
+            {
+                "Tables_in_analytics": "orders_mv",
+                "Table_type": "MATERIALIZED VIEW",
+            },
+        ],
+    )
+
+    catalogs = await reader.list_catalogs()
+    databases = await reader.list_databases(catalog_name="lake")
+    tables = await reader.list_tables(
+        database_name="analytics",
+        catalog_name="lake",
+    )
+
+    assert catalogs["result"] == [
+        {
+            "name": "internal",
+            "type": "internal",
+            "scope": "internal",
+            "is_current": True,
+        },
+        {
+            "name": "lake",
+            "type": "iceberg",
+            "scope": "external",
+            "is_current": False,
+        },
+    ]
+    assert databases["result"] == [
+        {"name": "analytics", "catalog": "lake"}
+    ]
+    assert [item["type"] for item in tables["result"]] == [
+        "table",
+        "view",
+        "materialized_view",
+    ]
+    assert manager.calls == [
+        ("SHOW CATALOGS", None),
+        ("SHOW DATABASES FROM `lake`", None),
+        ("SHOW FULL TABLES FROM `lake`.`analytics`", None),
+    ]
+
+
[email protected]
+async def test_table_context_sections_use_strict_read_only_metadata_paths() -> 
None:
+    reader, manager = _reader(
+        [
+            {
+                "Field": "id",
+                "Type": "bigint",
+                "Null": "NO",
+                "Key": "PRI",
+                "Default": None,
+                "Comment": "identifier",
+                "Extra": "",
+            }
+        ],
+        [{"TABLE_COMMENT": "orders"}],
+        [
+            {"COLUMN_NAME": "id", "COLUMN_COMMENT": "identifier"},
+            {"COLUMN_NAME": "amount", "COLUMN_COMMENT": ""},
+        ],
+        [
+            {
+                "Key_name": "idx_amount",
+                "Column_name": "amount",
+                "Non_unique": 1,
+                "Index_type": "INVERTED",
+            },
+            {
+                "Key_name": "idx_compound",
+                "Column_name": "id",
+                "Non_unique": 0,
+                "Index_type": "BTREE",
+            },
+            {
+                "Key_name": "idx_compound",
+                "Column_name": "amount",
+                "Non_unique": 0,
+                "Index_type": "BTREE",
+            },
+        ],
+        [
+            {
+                "TABLE_TYPE": "BASE TABLE",
+                "ENGINE": "OLAP",
+                "TABLE_ROWS": 7,
+                "AVG_ROW_LENGTH": 20,
+                "DATA_LENGTH": 140,
+                "INDEX_LENGTH": 10,
+                "CREATE_TIME": "2026-07-01 00:00:00",
+                "UPDATE_TIME": "2026-07-30 00:00:00",
+                "TABLE_COMMENT": "orders",
+            }
+        ],
+    )
+
+    schema = await reader.get_table_schema(
+        table_name="orders",
+        database_name="analytics",
+        catalog_name="internal",
+    )
+    table_comment = await reader.get_table_comment(
+        table_name="orders",
+        database_name="analytics",
+        catalog_name="internal",
+    )
+    column_comments = await reader.get_column_comments(
+        table_name="orders",
+        database_name="analytics",
+        catalog_name="internal",
+    )
+    indexes = await reader.get_table_indexes(
+        table_name="orders",
+        database_name="analytics",
+        catalog_name="internal",
+    )
+    basic = await reader.get_table_basic(
+        table_name="orders",
+        database_name="analytics",
+        catalog_name="internal",
+    )
+
+    assert schema["result"]["column_count"] == 1
+    assert schema["result"]["columns"][0]["comment"] == "identifier"
+    assert table_comment["result"] == {"comment": "orders"}
+    assert column_comments["result"] == {
+        "id": "identifier",
+        "amount": "",
+    }
+    assert indexes["result"]["items"][1]["columns"] == ["id", "amount"]
+    assert basic["result"]["row_count"] == 7
+    assert basic["result"]["data_size_bytes"] == 140
+    assert manager.calls[0] == (
+        "SHOW FULL COLUMNS FROM `analytics`.`orders`",
+        None,
+    )
+    assert all(
+        statement.lstrip().upper().startswith(("SHOW", "SELECT"))
+        for statement, _params in manager.calls
+    )
+
+
[email protected]
+async def test_table_size_returns_optional_partition_statistics() -> None:
+    reader, manager = _reader(
+        [
+            {
+                "TABLE_ROWS": "9",
+                "DATA_LENGTH": "200",
+                "INDEX_LENGTH": "20",
+            }
+        ],
+        [
+            {
+                "PARTITION_NAME": "p1",
+                "PARTITION_ORDINAL_POSITION": 1,
+                "TABLE_ROWS": 4,
+                "DATA_LENGTH": 80,
+                "INDEX_LENGTH": 8,
+            }
+        ],
+    )
+
+    result = await reader.get_table_size(
+        table_name="orders",
+        database_name="analytics",
+        catalog_name=None,
+        include_partitions=True,
+    )
+
+    assert result["result"]["total_size_bytes"] == 220
+    assert result["result"]["partitions"] == [
+        {
+            "name": "p1",
+            "ordinal_position": 1,
+            "row_count": 4,
+            "data_size_bytes": 80,
+            "index_size_bytes": 8,
+        }
+    ]
+    assert result["warnings"] == []
+    assert result["metadata"]["partition_status"] == "available"
+    assert "`information_schema`.`partitions`" in manager.calls[1][0]
+
+
[email protected]
+async def 
test_table_size_preserves_base_result_when_partitions_are_unsupported() -> None:
+    reader, _manager = _reader(
+        [{"TABLE_ROWS": 9, "DATA_LENGTH": 200, "INDEX_LENGTH": 20}],
+        RuntimeError(1109, "Unknown system table"),
+    )
+
+    result = await reader.get_table_size(
+        table_name="orders",
+        database_name="analytics",
+        catalog_name="lake",
+        include_partitions=True,
+    )
+
+    assert result["result"]["row_count"] == 9
+    assert result["result"]["partitions"] == []
+    assert result["metadata"]["partition_status"] == "unavailable"
+    assert (
+        result["metadata"]["partition_reason_code"]
+        == "CATALOG_SECTION_UNSUPPORTED"
+    )
+    assert result["warnings"] == [
+        "Partition statistics are unavailable for the requested table "
+        "(CATALOG_SECTION_UNSUPPORTED)."
+    ]
+
+
[email protected]
[email protected](
+    ("failure", "reason_code", "status_code", "retryable"),
+    [
+        (
+            RuntimeError(1142, "SELECT command denied"),
+            "CATALOG_PERMISSION_DENIED",
+            403,
+            False,
+        ),
+        (
+            RuntimeError(1146, "Table does not exist"),
+            "CATALOG_OBJECT_NOT_FOUND",
+            404,
+            False,
+        ),
+        (
+            RuntimeError(1064, "Syntax is not supported"),
+            "CATALOG_SECTION_UNSUPPORTED",
+            501,
+            False,
+        ),
+        (
+            TimeoutError("timed out"),
+            "CATALOG_BACKEND_UNAVAILABLE",
+            503,
+            True,
+        ),
+    ],
+)
+async def test_catalog_failures_are_sanitized_and_distinguishable(
+    failure: Exception,
+    reason_code: str,
+    status_code: int,
+    retryable: bool,
+) -> None:
+    reader, _manager = _reader(failure)
+
+    with pytest.raises(CatalogMetadataFailure) as exc_info:
+        await reader.list_catalogs()
+
+    assert exc_info.value.reason_code == reason_code
+    assert exc_info.value.status_code == status_code
+    assert exc_info.value.retryable is retryable
+    assert "SELECT command denied" not in str(exc_info.value)
+
+
[email protected]
+async def 
test_empty_schema_and_invalid_identifier_fail_before_ambiguous_results() -> 
None:
+    empty_reader, _manager = _reader([])
+    invalid_reader, invalid_manager = _reader([])
+
+    with pytest.raises(CatalogMetadataFailure) as missing:
+        await empty_reader.get_table_schema(
+            table_name="missing",
+            database_name="analytics",
+            catalog_name=None,
+        )
+    with pytest.raises(CatalogMetadataFailure) as invalid:
+        await invalid_reader.list_tables(
+            database_name="analytics; DROP TABLE orders",
+            catalog_name=None,
+        )
+
+    assert missing.value.reason_code == "CATALOG_OBJECT_NOT_FOUND"
+    assert invalid.value.reason_code == "CATALOG_ARGUMENT_INVALID"
+    assert invalid_manager.calls == []


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to