This is an automated email from the ASF dual-hosted git repository. FreeOnePlus pushed a commit to branch feat/v1-04-domain-manifests in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git
commit f91a5bf9cf87f14e45838725b9a775290e6ba4fe Author: FreeOnePlus <[email protected]> AuthorDate: Fri Jul 31 03:50:26 2026 +0800 feat: add bounded hierarchical domain discovery --- 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]
