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 d85a3a1 feat: add bounded hierarchical domain discovery (#168)
d85a3a1 is described below
commit d85a3a12d0604353ceab90cabccc6549f14c96eb
Author: Yijia Su <[email protected]>
AuthorDate: Fri Jul 31 04:05:13 2026 +0800
feat: add bounded hierarchical domain discovery (#168)
---
README.md | 26 +-
docs/tool-registry.md | 45 +--
doris_mcp_server/auth/operation_policy.py | 14 +-
doris_mcp_server/tools/domain_manifest.py | 580 ++++++++++++++++++++++++++++++
doris_mcp_server/tools/domain_models.py | 74 +++-
doris_mcp_server/tools/tool_registry.py | 20 +-
doris_mcp_server/tools/tools_manager.py | 28 +-
test/protocol/test_mcp_v2_protocol.py | 65 ++--
test/security/test_auth_cross_matrix.py | 61 ++--
test/security/test_operation_policy.py | 37 ++
test/tools/test_custom_tool_provider.py | 48 +--
test/tools/test_domain_manifest.py | 535 +++++++++++++++++++++++++++
test/tools/test_domain_models.py | 39 +-
test/tools/test_tool_registry.py | 14 +-
test/tools/test_tools_manager.py | 9 +-
test/tools/test_tools_operation_guard.py | 19 +-
16 files changed, 1446 insertions(+), 168 deletions(-)
diff --git a/README.md b/README.md
index 4629b3a..fd9ea39 100644
--- a/README.md
+++ b/README.md
@@ -388,12 +388,17 @@ cp .env.example .env
### Available MCP Tools
-The canonical catalog is generated from the same `ToolDefinitionRegistry` used
-for MCP JSON Schemas, execution dispatch, authorization policy, and audit
-metadata. See [docs/tool-registry.md](docs/tool-registry.md). The checked-in
-catalog is verified against the runtime registry by the test suite.
-
-**Doris-backed OAuth note:** The generated catalog describes global server
capabilities. Doris-backed OAuth uses configuration gates for its operation
surface. MCP resources are available with resource metadata caching disabled.
Reviewed metadata tools are callable when `DORIS_OAUTH_DB_TOOLS_ENABLED=true`;
`exec_query` and `get_sql_explain` are callable when their Doris OAuth
query/explain gates are enabled. These MySQL-channel operations run through the
logged-in Doris user pool, so Dor [...]
+MCP `tools/list` exposes eight stable read-only domain tools. Call a domain
+with an empty object to progressively discover its exact authorized child
+tools, schemas, version support, availability, evidence, and risk annotations.
+See [docs/tool-registry.md](docs/tool-registry.md). The checked-in catalog is
+generated from the same validated domain definitions used at runtime.
+
+`tool:list` authorizes domain-manifest discovery for OAuth sessions; it does
+not authorize child execution. Children without discovery permission are
+omitted, while authorized but unavailable children remain visible with
+`callable=false`. Doris RBAC remains the final data authorization backend for
+all Doris object access.
### 4. Run the Service
@@ -1594,11 +1599,10 @@ Tool(
)
```
-The registry derives the execution handler, safe audit fields, and generated
-documentation from that definition. Add the name to exactly one policy class:
-metadata, query, explain, or restricted. Run the registry tests and refresh
-`docs/tool-registry.md` from `ToolDefinitionRegistry.render_markdown()`; the
-test suite rejects documentation drift.
+Custom providers are internal capability sources and do not create additional
+top-level MCP tools. Integrate each provider capability into one formal domain
+child with a support contract, authorization policy, input/output schemas, and
+a deterministic handler binding. The test suite rejects public catalog drift.
### 4. Advanced Features
diff --git a/docs/tool-registry.md b/docs/tool-registry.md
index 3d3e09a..a005484 100644
--- a/docs/tool-registry.md
+++ b/docs/tool-registry.md
@@ -1,33 +1,18 @@
-# Doris MCP Tool Registry
+# Doris MCP Domain Tool Registry
-<!-- Generated from ToolDefinitionRegistry; do not edit by hand. -->
+<!-- Generated from DomainManifestService; do not edit by hand. -->
-| Tool | Policy | Risk | Handler | Audit event | Parameters |
-|---|---|---|---|---|---|
-| `exec_query` | `query` | `query` | `_exec_query_tool` |
`mcp.tool.call.exec_query` | `catalog_name`, `db_name`, `max_bytes`,
`max_rows`, `sql`*, `timeout` |
-| `get_table_schema` | `metadata` | `metadata` | `_get_table_schema_tool` |
`mcp.tool.call.get_table_schema` | `catalog_name`, `db_name`, `table_name`* |
-| `get_db_table_list` | `metadata` | `metadata` | `_get_db_table_list_tool` |
`mcp.tool.call.get_db_table_list` | `catalog_name`, `db_name` |
-| `get_db_list` | `metadata` | `metadata` | `_get_db_list_tool` |
`mcp.tool.call.get_db_list` | `catalog_name` |
-| `get_table_comment` | `metadata` | `metadata` | `_get_table_comment_tool` |
`mcp.tool.call.get_table_comment` | `catalog_name`, `db_name`, `table_name`* |
-| `get_table_column_comments` | `metadata` | `metadata` |
`_get_table_column_comments_tool` | `mcp.tool.call.get_table_column_comments` |
`catalog_name`, `db_name`, `table_name`* |
-| `get_table_indexes` | `metadata` | `metadata` | `_get_table_indexes_tool` |
`mcp.tool.call.get_table_indexes` | `catalog_name`, `db_name`, `table_name`* |
-| `get_recent_audit_logs` | `restricted` | `high` |
`_get_recent_audit_logs_tool` | `mcp.tool.call.get_recent_audit_logs` | `days`,
`limit` |
-| `get_catalog_list` | `metadata` | `metadata` | `_get_catalog_list_tool` |
`mcp.tool.call.get_catalog_list` | `random_string`* |
-| `get_sql_explain` | `explain` | `explain` | `_get_sql_explain_tool` |
`mcp.tool.call.get_sql_explain` | `catalog_name`, `db_name`, `sql`*, `verbose` |
-| `get_sql_profile` | `restricted` | `high` | `_get_sql_profile_tool` |
`mcp.tool.call.get_sql_profile` | `catalog_name`, `db_name`, `sql`*, `timeout` |
-| `get_table_data_size` | `restricted` | `high` | `_get_table_data_size_tool`
| `mcp.tool.call.get_table_data_size` | `db_name`, `single_replica`,
`table_name` |
-| `get_monitoring_metrics` | `restricted` | `high` |
`_get_monitoring_metrics_tool` | `mcp.tool.call.get_monitoring_metrics` |
`content_type`, `include_raw_metrics`, `monitor_type`, `priority`, `role` |
-| `get_memory_stats` | `restricted` | `high` | `_get_memory_stats_tool` |
`mcp.tool.call.get_memory_stats` | `data_type`, `include_details`,
`time_range`, `tracker_names`, `tracker_type` |
-| `get_table_basic_info` | `restricted` | `high` |
`_get_table_basic_info_tool` | `mcp.tool.call.get_table_basic_info` |
`catalog_name`, `db_name`, `table_name`* |
-| `analyze_columns` | `restricted` | `high` | `_analyze_columns_tool` |
`mcp.tool.call.analyze_columns` | `analysis_types`, `catalog_name`, `columns`*,
`db_name`, `detailed_response`, `sample_size`, `table_name`* |
-| `analyze_table_storage` | `restricted` | `high` |
`_analyze_table_storage_tool` | `mcp.tool.call.analyze_table_storage` |
`catalog_name`, `db_name`, `detailed_response`, `table_name`* |
-| `trace_column_lineage` | `restricted` | `high` |
`_trace_column_lineage_tool` | `mcp.tool.call.trace_column_lineage` |
`analysis_depth`, `catalog_name`, `include_transformations`, `target_columns`* |
-| `monitor_data_freshness` | `restricted` | `high` |
`_monitor_data_freshness_tool` | `mcp.tool.call.monitor_data_freshness` |
`catalog_name`, `db_name`, `freshness_threshold_hours`,
`include_update_patterns`, `table_names` |
-| `analyze_data_access_patterns` | `restricted` | `high` |
`_analyze_data_access_patterns_tool` |
`mcp.tool.call.analyze_data_access_patterns` | `days`, `include_system_users`,
`min_query_threshold` |
-| `analyze_data_flow_dependencies` | `restricted` | `high` |
`_analyze_data_flow_dependencies_tool` |
`mcp.tool.call.analyze_data_flow_dependencies` | `analysis_depth`,
`catalog_name`, `db_name`, `include_views`, `target_table` |
-| `analyze_slow_queries_topn` | `restricted` | `high` |
`_analyze_slow_queries_topn_tool` | `mcp.tool.call.analyze_slow_queries_topn` |
`days`, `include_patterns`, `min_execution_time_ms`, `top_n` |
-| `analyze_resource_growth_curves` | `restricted` | `high` |
`_analyze_resource_growth_curves_tool` |
`mcp.tool.call.analyze_resource_growth_curves` | `days`, `detailed_response`,
`include_predictions`, `resource_types` |
-| `exec_adbc_query` | `restricted` | `high` | `_exec_adbc_query_tool` |
`mcp.tool.call.exec_adbc_query` | `max_bytes`, `max_rows`, `return_format`,
`sql`*, `timeout` |
-| `get_adbc_connection_info` | `restricted` | `high` |
`_get_adbc_connection_info_tool` | `mcp.tool.call.get_adbc_connection_info` |
None |
+| Domain tool | Read-only boundary | Child count | Discovery |
+|---|---|---:|---|
+| `doris_catalog` | Explore Doris catalogs, databases, tables, schemas,
comments, indexes, key models, storage metadata, and object sizes. | 5 | Call
with `{}` |
+| `doris_query` | Execute read-only Doris SQL, inspect plans and profiles,
review slow queries, and use the optional ADBC provider. | 7 | Call with `{}` |
+| `doris_cluster` | Inspect Doris health, nodes, tasks, monitoring, memory,
cache, compaction, workload, compute groups, and runtime capabilities. | 11 |
Call with `{}` |
+| `doris_pipeline` | Inspect ingestion, load health, materialized views, data
freshness, and upstream or downstream dependencies. | 5 | Call with `{}` |
+| `doris_search` | Search Doris data with text, vector, or hybrid retrieval
and inspect or diagnose search analyzers and indexes. | 4 | Call with `{}` |
+| `doris_governance` | Analyze columns and storage, inspect lineage and access
evidence, read audit metadata, and review UDF or authentication mappings. | 8 |
Call with `{}` |
+| `doris_lakehouse` | Inspect external catalogs, lakehouse tables, snapshots,
partitions, pushdown behavior, and Variant semi-structured columns. | 3 | Call
with `{}` |
+| `doris_semantic` | Discover validated Ossie models bound to Doris and read
permission-filtered semantic summaries, context, and mapping status. | 4 | Call
with `{}` |
-Required parameters are marked with `*`. Tool descriptions and JSON Schemas
are exposed directly by MCP `tools/list` from the same registry entries.
+MCP `tools/list` exposes only these eight stable read-only domains. Call a
domain with an empty object to receive its authorized, bounded child manifest.
The manifest contains exact child names, schemas, version support,
availability, evidence, and risk annotations.
+
+A discoverable child that is not supported by the current runtime remains in
the manifest with `callable=false`. A child without discovery permission is
omitted entirely. Internal handlers, custom providers, and pre-1.0 flat names
are not advertised by `tools/list`.
diff --git a/doris_mcp_server/auth/operation_policy.py
b/doris_mcp_server/auth/operation_policy.py
index 8bfad49..4f1d515 100644
--- a/doris_mcp_server/auth/operation_policy.py
+++ b/doris_mcp_server/auth/operation_policy.py
@@ -218,6 +218,15 @@ def _tool_policy(tool_name: str, auth_context: Any = None)
-> OperationPolicy:
error_code="UNKNOWN_OPERATION",
)
+ if registry_policy.policy_class == "domain":
+ return OperationPolicy(
+ name=f"tool:{tool_name}",
+ required_scope="tool:list",
+ doris_oauth_policy="allow",
+ channel="manifest",
+ risk="metadata",
+ )
+
if registry_policy.policy_class == "metadata":
if not _metadata_tools_enabled(auth_context):
return _denied_tool_policy(
@@ -352,7 +361,8 @@ def _has_scope(auth_context: Any, required_scope: str |
None) -> bool:
def _external_oauth_required_scope(operation: str) -> str:
if operation.startswith("tool:"):
tool_name = operation.split(":", 1)[1]
- if policy_definition_for_tool(tool_name) is None:
+ policy = policy_definition_for_tool(tool_name)
+ if policy is None:
raise OperationAuthorizationError(
f"Unknown MCP operation: {operation}",
status_code=403,
@@ -360,6 +370,8 @@ def _external_oauth_required_scope(operation: str) -> str:
required_scope=f"tool:call:{tool_name}",
operation=operation,
)
+ if policy.policy_class == "domain":
+ return "tool:list"
return f"tool:call:{tool_name}"
required_scope = EXTERNAL_OAUTH_OPERATION_SCOPES.get(operation)
diff --git a/doris_mcp_server/tools/domain_manifest.py
b/doris_mcp_server/tools/domain_manifest.py
new file mode 100644
index 0000000..919c51a
--- /dev/null
+++ b/doris_mcp_server/tools/domain_manifest.py
@@ -0,0 +1,580 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Bounded progressive discovery for the eight read-only Doris domains."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from collections.abc import Callable, Mapping, Sequence
+from datetime import UTC, datetime
+from typing import Any, Protocol
+
+from mcp.types import Tool, ToolAnnotations
+from pydantic import ValidationError
+
+from ..auth.operation_policy import (
+ authorize_operation,
+ filter_tools_for_auth_context,
+)
+from ..utils.logger import get_audit_logger
+from ..utils.security import get_current_auth_context
+from .domain_catalog import DORIS_DOMAIN_CATALOG, DorisDomainCatalog
+from .domain_models import (
+ Availability,
+ AvailabilityStatus,
+ ChildManifestEntry,
+ ChildToolDefinition,
+ DiscoveryEnvelope,
+ DomainDefinition,
+ DomainErrorCode,
+ DomainToolRequest,
+ ErrorEnvelope,
+ ManifestVersionSupport,
+ StandardError,
+)
+
+MAX_CHILD_DESCRIPTION_CHARACTERS = 800
+MAX_CHILD_SCHEMA_BYTES = 4 * 1024
+MAX_SCHEMA_ENUM_VALUES = 32
+MANIFEST_FORMAT_VERSION = "1"
+
+DOMAIN_DISCOVERY_DESCRIPTION_SUFFIX = (
+ " Call with an empty object to discover the exact child tools and schemas "
+ "available in this domain. Then call this same tool with the returned "
+ "child_tool name and arguments."
+)
+
+DOMAIN_TOOL_INPUT_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {
+ "child_tool": {
+ "type": "string",
+ "minLength": 1,
+ "description": (
+ "Exact child tool name returned by this domain manifest."
+ ),
+ },
+ "arguments": {
+ "type": "object",
+ "description": (
+ "Arguments validated against the selected child tool schema."
+ ),
+ },
+ "manifest_version": {
+ "type": "string",
+ "minLength": 1,
+ "description": (
+ "Optional manifest version returned by domain discovery."
+ ),
+ },
+ },
+ "additionalProperties": False,
+}
+
+# The complete child contract is validated by DiscoveryEnvelope before it
reaches
+# the protocol layer. This compact outer schema avoids repeating the manifest
+# implementation schema eight times in tools/list.
+DOMAIN_DISCOVERY_OUTPUT_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {
+ "mode": {"type": "string", "const": "manifest"},
+ "domain": {"type": "string"},
+ "manifest_version": {"type": "string"},
+ "generated_at": {"type": "string", "format": "date-time"},
+ "children": {
+ "type": "array",
+ "items": {"type": "object"},
+ },
+ },
+ "required": [
+ "mode",
+ "domain",
+ "manifest_version",
+ "generated_at",
+ "children",
+ ],
+ "additionalProperties": False,
+}
+
+
+class DomainManifestError(ValueError):
+ """Raised when a domain manifest cannot be rendered safely."""
+
+
+class DomainManifestBudgetError(DomainManifestError):
+ """Raised when a public domain manifest exceeds a hard context budget."""
+
+
+class DomainAvailabilityProvider(Protocol):
+ """Resolve the current capability state for one authorized child."""
+
+ async def availability_for(
+ self,
+ domain: DomainDefinition,
+ child: ChildToolDefinition,
+ auth_context: Any | None,
+ ) -> Availability:
+ """Return authoritative availability for a single child."""
+
+
+class PendingCapabilityProvider:
+ """Fail-closed provider used until runtime capability snapshots are
enabled."""
+
+ async def availability_for(
+ self,
+ domain: DomainDefinition,
+ child: ChildToolDefinition,
+ auth_context: Any | None,
+ ) -> Availability:
+ del domain, child, auth_context
+ return pending_capability_availability()
+
+
+ChildDiscoveryPolicy = Callable[
+ [Any | None, DomainDefinition, ChildToolDefinition],
+ bool,
+]
+
+
+def pending_capability_availability() -> Availability:
+ """Return the explicit pre-probe state without guessing backend support."""
+ return Availability(
+ status=AvailabilityStatus.UNKNOWN,
+ callable=False,
+ reason_code="CAPABILITY_SNAPSHOT_PENDING",
+ evidence_sources=("catalog_contract",),
+ )
+
+
+def authorized_child_discovery(
+ auth_context: Any | None,
+ domain: DomainDefinition,
+ child: ChildToolDefinition,
+) -> bool:
+ """Apply explicit child grants without changing legacy local-session
access."""
+ del domain
+ if auth_context is None:
+ return True
+
+ controls = {
+ str(value)
+ for collection_name in ("permissions", "oauth_scopes")
+ for value in (getattr(auth_context, collection_name, None) or ())
+ }
+ child_controls = {
+ value
+ for value in controls
+ if value.startswith(("child:call:", "child:discover:"))
+ }
+ if not child_controls:
+ return True
+
+ discover_policy = child.authorization_policy.replace(
+ "child:call:",
+ "child:discover:",
+ 1,
+ )
+ return (
+ child.authorization_policy in child_controls
+ or discover_policy in child_controls
+ )
+
+
+class DomainManifestService:
+ """Render top-level domain tools and deterministic authorized manifests."""
+
+ def __init__(
+ self,
+ *,
+ catalog: DorisDomainCatalog = DORIS_DOMAIN_CATALOG,
+ availability_provider: DomainAvailabilityProvider | None = None,
+ discovery_policy: ChildDiscoveryPolicy = authorized_child_discovery,
+ clock: Callable[[], datetime] | None = None,
+ ) -> None:
+ catalog.validate_integrity()
+ self._catalog = catalog
+ self._availability_provider = (
+ availability_provider or PendingCapabilityProvider()
+ )
+ self._discovery_policy = discovery_policy
+ self._clock = clock or (lambda: datetime.now(UTC))
+ self._domain_names = frozenset(
+ domain.name for domain in self._catalog.domains
+ )
+ self._tools = tuple(
+ _build_top_level_tool(domain)
+ for domain in self._catalog.domains
+ )
+ self.validate_default_manifest_budgets()
+
+ @property
+ def domain_names(self) -> frozenset[str]:
+ return self._domain_names
+
+ def handles(self, name: str) -> bool:
+ """Return whether a name is one of the exact top-level domains."""
+ return name in self._domain_names
+
+ def list_tools(self) -> list[Tool]:
+ """Return all stable read-only top-level domain tools."""
+ return list(self._tools)
+
+ def render_markdown(self) -> str:
+ """Render deterministic public documentation from the domain
catalog."""
+ lines = [
+ "# Doris MCP Domain Tool Registry",
+ "",
+ "<!-- Generated from DomainManifestService; do not edit by hand.
-->",
+ "",
+ "| Domain tool | Read-only boundary | Child count | Discovery |",
+ "|---|---|---:|---|",
+ ]
+ for domain in self._catalog.domains:
+ lines.append(
+ f"| `{domain.name}` | {domain.description} | "
+ f"{len(domain.children)} | Call with `{{}}` |"
+ )
+ lines.extend(
+ [
+ "",
+ "MCP `tools/list` exposes only these eight stable read-only "
+ "domains. Call a domain with an empty object to receive its "
+ "authorized, bounded child manifest. The manifest contains "
+ "exact child names, schemas, version support, availability, "
+ "evidence, and risk annotations.",
+ "",
+ "A discoverable child that is not supported by the current "
+ "runtime remains in the manifest with `callable=false`. A
child "
+ "without discovery permission is omitted entirely. Internal "
+ "handlers, custom providers, and pre-1.0 flat names are not "
+ "advertised by `tools/list`.",
+ "",
+ ]
+ )
+ return "\n".join(lines)
+
+ async def call(
+ self,
+ domain_name: str,
+ arguments: Mapping[str, Any],
+ auth_context: Any | None,
+ ) -> DiscoveryEnvelope | ErrorEnvelope:
+ """Handle discovery now and reject child execution until the
dispatcher."""
+ domain = self._catalog.resolve_domain(domain_name)
+ try:
+ request = DomainToolRequest.model_validate(dict(arguments))
+ except ValidationError:
+ return ErrorEnvelope(
+ mode="error",
+ domain=domain.name,
+ error=StandardError(
+ code=DomainErrorCode.CHILD_ARGUMENTS_INVALID,
+ message="Domain tool arguments are invalid.",
+ retryable=False,
+ details={"rediscover": True},
+ ),
+ )
+
+ if not request.is_discovery:
+ return ErrorEnvelope(
+ mode="error",
+ domain=domain.name,
+ child_tool=request.child_tool,
+ manifest_version=request.manifest_version,
+ error=StandardError(
+ code=DomainErrorCode.CHILD_CAPABILITY_UNAVAILABLE,
+ message=(
+ "Exact child execution is not enabled by this runtime "
+ "stage."
+ ),
+ retryable=False,
+ details={"rediscover": False},
+ ),
+ )
+
+ return await self.discover(domain, auth_context)
+
+ async def discover(
+ self,
+ domain: DomainDefinition,
+ auth_context: Any | None,
+ ) -> DiscoveryEnvelope:
+ """Build the current authorized manifest in stable catalog order."""
+ entries: list[ChildManifestEntry] = []
+ for child in domain.children:
+ if not self._discovery_policy(auth_context, domain, child):
+ continue
+ availability = await self._availability_provider.availability_for(
+ domain,
+ child,
+ auth_context,
+ )
+ entries.append(_render_child(child, availability))
+
+ return _build_manifest(
+ domain,
+ tuple(entries),
+ generated_at=self._clock(),
+ )
+
+ def validate_default_manifest_budgets(self) -> None:
+ """Fail startup if the maximum public catalog cannot fit its
budgets."""
+ generated_at = datetime(2000, 1, 1, tzinfo=UTC)
+ for domain in self._catalog.domains:
+ entries = tuple(
+ _render_child(child, pending_capability_availability())
+ for child in domain.children
+ )
+ _build_manifest(
+ domain,
+ entries,
+ generated_at=generated_at,
+ )
+
+
+class DomainManifestManagerMixin:
+ """Integrate domain discovery without growing the legacy handler
manager."""
+
+ _domain_manifest_service: DomainManifestService
+
+ @property
+ def domain_manifest_service(self) -> DomainManifestService:
+ """Return the discovery service, including minimal legacy fixtures."""
+ service = getattr(self, "_domain_manifest_service", None)
+ if service is None:
+ service = DomainManifestService()
+ self._domain_manifest_service = service
+ return service
+
+ async def list_tools(self) -> list[Tool]:
+ """List the eight stable read-only domains visible to this
principal."""
+ return filter_tools_for_auth_context(
+ get_current_auth_context(),
+ self.domain_manifest_service.list_tools(),
+ )
+
+ async def _call_domain_tool(
+ self,
+ name: str,
+ arguments: dict[str, Any],
+ ) -> str:
+ """Return a bounded domain manifest without exposing internal
handlers."""
+ auth_context = get_current_auth_context()
+ authorize_operation(auth_context, f"tool:{name}")
+ result = await self.domain_manifest_service.call(
+ name,
+ arguments,
+ auth_context,
+ )
+ get_audit_logger().info(
+ "event=mcp.tool.call.%s tool=%s category=domain risk=metadata "
+ "status=%s argument_names=%s",
+ name,
+ name,
+ "error" if result.mode == "error" else "success",
+ ",".join(sorted(arguments)),
+ )
+ return result.to_canonical_json()
+
+
+def _build_top_level_tool(domain: DomainDefinition) -> Tool:
+ annotations = domain.annotations
+ return Tool(
+ name=domain.name,
+ title=domain.title,
+ description=domain.description + DOMAIN_DISCOVERY_DESCRIPTION_SUFFIX,
+ input_schema=DOMAIN_TOOL_INPUT_SCHEMA,
+ output_schema=DOMAIN_DISCOVERY_OUTPUT_SCHEMA,
+ annotations=ToolAnnotations(
+ title=domain.title,
+ read_only_hint=annotations.read_only,
+ destructive_hint=annotations.destructive,
+ idempotent_hint=annotations.idempotent,
+ open_world_hint=annotations.open_world,
+ ),
+ )
+
+
+def _render_child(
+ child: ChildToolDefinition,
+ availability: Availability,
+) -> ChildManifestEntry:
+ description = _dynamic_description(
+ child.canonical_description,
+ availability,
+ )
+ _validate_child_budget(child, description)
+ return ChildManifestEntry(
+ name=child.name,
+ title=child.title,
+ description=description,
+ input_schema=child.input_schema,
+ output_schema=child.output_schema,
+ version_support=ManifestVersionSupport.from_contract(
+ child.support_contract
+ ),
+ availability=availability,
+ annotations=child.annotations,
+ )
+
+
+def _dynamic_description(
+ canonical_description: str,
+ availability: Availability,
+) -> str:
+ context = _availability_context(availability)
+ return (
+ f"[{availability.status.value.upper()} | {context}] "
+ f"{canonical_description}"
+ )
+
+
+def _availability_context(availability: Availability) -> str:
+ context: list[str] = []
+ if availability.detected_versions:
+ component = next(
+ (
+ candidate
+ for candidate in ("master_fe", "fe", "be")
+ if candidate in availability.detected_versions
+ ),
+ sorted(availability.detected_versions)[0],
+ )
+ versions = availability.detected_versions[component]
+ if versions:
+ context.append(f"Doris {versions[0]}")
+ if availability.active_variant is not None:
+ context.append(availability.active_variant)
+ if not context:
+ context.append(
+ availability.reason_code.lower().replace("_", " ")
+ )
+ return " | ".join(context)
+
+
+def _build_manifest(
+ domain: DomainDefinition,
+ entries: tuple[ChildManifestEntry, ...],
+ *,
+ generated_at: datetime,
+) -> DiscoveryEnvelope:
+ version = _manifest_version(domain, entries)
+ envelope = DiscoveryEnvelope(
+ mode="manifest",
+ domain=domain.name,
+ manifest_version=version,
+ generated_at=generated_at,
+ children=entries,
+ )
+ size = len(envelope.to_canonical_json().encode("utf-8"))
+ if size > domain.max_manifest_bytes:
+ raise DomainManifestBudgetError(
+ f"domain {domain.name!r} manifest is {size} bytes; "
+ f"limit is {domain.max_manifest_bytes} bytes"
+ )
+ return envelope
+
+
+def _manifest_version(
+ domain: DomainDefinition,
+ entries: Sequence[ChildManifestEntry],
+) -> str:
+ payload = {
+ "format_version": MANIFEST_FORMAT_VERSION,
+ "domain_contract": domain.to_wire(),
+ "children": [entry.to_wire() for entry in entries],
+ }
+ canonical = json.dumps(
+ payload,
+ ensure_ascii=False,
+ separators=(",", ":"),
+ sort_keys=True,
+ )
+ digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12]
+ prefix = domain.name.removeprefix("doris_")
+ return f"{prefix}.{digest}"
+
+
+def _validate_child_budget(
+ child: ChildToolDefinition,
+ description: str,
+) -> None:
+ feature_id = child.handler_name.removeprefix("child:")
+ child_wire = child.to_wire()
+ if len(description) > MAX_CHILD_DESCRIPTION_CHARACTERS:
+ raise DomainManifestBudgetError(
+ f"{feature_id} description exceeds "
+ f"{MAX_CHILD_DESCRIPTION_CHARACTERS} characters"
+ )
+ for label, schema in (
+ ("input", child_wire["input_schema"]),
+ ("output", child_wire["output_schema"]),
+ ):
+ size = len(
+ json.dumps(
+ schema,
+ ensure_ascii=False,
+ separators=(",", ":"),
+ sort_keys=True,
+ ).encode("utf-8")
+ )
+ if size > MAX_CHILD_SCHEMA_BYTES:
+ raise DomainManifestBudgetError(
+ f"{feature_id} {label} schema is {size} bytes; "
+ f"limit is {MAX_CHILD_SCHEMA_BYTES} bytes"
+ )
+ _validate_enum_budget(feature_id, label, schema)
+
+
+def _validate_enum_budget(
+ feature_id: str,
+ label: str,
+ value: Any,
+) -> None:
+ if isinstance(value, Mapping):
+ enum = value.get("enum")
+ if isinstance(enum, Sequence) and not isinstance(enum, str | bytes):
+ if len(enum) > MAX_SCHEMA_ENUM_VALUES:
+ raise DomainManifestBudgetError(
+ f"{feature_id} {label} schema enum exceeds "
+ f"{MAX_SCHEMA_ENUM_VALUES} values"
+ )
+ for child in value.values():
+ _validate_enum_budget(feature_id, label, child)
+ elif isinstance(value, Sequence) and not isinstance(value, str | bytes):
+ for child in value:
+ _validate_enum_budget(feature_id, label, child)
+
+
+__all__ = [
+ "DOMAIN_DISCOVERY_DESCRIPTION_SUFFIX",
+ "DOMAIN_DISCOVERY_OUTPUT_SCHEMA",
+ "DOMAIN_TOOL_INPUT_SCHEMA",
+ "MAX_CHILD_DESCRIPTION_CHARACTERS",
+ "MAX_CHILD_SCHEMA_BYTES",
+ "MAX_SCHEMA_ENUM_VALUES",
+ "ChildDiscoveryPolicy",
+ "DomainAvailabilityProvider",
+ "DomainManifestBudgetError",
+ "DomainManifestError",
+ "DomainManifestManagerMixin",
+ "DomainManifestService",
+ "PendingCapabilityProvider",
+ "authorized_child_discovery",
+ "pending_capability_availability",
+]
diff --git a/doris_mcp_server/tools/domain_models.py
b/doris_mcp_server/tools/domain_models.py
index 715b92b..d0fc335 100644
--- a/doris_mcp_server/tools/domain_models.py
+++ b/doris_mcp_server/tools/domain_models.py
@@ -221,6 +221,49 @@ class ChildSupportContract(ContractModel):
return self
+class ManifestVersionSupport(ContractModel):
+ """Compact public version ranges projected from an internal support
contract."""
+
+ rule_id: RuleIdentifier
+ supported_ranges: Annotated[tuple[NonEmptyText, ...], Field(min_length=1)]
+ excluded_ranges: tuple[NonEmptyText, ...] = ()
+ tested_versions: tuple[NonEmptyText, ...] = ()
+
+ @classmethod
+ def from_contract(
+ cls,
+ contract: ChildSupportContract,
+ ) -> ManifestVersionSupport:
+ """Project execution details into the bounded public manifest shape."""
+ supported_ranges = tuple(
+ dict.fromkeys(
+ supported_range
+ for variant in contract.variants
+ for supported_range in variant.supported_ranges
+ )
+ )
+ excluded_ranges = tuple(
+ dict.fromkeys(
+ excluded_range
+ for variant in contract.variants
+ for excluded_range in variant.excluded_ranges
+ )
+ )
+ return cls(
+ rule_id=contract.rule_id,
+ supported_ranges=supported_ranges,
+ excluded_ranges=excluded_ranges,
+ tested_versions=contract.tested_versions,
+ )
+
+ @model_validator(mode="after")
+ def _validate_summary(self) -> Self:
+ _require_unique(self.supported_ranges, "supported_ranges")
+ _require_unique(self.excluded_ranges, "excluded_ranges")
+ _require_unique(self.tested_versions, "tested_versions")
+ return self
+
+
class CompositeStep(ContractModel):
"""One private deterministic step in a composite child DAG."""
@@ -409,7 +452,7 @@ class DomainToolRequest(ContractModel):
@model_validator(mode="after")
def _validate_request_mode(self) -> Self:
- if self.child_tool is None and self.arguments:
+ if self.child_tool is None and self.arguments is not None:
raise ValueError("arguments require child_tool")
return self
@@ -430,7 +473,7 @@ class ChildManifestEntry(ContractModel):
description: NonEmptyText
input_schema: JsonObject
output_schema: JsonObject
- version_support: ChildSupportContract
+ version_support: ManifestVersionSupport
availability: Availability
annotations: ToolContractAnnotations
@@ -454,6 +497,10 @@ class ChildManifestEntry(ContractModel):
_check_object_schema(self.output_schema, "output_schema")
return self
+ def to_wire(self) -> JsonObject:
+ """Omit optional empty metadata from the bounded public manifest."""
+ return _compact_manifest_entry_wire(super().to_wire())
+
class DiscoveryEnvelope(ContractModel):
"""Stable structured result for an empty domain discovery call."""
@@ -474,6 +521,12 @@ class DiscoveryEnvelope(ContractModel):
)
return self
+ def to_wire(self) -> JsonObject:
+ """Serialize children through their compact public wire projection."""
+ payload = super().to_wire()
+ payload["children"] = [child.to_wire() for child in self.children]
+ return payload
+
class ExecutionMetadata(ContractModel):
"""Bounded metadata for an exact child execution result."""
@@ -548,6 +601,22 @@ class ErrorEnvelope(ContractModel):
error: StandardError
+def _compact_manifest_entry_wire(payload: JsonObject) -> JsonObject:
+ """Remove optional empty fields without requiring a newer Pydantic."""
+ for section_name, optional_fields in (
+ ("version_support", ("excluded_ranges", "tested_versions")),
+ (
+ "availability",
+ ("detected_versions", "evidence_sources", "limitations"),
+ ),
+ ):
+ section = cast(dict[str, JsonValue], payload[section_name])
+ for field_name in optional_fields:
+ if not section.get(field_name):
+ section.pop(field_name, None)
+ return payload
+
+
def _require_unique(values: tuple[str, ...], field_name: str) -> None:
if len(values) != len(set(values)):
raise ValueError(f"{field_name} must not contain duplicates")
@@ -624,6 +693,7 @@ __all__ = [
"ExecutionEnvelope",
"ExecutionMetadata",
"JsonObject",
+ "ManifestVersionSupport",
"StandardError",
"ToolContractAnnotations",
"UnavailableBehavior",
diff --git a/doris_mcp_server/tools/tool_registry.py
b/doris_mcp_server/tools/tool_registry.py
index a8f460d..3e59c69 100644
--- a/doris_mcp_server/tools/tool_registry.py
+++ b/doris_mcp_server/tools/tool_registry.py
@@ -27,7 +27,13 @@ from mcp.types import Tool
from .tool_provider import CustomTool, ToolRateLimit
-ToolPolicyClass = Literal["metadata", "query", "explain", "restricted"]
+ToolPolicyClass = Literal[
+ "domain",
+ "metadata",
+ "query",
+ "explain",
+ "restricted",
+]
ToolHandler = Callable[[dict[str, Any]], Awaitable[dict[str, Any]]]
DORIS_OAUTH_METADATA_TOOL_NAMES = (
@@ -154,6 +160,8 @@ class ToolDefinition:
def _policy_for_name(name: str) -> ToolPolicyDefinition:
+ if _is_read_only_domain_tool(name):
+ return ToolPolicyDefinition("domain", "manifest", "metadata")
if name in DORIS_OAUTH_METADATA_TOOL_SET:
return ToolPolicyDefinition("metadata", "mysql_metadata", "metadata")
if name in DORIS_OAUTH_QUERY_TOOL_SET:
@@ -381,10 +389,18 @@ class ToolDefinitionRegistry:
def policy_definition_for_tool(name: str) -> ToolPolicyDefinition | None:
"""Return policy metadata for a registered canonical tool or legacy
alias."""
if (
- name in DORIS_OAUTH_METADATA_TOOL_SET
+ _is_read_only_domain_tool(name)
+ or name in DORIS_OAUTH_METADATA_TOOL_SET
or name in DORIS_OAUTH_QUERY_TOOL_SET
or name in DORIS_OAUTH_EXPLAIN_TOOL_SET
or name in RESTRICTED_TOOL_NAMES
):
return _policy_for_name(name)
return None
+
+
+def _is_read_only_domain_tool(name: str) -> bool:
+ """Resolve domain names lazily to keep the catalog as the sole
authority."""
+ from .doris_feature_matrix import EXPECTED_DOMAIN_CHILDREN
+
+ return name in EXPECTED_DOMAIN_CHILDREN
diff --git a/doris_mcp_server/tools/tools_manager.py
b/doris_mcp_server/tools/tools_manager.py
index 6f054c1..2776649 100644
--- a/doris_mcp_server/tools/tools_manager.py
+++ b/doris_mcp_server/tools/tools_manager.py
@@ -26,12 +26,9 @@ from collections.abc import Iterable
from datetime import datetime
from typing import Any
-from mcp.types import Tool
-
from ..auth.operation_policy import (
OperationAuthorizationError,
authorize_operation,
- filter_tools_for_auth_context,
)
from ..result_limits import configured_default_result_rows
from ..utils.adbc_query_tools import DorisADBCQueryTools
@@ -48,6 +45,11 @@ from ..utils.query_executor import DorisQueryExecutor
from ..utils.schema_extractor import MetadataExtractor
from ..utils.security import get_current_auth_context
from ..utils.security_analytics_tools import SecurityAnalyticsTools
+from .domain_manifest import (
+ DomainAvailabilityProvider,
+ DomainManifestManagerMixin,
+ DomainManifestService,
+)
from .tool_catalog import build_tool_registry
from .tool_provider import CustomToolProvider, ToolProviderRuntime
from .tool_registry import ToolDefinition, ToolDefinitionRegistry
@@ -55,7 +57,7 @@ from .tool_registry import ToolDefinition,
ToolDefinitionRegistry
logger = get_logger(__name__)
-class DorisToolsManager:
+class DorisToolsManager(DomainManifestManagerMixin):
"""Apache Doris Tools Manager"""
def __init__(
@@ -63,6 +65,7 @@ class DorisToolsManager:
connection_manager: DorisConnectionManager,
*,
tool_providers: Iterable[CustomToolProvider] | None = None,
+ domain_availability_provider: DomainAvailabilityProvider | None = None,
) -> None:
self.connection_manager = connection_manager
config = getattr(connection_manager, "config", None)
@@ -89,6 +92,9 @@ class DorisToolsManager:
# Initialize ADBC query tools
self.adbc_query_tools = DorisADBCQueryTools(connection_manager)
self._tool_registry = self._build_tool_registry()
+ self._domain_manifest_service = DomainManifestService(
+ availability_provider=domain_availability_provider,
+ )
logger.info(
"DorisToolsManager initialized with business logic processors,
v0.5.0 "
@@ -154,17 +160,11 @@ class DorisToolsManager:
self._tool_registry = registry
return registry
- async def list_tools(self) -> list[Tool]:
- """List tools visible to the current authorization context."""
- return filter_tools_for_auth_context(
- get_current_auth_context(),
- self.tool_registry.listed_tools(),
- )
-
async def call_tool(self, name: str, arguments: dict[str, Any]) -> str:
- """
- Call the specified query tool (tool routing and scheduling center)
- """
+ """Call a domain or an internal migration tool by exact name."""
+ if self.domain_manifest_service.handles(name):
+ return await self._call_domain_tool(name, arguments)
+
authorize_operation(get_current_auth_context(), f"tool:{name}")
definition: ToolDefinition | None = None
start_time = time.time()
diff --git a/test/protocol/test_mcp_v2_protocol.py
b/test/protocol/test_mcp_v2_protocol.py
index f7941dc..4ce86de 100644
--- a/test/protocol/test_mcp_v2_protocol.py
+++ b/test/protocol/test_mcp_v2_protocol.py
@@ -49,6 +49,9 @@ from doris_mcp_server.protocol import (
create_doris_mcp_server,
create_transport_security,
)
+from doris_mcp_server.tools.doris_feature_matrix import (
+ EXPECTED_DOMAIN_CHILDREN,
+)
from test.protocol.schema_validation_server import
create_schema_validation_server
from test.protocol.stdio_capability_server import OneToolManager as
ProfileToolManager
from test.protocol.tool_registry_server import create_registry_test_server
@@ -363,7 +366,7 @@ def modern_tool_headers(name: str) -> dict[str, str]:
@pytest.mark.asyncio
-async def
test_http_uses_production_tool_registry_for_list_validation_and_dispatch():
+async def test_http_uses_production_domain_catalog_for_discovery_validation():
server = create_registry_test_server()
app = server.streamable_http_app(
json_response=True,
@@ -390,14 +393,22 @@ async def
test_http_uses_production_tool_registry_for_list_validation_and_dispat
tool["name"]: tool
for tool in listed.json()["result"]["tools"]
}
- assert len(tools) == 25
- assert tools["exec_query"]["inputSchema"]["required"] == ["sql"]
+ assert set(tools) == set(EXPECTED_DOMAIN_CHILDREN)
+ assert set(tools["doris_catalog"]["inputSchema"]["properties"]) == {
+ "child_tool",
+ "arguments",
+ "manifest_version",
+ }
assert "get_monitoring_metrics_info" not in tools
invalid = await client.post(
"/mcp",
- json=modern_tool_request(2, "exec_query", {}),
- headers=modern_tool_headers("exec_query"),
+ json=modern_tool_request(
+ 2,
+ "doris_catalog",
+ {"unexpected": True},
+ ),
+ headers=modern_tool_headers("doris_catalog"),
)
assert invalid.status_code == 400
assert invalid.json()["error"]["code"] == -32602
@@ -406,18 +417,16 @@ async def
test_http_uses_production_tool_registry_for_list_validation_and_dispat
"/mcp",
json=modern_tool_request(
3,
- "exec_query",
- {"sql": "SELECT 1"},
+ "doris_catalog",
+ {},
),
- headers=modern_tool_headers("exec_query"),
+ headers=modern_tool_headers("doris_catalog"),
)
assert called.status_code == 200
structured = called.json()["result"]["structuredContent"]
- assert structured["registry_dispatch"] is True
- assert structured["sql_length"] == len("SELECT 1")
- assert structured["_execution_info"]["canonical_tool_name"] == (
- "exec_query"
- )
+ assert structured["mode"] == "manifest"
+ assert structured["domain"] == "doris_catalog"
+ assert len(structured["children"]) == 5
@pytest.mark.asyncio
@@ -1303,7 +1312,7 @@ async def
test_true_subprocess_stdio_does_not_advertise_or_serve_subscriptions()
@pytest.mark.asyncio
-async def test_true_subprocess_stdio_uses_production_tool_registry():
+async def test_true_subprocess_stdio_uses_production_domain_catalog():
server_script = Path(__file__).with_name("tool_registry_server.py")
server_params = StdioServerParameters(
command=sys.executable,
@@ -1315,28 +1324,34 @@ async def
test_true_subprocess_stdio_uses_production_tool_registry():
tool.name: tool
for tool in (await modern.list_tools(cache_mode="bypass")).tools
}
- assert len(tools) == 25
- assert tools["exec_query"].input_schema["required"] == ["sql"]
+ assert set(tools) == set(EXPECTED_DOMAIN_CHILDREN)
+ assert set(tools["doris_catalog"].input_schema["properties"]) == {
+ "child_tool",
+ "arguments",
+ "manifest_version",
+ }
assert "get_monitoring_metrics_info" not in tools
with pytest.raises(MCPError) as invalid:
- await modern.call_tool("exec_query", {})
+ await modern.call_tool(
+ "doris_catalog",
+ {"unexpected": True},
+ )
assert invalid.value.code == -32602
- called = await modern.call_tool("exec_query", {"sql": "SELECT 1"})
- assert called.structured_content["registry_dispatch"] is True
- assert called.structured_content["_execution_info"][
- "canonical_tool_name"
- ] == "exec_query"
+ called = await modern.call_tool("doris_catalog", {})
+ assert called.structured_content["mode"] == "manifest"
+ assert called.structured_content["domain"] == "doris_catalog"
async with Client(stdio_client(server_params), mode="legacy") as legacy:
tools = {
tool.name: tool
for tool in (await legacy.list_tools()).tools
}
- assert len(tools) == 25
- called = await legacy.call_tool("exec_query", {"sql": "SELECT 1"})
- assert called.structured_content["registry_dispatch"] is True
+ assert set(tools) == set(EXPECTED_DOMAIN_CHILDREN)
+ called = await legacy.call_tool("doris_catalog", {})
+ assert called.structured_content["mode"] == "manifest"
+ assert called.structured_content["domain"] == "doris_catalog"
@pytest.mark.asyncio
diff --git a/test/security/test_auth_cross_matrix.py
b/test/security/test_auth_cross_matrix.py
index c851c22..ffd3615 100644
--- a/test/security/test_auth_cross_matrix.py
+++ b/test/security/test_auth_cross_matrix.py
@@ -17,6 +17,9 @@ from doris_mcp_server.auth.operation_policy import (
)
from doris_mcp_server.http_transport import DorisMCPHTTPTransport
from doris_mcp_server.protocol import create_transport_security
+from doris_mcp_server.tools.doris_feature_matrix import (
+ EXPECTED_DOMAIN_CHILDREN,
+)
from doris_mcp_server.tools.tool_registry import (
DORIS_OAUTH_EXPLAIN_TOOL_SET,
DORIS_OAUTH_METADATA_TOOL_SET,
@@ -47,7 +50,15 @@ TOOL_OPERATION_SCOPES = tuple(
(f"tool:{tool_name}", f"tool:call:{tool_name}")
for tool_name in ALL_TOOL_NAMES
)
-ALL_OPERATION_SCOPES = BASE_OPERATION_SCOPES + TOOL_OPERATION_SCOPES
+DOMAIN_OPERATION_SCOPES = tuple(
+ (f"tool:{domain_name}", "tool:list")
+ for domain_name in EXPECTED_DOMAIN_CHILDREN
+)
+ALL_OPERATION_SCOPES = (
+ BASE_OPERATION_SCOPES
+ + TOOL_OPERATION_SCOPES
+ + DOMAIN_OPERATION_SCOPES
+)
def _context(auth_method: str, scopes: Iterable[str] = ()) -> AuthContext:
@@ -88,7 +99,6 @@ def _external_oauth_config() -> EffectiveAuthConfig:
external_oauth_resource="https://mcp.example.test/mcp",
external_oauth_scopes=(
"tool:list",
- "tool:call:exec_query",
),
external_oauth_required_scopes=("tool:list",),
)
@@ -260,11 +270,8 @@ def
test_external_oauth_rejects_unknown_tool_before_dispatch() -> None:
@pytest.mark.asyncio
async def
test_streamable_http_enforces_external_oauth_tool_scope_before_dispatch() ->
None:
contexts = {
+ "no-scope": _context("external_oauth"),
"list-only": _context("external_oauth", ["tool:list"]),
- "exec-query": _context(
- "external_oauth",
- ["tool:list", "tool:call:exec_query"],
- ),
}
class SecurityManager:
@@ -299,29 +306,31 @@ async def
test_streamable_http_enforces_external_oauth_tool_scope_before_dispatc
headers=_modern_headers("tools/list", token="list-only"),
)
assert listed.status_code == 200
- assert len(listed.json()["result"]["tools"]) == 25
+ assert {
+ tool["name"] for tool in listed.json()["result"]["tools"]
+ } == set(EXPECTED_DOMAIN_CHILDREN)
denied = await client.post(
"/mcp",
json=_modern_request(
2,
"tools/call",
- name="exec_query",
- arguments={"sql": "SELECT 1"},
+ name="doris_catalog",
+ arguments={},
),
headers=_modern_headers(
"tools/call",
- name="exec_query",
- token="list-only",
+ name="doris_catalog",
+ token="no-scope",
),
)
assert denied.status_code == 403
assert denied.json()["error"] == "PERMISSION_DENIED"
- assert denied.json()["required_scope"] == "tool:call:exec_query"
+ assert denied.json()["required_scope"] == "tool:list"
assert 'error="insufficient_scope"' in denied.headers[
"www-authenticate"
]
- assert 'scope="tool:call:exec_query"' in denied.headers[
+ assert 'scope="tool:list"' in denied.headers[
"www-authenticate"
]
@@ -330,19 +339,22 @@ async def
test_streamable_http_enforces_external_oauth_tool_scope_before_dispatc
json=_modern_request(
3,
"tools/call",
- name="exec_query",
- arguments={"sql": "SELECT 1"},
+ name="doris_catalog",
+ arguments={},
),
headers=_modern_headers(
"tools/call",
- name="exec_query",
- token="exec-query",
+ name="doris_catalog",
+ token="list-only",
),
)
assert allowed.status_code == 200
- assert allowed.json()["result"]["structuredContent"][
- "registry_dispatch"
- ] is True
+ assert allowed.json()["result"]["structuredContent"]["mode"] == (
+ "manifest"
+ )
+ assert allowed.json()["result"]["structuredContent"]["domain"] == (
+ "doris_catalog"
+ )
@pytest.mark.asyncio
@@ -359,10 +371,13 @@ async def
test_true_subprocess_stdio_keeps_local_tool_and_resource_paths_scope_f
async with Client(stdio_client(server_params)) as client:
tools = await client.list_tools(cache_mode="bypass")
- assert len(tools.tools) == 25
+ assert {tool.name for tool in tools.tools} == set(
+ EXPECTED_DOMAIN_CHILDREN
+ )
- called = await client.call_tool("exec_query", {"sql": "SELECT 1"})
- assert called.structured_content["registry_dispatch"] is True
+ called = await client.call_tool("doris_catalog", {})
+ assert called.structured_content["mode"] == "manifest"
+ assert called.structured_content["domain"] == "doris_catalog"
resources = await client.list_resources(cache_mode="bypass")
assert resources.resources == []
diff --git a/test/security/test_operation_policy.py
b/test/security/test_operation_policy.py
index 5d99366..e865345 100644
--- a/test/security/test_operation_policy.py
+++ b/test/security/test_operation_policy.py
@@ -11,6 +11,9 @@ from doris_mcp_server.auth.operation_policy import (
authorize_operation,
filter_tools_for_auth_context,
)
+from doris_mcp_server.tools.doris_feature_matrix import (
+ EXPECTED_DOMAIN_CHILDREN,
+)
from doris_mcp_server.utils.security import AuthContext
@@ -53,6 +56,40 @@ def
test_missing_auth_context_passes_for_legacy_stdio_paths():
authorize_operation(None, "tool:get_sql_profile")
[email protected]("domain_name", EXPECTED_DOMAIN_CHILDREN)
+def test_doris_oauth_allows_read_only_domain_discovery_with_tool_list_scope(
+ domain_name,
+):
+ authorize_operation(
+ doris_context(["tool:list"]),
+ f"tool:{domain_name}",
+ )
+
+
[email protected]("domain_name", EXPECTED_DOMAIN_CHILDREN)
+def test_doris_oauth_domain_discovery_rejects_missing_tool_list_scope(
+ domain_name,
+):
+ with pytest.raises(OperationAuthorizationError) as exc:
+ authorize_operation(
+ doris_context([]),
+ f"tool:{domain_name}",
+ )
+
+ assert exc.value.error_code == "PERMISSION_DENIED"
+ assert exc.value.required_scope == "tool:list"
+
+
+def test_domain_discovery_does_not_expand_default_oauth_scope_set() -> None:
+ policy = DorisOAuthScopePolicy()
+
+ assert {
+ f"tool:call:{domain_name}"
+ for domain_name in EXPECTED_DOMAIN_CHILDREN
+ }.isdisjoint(policy.server_allowed_scopes)
+ assert "tool:list" in policy.server_allowed_scopes
+
+
@pytest.mark.parametrize("tool_name", sorted(P4_DORIS_OAUTH_METADATA_TOOLS))
def test_doris_oauth_rejects_metadata_tools_when_db_gate_false(tool_name):
with pytest.raises(OperationAuthorizationError) as exc:
diff --git a/test/tools/test_custom_tool_provider.py
b/test/tools/test_custom_tool_provider.py
index 3ffd7c2..fa386fd 100644
--- a/test/tools/test_custom_tool_provider.py
+++ b/test/tools/test_custom_tool_provider.py
@@ -32,6 +32,9 @@ from doris_mcp_server.protocol import (
create_doris_mcp_server,
create_transport_security,
)
+from doris_mcp_server.tools.doris_feature_matrix import (
+ EXPECTED_DOMAIN_CHILDREN,
+)
from doris_mcp_server.tools.tool_provider import (
CustomTool,
LocalToolRateLimiter,
@@ -104,7 +107,7 @@ class RecordingProvider:
@pytest.mark.asyncio
-async def
test_provider_tool_is_listed_dispatched_audited_and_lifecycle_managed():
+async def
test_provider_tool_is_internal_dispatched_audited_and_lifecycle_managed():
provider = RecordingProvider()
manager = DorisToolsManager(
_connection_manager(),
@@ -123,7 +126,8 @@ async def
test_provider_tool_is_listed_dispatched_audited_and_lifecycle_managed(
)
await manager.close()
- assert "lookup_business_order" in {tool.name for tool in listed}
+ assert {tool.name for tool in listed} == set(EXPECTED_DOMAIN_CHILDREN)
+ assert "lookup_business_order" not in {tool.name for tool in listed}
provider.handler.assert_awaited_once_with({"order_id": "order-42"})
assert payload["ok"] is True
assert payload["source"] == "business-api"
@@ -373,7 +377,7 @@ def _modern_headers(method: str, *, name: str | None =
None) -> dict[str, str]:
@pytest.mark.asyncio
-async def
test_custom_tool_uses_real_streamable_http_list_validation_and_call():
+async def test_custom_tool_does_not_bypass_real_http_domain_discovery():
provider = RecordingProvider()
manager = DorisToolsManager(
_connection_manager(),
@@ -413,29 +417,17 @@ async def
test_custom_tool_uses_real_streamable_http_list_validation_and_call():
json=_modern_request(1, "tools/list"),
headers=_modern_headers("tools/list"),
)
- invalid = await client.post(
+ discovered = await client.post(
"/mcp",
json=_modern_request(
2,
"tools/call",
- name="lookup_business_order",
- ),
- headers=_modern_headers(
- "tools/call",
- name="lookup_business_order",
- ),
- )
- called = await client.post(
- "/mcp",
- json=_modern_request(
- 3,
- "tools/call",
- name="lookup_business_order",
- arguments={"order_id": "order-42"},
+ name="doris_catalog",
+ arguments={},
),
headers=_modern_headers(
"tools/call",
- name="lookup_business_order",
+ name="doris_catalog",
),
)
@@ -443,14 +435,10 @@ async def
test_custom_tool_uses_real_streamable_http_list_validation_and_call():
listed_tools = {
tool["name"]: tool for tool in listed.json()["result"]["tools"]
}
- assert "lookup_business_order" in listed_tools
- assert listed_tools["lookup_business_order"]["inputSchema"]["required"] ==
[
- "order_id"
- ]
- assert invalid.status_code == 400
- assert invalid.json()["error"]["code"] == -32602
- assert called.status_code == 200
- structured = called.json()["result"]["structuredContent"]
- assert structured["ok"] is True
- assert structured["source"] == "business-api"
- assert "_execution_info" not in structured
+ assert set(listed_tools) == set(EXPECTED_DOMAIN_CHILDREN)
+ assert "lookup_business_order" not in listed_tools
+ assert discovered.status_code == 200
+ structured = discovered.json()["result"]["structuredContent"]
+ assert structured["mode"] == "manifest"
+ assert structured["domain"] == "doris_catalog"
+ provider.handler.assert_not_awaited()
diff --git a/test/tools/test_domain_manifest.py
b/test/tools/test_domain_manifest.py
new file mode 100644
index 0000000..5ea0366
--- /dev/null
+++ b/test/tools/test_domain_manifest.py
@@ -0,0 +1,535 @@
+# 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.
+
+"""Contract tests for bounded progressive domain discovery."""
+
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from doris_mcp_server.schema_validation import ToolSchemaGuard
+from doris_mcp_server.tools.domain_catalog import (
+ DORIS_DOMAIN_CATALOG,
+ DorisDomainCatalog,
+)
+from doris_mcp_server.tools.domain_manifest import (
+ DOMAIN_DISCOVERY_DESCRIPTION_SUFFIX,
+ MAX_CHILD_DESCRIPTION_CHARACTERS,
+ MAX_CHILD_SCHEMA_BYTES,
+ MAX_SCHEMA_ENUM_VALUES,
+ DomainManifestBudgetError,
+ DomainManifestManagerMixin,
+ DomainManifestService,
+)
+from doris_mcp_server.tools.domain_models import (
+ MAX_DOMAIN_MANIFEST_BYTES,
+ Availability,
+ AvailabilityStatus,
+ ChildToolDefinition,
+ DomainDefinition,
+ DomainErrorCode,
+)
+from doris_mcp_server.tools.doris_feature_matrix import (
+ EXPECTED_DOMAIN_CHILDREN,
+)
+from doris_mcp_server.utils.security import AuthContext
+
+FIXED_TIME = datetime(2026, 7, 31, 8, 30, tzinfo=UTC)
+REPO_ROOT = Path(__file__).resolve().parents[2]
+
+
+class StaticAvailabilityProvider:
+ def __init__(
+ self,
+ availability: Availability,
+ ) -> None:
+ self.availability = availability
+ self.calls: list[str] = []
+
+ async def availability_for(
+ self,
+ domain: DomainDefinition,
+ child: ChildToolDefinition,
+ auth_context: Any | None,
+ ) -> Availability:
+ del auth_context
+ self.calls.append(f"{domain.name}.{child.name}")
+ return self.availability
+
+
+def _availability(
+ *,
+ status: AvailabilityStatus = AvailabilityStatus.AVAILABLE,
+ callable_: bool = True,
+ reason_code: str = "RUNTIME_PROBE_CONFIRMED",
+ active_variant: str | None = "information_schema",
+ detected_versions: dict[str, tuple[str, ...]] | None = None,
+ evidence_sources: tuple[str, ...] = ("version", "sql_probe"),
+) -> Availability:
+ return Availability(
+ status=status,
+ callable=callable_,
+ reason_code=reason_code,
+ detected_versions=(
+ detected_versions
+ if detected_versions is not None
+ else {"fe": ("4.0.7",)}
+ ),
+ active_variant=active_variant,
+ evidence_sources=evidence_sources,
+ )
+
+
+def _service(
+ provider: StaticAvailabilityProvider | None = None,
+ *,
+ clock_time: datetime = FIXED_TIME,
+) -> DomainManifestService:
+ return DomainManifestService(
+ availability_provider=provider,
+ clock=lambda: clock_time,
+ )
+
+
+def _replace_domain(
+ domain_name: str,
+ replacement: DomainDefinition,
+) -> DorisDomainCatalog:
+ domains = tuple(
+ replacement if domain.name == domain_name else domain
+ for domain in DORIS_DOMAIN_CATALOG.domains
+ )
+ return DORIS_DOMAIN_CATALOG.model_copy(update={"domains": domains})
+
+
+def _replace_child(
+ domain: DomainDefinition,
+ child_index: int,
+ replacement: ChildToolDefinition,
+) -> DomainDefinition:
+ children = list(domain.children)
+ children[child_index] = replacement
+ return domain.model_copy(update={"children": tuple(children)})
+
+
+def test_top_level_catalog_is_exactly_eight_stable_read_only_domains() -> None:
+ service = _service()
+ tools = service.list_tools()
+
+ assert [tool.name for tool in tools] == list(EXPECTED_DOMAIN_CHILDREN)
+ assert service.domain_names == frozenset(EXPECTED_DOMAIN_CHILDREN)
+ assert service.handles("doris_catalog") is True
+ assert service.handles("exec_query") is False
+ assert len(tools) == 8
+ assert len({tool.name for tool in tools}) == 8
+ assert len({str(tool.input_schema) for tool in tools}) == 1
+ assert len({str(tool.output_schema) for tool in tools}) == 1
+ ToolSchemaGuard().compile_catalog(tools)
+
+ for tool in tools:
+ assert tool.description is not None
+ assert tool.description.endswith(DOMAIN_DISCOVERY_DESCRIPTION_SUFFIX)
+ assert tool.annotations is not None
+ assert tool.annotations.read_only_hint is True
+ assert tool.annotations.destructive_hint is False
+ assert tool.annotations.idempotent_hint is True
+ assert tool.annotations.open_world_hint is False
+ for child_name in EXPECTED_DOMAIN_CHILDREN[tool.name]:
+ assert child_name not in tool.description
+
+
+def test_manager_mixin_lazily_builds_discovery_service() -> None:
+ manager = DomainManifestManagerMixin()
+
+ assert manager.domain_manifest_service.handles("doris_catalog")
+ assert manager.domain_manifest_service is manager.domain_manifest_service
+
+
+def test_checked_in_public_registry_is_generated_from_domain_catalog() -> None:
+ checked_in = (REPO_ROOT / "docs" / "tool-registry.md").read_text(
+ encoding="utf-8"
+ )
+
+ assert checked_in == _service().render_markdown()
+
+
[email protected]
+async def test_manager_mixin_lists_and_calls_domain_discovery() -> None:
+ manager = DomainManifestManagerMixin()
+
+ listed = await manager.list_tools()
+ payload = json.loads(
+ await manager._call_domain_tool("doris_catalog", {})
+ )
+
+ assert [tool.name for tool in listed] == list(EXPECTED_DOMAIN_CHILDREN)
+ assert payload["mode"] == "manifest"
+ assert payload["domain"] == "doris_catalog"
+
+
[email protected]
+async def test_empty_calls_return_complete_bounded_manifests() -> None:
+ service = _service()
+
+ for domain_name, expected_children in EXPECTED_DOMAIN_CHILDREN.items():
+ first = await service.call(domain_name, {}, None)
+ second = await service.call(domain_name, {}, None)
+
+ assert first.mode == "manifest"
+ assert first.domain == domain_name
+ assert first.generated_at == FIXED_TIME
+ assert first.manifest_version == second.manifest_version
+ assert [child.name for child in first.children] == list(
+ expected_children
+ )
+ assert (
+ len(first.to_canonical_json().encode("utf-8"))
+ <= MAX_DOMAIN_MANIFEST_BYTES
+ )
+ for child in first.children:
+ assert child.availability.status is AvailabilityStatus.UNKNOWN
+ assert child.availability.callable is False
+ assert child.availability.evidence_sources == (
+ "catalog_contract",
+ )
+ assert child.description.startswith(
+ "[UNKNOWN | capability snapshot pending] "
+ )
+ first_child = first.to_wire()["children"][0]
+ assert "excluded_ranges" not in first_child["version_support"]
+ assert "tested_versions" not in first_child["version_support"]
+ assert "detected_versions" not in first_child["availability"]
+ assert "limitations" not in first_child["availability"]
+
+
[email protected]
+async def test_non_child_permissions_do_not_hide_read_only_catalog() -> None:
+ context = AuthContext(
+ auth_method="token",
+ permissions=["catalog:read"],
+ )
+
+ manifest = await _service().call("doris_catalog", {}, context)
+
+ assert len(manifest.children) == 5
+
+
[email protected]
+async def test_manifest_version_excludes_timestamp_but_tracks_content() ->
None:
+ available = StaticAvailabilityProvider(_availability())
+ first = await _service(available).call("doris_catalog", {}, None)
+ later = await _service(
+ StaticAvailabilityProvider(_availability()),
+ clock_time=FIXED_TIME + timedelta(minutes=5),
+ ).call("doris_catalog", {}, None)
+ unavailable = await _service(
+ StaticAvailabilityProvider(
+ _availability(
+ status=AvailabilityStatus.UNAVAILABLE,
+ callable_=False,
+ reason_code="DORIS_VERSION_UNSUPPORTED",
+ active_variant=None,
+ )
+ )
+ ).call("doris_catalog", {}, None)
+
+ assert first.manifest_version == later.manifest_version
+ assert first.generated_at != later.generated_at
+ assert first.manifest_version != unavailable.manifest_version
+
+
[email protected]
+async def test_dynamic_description_and_structured_availability_agree() -> None:
+ provider = StaticAvailabilityProvider(
+ _availability(
+ status=AvailabilityStatus.DEGRADED,
+ callable_=True,
+ reason_code="FALLBACK_ACTIVE",
+ active_variant="audit_sql_inference",
+ detected_versions={},
+ evidence_sources=("audit_log",),
+ )
+ )
+
+ manifest = await _service(provider).call(
+ "doris_governance",
+ {},
+ None,
+ )
+
+ assert manifest.children
+ for child in manifest.children:
+ assert child.description.startswith(
+ "[DEGRADED | audit_sql_inference] "
+ )
+ assert child.availability.status is AvailabilityStatus.DEGRADED
+ assert child.availability.callable is True
+ assert child.availability.active_variant == "audit_sql_inference"
+
+
[email protected]
+async def test_detected_version_has_priority_in_dynamic_description() -> None:
+ provider = StaticAvailabilityProvider(
+ _availability(
+ detected_versions={"be": ("4.0.6",), "fe": ("4.0.7",)},
+ )
+ )
+
+ manifest = await _service(provider).call("doris_cluster", {}, None)
+
+ assert manifest.children[0].description.startswith(
+ "[AVAILABLE | Doris 4.0.7 | information_schema] "
+ )
+
+
[email protected]
+async def test_realistic_runtime_evidence_stays_within_manifest_budget() ->
None:
+ provider = StaticAvailabilityProvider(
+ _availability(
+ detected_versions={
+ "master_fe": ("4.1.3",),
+ "fe": ("4.1.3",),
+ "be": ("4.1.3",),
+ },
+ evidence_sources=(
+ "version_probe",
+ "sql_probe",
+ "provider_config",
+ ),
+ )
+ )
+
+ manifest = await _service(provider).call("doris_cluster", {}, None)
+
+ assert (
+ len(manifest.to_canonical_json().encode("utf-8"))
+ <= MAX_DOMAIN_MANIFEST_BYTES
+ )
+
+
[email protected]
+async def test_explicit_child_grants_hide_every_unauthorized_child() -> None:
+ provider = StaticAvailabilityProvider(_availability())
+ context = AuthContext(
+ auth_method="external_oauth",
+ oauth_scopes=[
+ "tool:call:doris_catalog",
+ "child:call:doris_catalog:list_tables",
+ ],
+ )
+
+ catalog = await _service(provider).call(
+ "doris_catalog",
+ {},
+ context,
+ )
+ cluster = await _service(provider).call(
+ "doris_cluster",
+ {},
+ context,
+ )
+
+ assert [child.name for child in catalog.children] == ["list_tables"]
+ assert cluster.children == ()
+ assert provider.calls == ["doris_catalog.list_tables"]
+ assert "list_catalogs" not in catalog.to_canonical_json()
+
+
[email protected]
+async def test_explicit_discovery_permission_is_accepted() -> None:
+ context = AuthContext(
+ permissions=["child:discover:doris_semantic:get_semantic_context"],
+ )
+
+ manifest = await _service().call("doris_semantic", {}, context)
+
+ assert [child.name for child in manifest.children] == [
+ "get_semantic_context"
+ ]
+
+
[email protected]
+async def test_authorized_unavailable_children_remain_discoverable() -> None:
+ provider = StaticAvailabilityProvider(
+ _availability(
+ status=AvailabilityStatus.MISCONFIGURED,
+ callable_=False,
+ reason_code="PROVIDER_NOT_CONFIGURED",
+ active_variant=None,
+ detected_versions={},
+ evidence_sources=("provider_config",),
+ )
+ )
+
+ manifest = await _service(provider).call(
+ "doris_semantic",
+ {},
+ None,
+ )
+
+ assert len(manifest.children) == 4
+ assert all(not child.availability.callable for child in manifest.children)
+ assert all(
+ child.description.startswith(
+ "[MISCONFIGURED | provider not configured] "
+ )
+ for child in manifest.children
+ )
+
+
[email protected]
+async def test_public_version_support_omits_internal_execution_contracts() ->
None:
+ manifest = await _service().call("doris_cluster", {}, None)
+ serialized = manifest.to_canonical_json()
+
+ for child in manifest.children:
+ support = child.version_support.to_wire()
+ assert support["rule_id"]
+ assert support["supported_ranges"]
+ assert "variants" not in support
+ assert "required_probes" not in support
+ assert "source_references" not in support
+ assert '"required_providers"' not in serialized
+ assert '"handler_name"' not in serialized
+ assert '"authorization_policy"' not in serialized
+
+
[email protected]
+async def test_invalid_or_execution_requests_fail_with_stable_envelopes() ->
None:
+ service = _service()
+
+ empty_arguments = await service.call(
+ "doris_catalog",
+ {"arguments": {}},
+ None,
+ )
+ invalid = await service.call(
+ "doris_catalog",
+ {"arguments": {"database": "analytics"}},
+ None,
+ )
+ execution = await service.call(
+ "doris_catalog",
+ {
+ "child_tool": "list_tables",
+ "arguments": {"database": "analytics"},
+ "manifest_version": "catalog.previous",
+ },
+ None,
+ )
+
+ assert empty_arguments.mode == "error"
+ assert (
+ empty_arguments.error.code
+ is DomainErrorCode.CHILD_ARGUMENTS_INVALID
+ )
+ assert invalid.mode == "error"
+ assert invalid.error.code is DomainErrorCode.CHILD_ARGUMENTS_INVALID
+ assert execution.mode == "error"
+ assert (
+ execution.error.code
+ is DomainErrorCode.CHILD_CAPABILITY_UNAVAILABLE
+ )
+ assert execution.child_tool == "list_tables"
+ assert execution.manifest_version == "catalog.previous"
+
+
+def test_startup_rejects_manifest_over_domain_budget() -> None:
+ domain = DORIS_DOMAIN_CATALOG.resolve_domain("doris_cluster")
+ constrained = domain.model_copy(update={"max_manifest_bytes": 100})
+
+ with pytest.raises(DomainManifestBudgetError, match="limit is 100 bytes"):
+ DomainManifestService(
+ catalog=_replace_domain(domain.name, constrained)
+ )
+
+
[email protected](
+ ("replacement", "message"),
+ [
+ (
+ {"canonical_description": "x" * MAX_CHILD_DESCRIPTION_CHARACTERS},
+ "description exceeds",
+ ),
+ (
+ {
+ "input_schema": {
+ "type": "object",
+ "description": "x" * MAX_CHILD_SCHEMA_BYTES,
+ }
+ },
+ "input schema is",
+ ),
+ (
+ {
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "value": {
+ "type": "string",
+ "enum": [
+ str(index)
+ for index in range(
+ MAX_SCHEMA_ENUM_VALUES + 1
+ )
+ ],
+ }
+ },
+ }
+ },
+ "schema enum exceeds",
+ ),
+ ],
+)
+def test_startup_rejects_child_level_budget_violations(
+ replacement: dict[str, Any],
+ message: str,
+) -> None:
+ domain = DORIS_DOMAIN_CATALOG.resolve_domain("doris_catalog")
+ child = domain.children[0].model_copy(update=replacement)
+ modified = _replace_child(domain, 0, child)
+
+ with pytest.raises(DomainManifestBudgetError, match=message):
+ DomainManifestService(
+ catalog=_replace_domain(domain.name, modified)
+ )
+
+
[email protected]
+async def test_runtime_availability_cannot_overflow_manifest_budget() -> None:
+ provider = StaticAvailabilityProvider(
+ _availability(
+ evidence_sources=tuple(
+ f"evidence_{index}" for index in range(500)
+ )
+ )
+ )
+
+ with pytest.raises(DomainManifestBudgetError, match="manifest is"):
+ await _service(provider).call("doris_cluster", {}, None)
+
+
[email protected]
+async def test_naive_runtime_clock_fails_closed() -> None:
+ service = _service(clock_time=datetime(2026, 7, 31, 8, 30))
+
+ with pytest.raises(ValueError, match="must include a timezone"):
+ await service.call("doris_catalog", {}, None)
diff --git a/test/tools/test_domain_models.py b/test/tools/test_domain_models.py
index ecf8b08..2c8ab74 100644
--- a/test/tools/test_domain_models.py
+++ b/test/tools/test_domain_models.py
@@ -40,6 +40,7 @@ from doris_mcp_server.tools.domain_models import (
ExecutionEnvelope,
ExecutionMetadata,
JsonObject,
+ ManifestVersionSupport,
StandardError,
ToolContractAnnotations,
UnavailableBehavior,
@@ -137,7 +138,9 @@ def _manifest_child() -> ChildManifestEntry:
description="[AVAILABLE] List visible tables in one Doris database.",
input_schema=child.input_schema,
output_schema=child.output_schema,
- version_support=child.support_contract,
+ version_support=ManifestVersionSupport.from_contract(
+ child.support_contract
+ ),
availability=_availability(),
annotations=child.annotations,
)
@@ -229,6 +232,30 @@ def test_support_contract_rejects_duplicate_variant_ids()
-> None:
_support_contract(_variant(), _variant())
+def test_manifest_version_support_is_a_compact_ordered_projection() -> None:
+ support = ManifestVersionSupport.from_contract(
+ _support_contract(
+ _variant(name="native"),
+ CapabilityVariant(
+ name="fallback",
+ supported_ranges=(">=3.0.0",),
+ excluded_ranges=("==4.0.6-rc1",),
+ required_probes=("audit_readable",),
+ ),
+ )
+ )
+
+ assert support.to_wire() == {
+ "rule_id": "catalog.list_tables.v1",
+ "supported_ranges": [
+ "project-supported Doris releases",
+ ">=3.0.0",
+ ],
+ "excluded_ranges": ["==4.0.6-rc1"],
+ "tested_versions": ["4.0.5-rc01"],
+ }
+
+
def test_child_rejects_empty_handler_name() -> None:
with pytest.raises(ValidationError, match="handler_name"):
_child(handler_name="")
@@ -382,18 +409,20 @@ def
test_degraded_availability_may_be_callable_or_disabled() -> None:
def test_domain_request_distinguishes_discovery_and_execution() -> None:
discovery = DomainToolRequest()
- empty_discovery = DomainToolRequest(arguments={})
execution = DomainToolRequest(child_tool="list_tables")
assert discovery.is_discovery is True
- assert empty_discovery.is_discovery is True
assert execution.is_discovery is False
assert execution.execution_arguments == {}
def test_domain_request_rejects_arguments_without_child() -> None:
- with pytest.raises(ValidationError, match="arguments require child_tool"):
- DomainToolRequest(arguments={"database": "analytics"})
+ for arguments in ({}, {"database": "analytics"}):
+ with pytest.raises(
+ ValidationError,
+ match="arguments require child_tool",
+ ):
+ DomainToolRequest(arguments=arguments)
def test_request_arguments_and_result_data_are_deeply_immutable() -> None:
diff --git a/test/tools/test_tool_registry.py b/test/tools/test_tool_registry.py
index 5a5631c..a51bff4 100644
--- a/test/tools/test_tool_registry.py
+++ b/test/tools/test_tool_registry.py
@@ -20,7 +20,6 @@ from __future__ import annotations
import inspect
import json
-from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
import pytest
@@ -39,8 +38,6 @@ from doris_mcp_server.tools.tool_registry import (
)
from doris_mcp_server.tools.tools_manager import DorisToolsManager
-REPO_ROOT = Path(__file__).resolve().parents[2]
-
@pytest.fixture
def tools_manager() -> DorisToolsManager:
@@ -183,13 +180,14 @@ def
test_registry_rejects_duplicate_unknown_and_missing_handler_definitions(
)
-def test_checked_in_tool_catalog_is_generated_from_registry(
+def test_internal_migration_registry_rendering_is_deterministic(
tools_manager: DorisToolsManager,
) -> None:
- checked_in = (REPO_ROOT / "docs" / "tool-registry.md").read_text(
- encoding="utf-8"
- )
- assert checked_in == tools_manager.tool_registry.render_markdown()
+ first = tools_manager.tool_registry.render_markdown()
+ second = tools_manager.tool_registry.render_markdown()
+
+ assert first == second
+ assert first.startswith("# Doris MCP Tool Registry")
def test_tools_manager_has_no_parallel_decorator_or_dispatch_registry() ->
None:
diff --git a/test/tools/test_tools_manager.py b/test/tools/test_tools_manager.py
index ca01dd7..5ed3ed4 100644
--- a/test/tools/test_tools_manager.py
+++ b/test/tools/test_tools_manager.py
@@ -25,6 +25,9 @@ from unittest.mock import AsyncMock, Mock, patch
import pytest
from doris_mcp_server.schema_validation import ToolSchemaGuard
+from doris_mcp_server.tools.doris_feature_matrix import (
+ EXPECTED_DOMAIN_CHILDREN,
+)
from doris_mcp_server.tools.tools_manager import DorisToolsManager
from doris_mcp_server.utils.config import DorisConfig
@@ -76,12 +79,8 @@ class TestDorisToolsManager:
"""Test getting available tools"""
tools = await tools_manager.list_tools()
- # Should have core tools
tool_names = [tool.name for tool in tools]
- assert "exec_query" in tool_names
- assert "get_db_list" in tool_names
- assert "get_db_table_list" in tool_names
- assert "get_table_schema" in tool_names
+ assert tool_names == list(EXPECTED_DOMAIN_CHILDREN)
@pytest.mark.asyncio
async def test_exec_query_tool(self, tools_manager):
diff --git a/test/tools/test_tools_operation_guard.py
b/test/tools/test_tools_operation_guard.py
index 8a679c9..27c7557 100644
--- a/test/tools/test_tools_operation_guard.py
+++ b/test/tools/test_tools_operation_guard.py
@@ -12,6 +12,9 @@ from doris_mcp_server.auth.operation_policy import (
OperationAuthorizationError,
)
from doris_mcp_server.protocol import create_doris_mcp_server
+from doris_mcp_server.tools.doris_feature_matrix import (
+ EXPECTED_DOMAIN_CHILDREN,
+)
from doris_mcp_server.tools.tools_manager import DorisToolsManager
from doris_mcp_server.utils.analysis_tools import SQLAnalyzer
from doris_mcp_server.utils.data_governance_tools import DataGovernanceTools
@@ -619,7 +622,9 @@ async def
test_user_roles_fall_back_to_current_user_grants():
@pytest.mark.asyncio
-async def
test_doris_oauth_list_tools_uses_configured_default_scope_visibility(tmp_path):
+async def test_doris_oauth_list_tools_exposes_only_domain_discovery_scopes(
+ tmp_path,
+):
manager, _connection_manager = _real_tool_manager_for_routing(tmp_path)
token = set_current_auth_context(
doris_context(
@@ -645,17 +650,7 @@ async def
test_doris_oauth_list_tools_uses_configured_default_scope_visibility(t
reset_auth_context(token)
names = {tool.name for tool in tools}
- assert {
- "exec_query",
- "get_sql_explain",
- "get_db_list",
- "get_db_table_list",
- "get_table_schema",
- "get_table_comment",
- "get_table_column_comments",
- "get_table_indexes",
- "get_catalog_list",
- } <= names
+ assert names == set(EXPECTED_DOMAIN_CHILDREN)
assert names.isdisjoint(HIGH_RISK_TOOLS)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]