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 c0c56cc refactor: centralize tool definitions (#145)
c0c56cc is described below
commit c0c56cc695db578d1ed1252be6607c708f5a6a82
Author: Yijia Su <[email protected]>
AuthorDate: Thu Jul 30 12:07:13 2026 +0800
refactor: centralize tool definitions (#145)
---
CHANGELOG.md | 3 +
README.md | 85 +-
docs/tool-registry.md | 33 +
doris_mcp_server/auth/doris_oauth_scope_policy.py | 45 +-
doris_mcp_server/auth/operation_policy.py | 90 +--
doris_mcp_server/tools/tool_registry.py | 356 +++++++++
doris_mcp_server/tools/tools_manager.py | 897 ++--------------------
doris_mcp_server/utils/config.py | 21 +-
test/protocol/test_mcp_v2_protocol.py | 96 +++
test/protocol/tool_registry_server.py | 95 +++
test/tools/test_tool_registry.py | 200 +++++
11 files changed, 956 insertions(+), 965 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d955da7..dc4da6c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -54,6 +54,9 @@ under **Unreleased** until a new version is selected and
published.
capability-honest.
- Added bounded JSON Schema 2020-12 validation for tool definitions, arguments,
and declared structured outputs without external reference fetching.
+- Consolidated tool schemas, execution handlers, Doris OAuth policy, safe
+ audit metadata, compatibility aliases, and generated documentation in one
+ validated Tool Definition Registry.
- Positioned Dynamic Client Registration as a compatibility fallback behind
preconfigured clients and Client ID Metadata Documents.
- Persisted static bearer tokens as self-describing SHA-256/SHA-512 digests,
diff --git a/README.md b/README.md
index a1e409f..df0b48b 100644
--- a/README.md
+++ b/README.md
@@ -342,39 +342,12 @@ cp .env.example .env
### Available MCP Tools
-The following table lists the main tools currently available for invocation
via an MCP client:
-
-| Tool Name | Description
| Parameters |
-|-----------------------------|--------------------------------------------------------------|--------------------------------------------------------------|
-| `exec_query` | Execute SQL query and return results.
| `sql` (string, Required), `db_name` (string, Optional),
`catalog_name` (string, Optional), `max_rows` (integer, Optional), `timeout`
(integer, Optional) |
-| `get_table_schema` | Get detailed table structure information.
| `table_name` (string, Required), `db_name` (string, Optional),
`catalog_name` (string, Optional) |
-| `get_db_table_list` | Get list of all table names in specified
database. | `db_name` (string, Optional), `catalog_name` (string,
Optional) |
-| `get_db_list` | Get list of all database names.
| `catalog_name` (string, Optional) |
-| `get_table_comment` | Get table comment information.
| `table_name` (string, Required), `db_name` (string, Optional),
`catalog_name` (string, Optional) |
-| `get_table_column_comments` | Get comment information for all columns in
table. | `table_name` (string, Required), `db_name` (string,
Optional), `catalog_name` (string, Optional) |
-| `get_table_indexes` | Get index information for specified table.
| `table_name` (string, Required), `db_name` (string, Optional),
`catalog_name` (string, Optional) |
-| `get_recent_audit_logs` | Get audit log records for recent period.
| `days` (integer, Optional), `limit` (integer, Optional) |
-| `get_catalog_list` | Get list of all catalog names.
| `random_string` (string, Required) |
-| `get_sql_explain` | Get SQL execution plan with configurable
content truncation and file export for LLM analysis. | `sql`
(string, Required), `verbose` (boolean, Optional), `db_name` (string,
Optional), `catalog_name` (string, Optional) |
-| `get_sql_profile` | Get SQL execution profile with content
management and file export for LLM optimization workflows. |
`sql` (string, Required), `db_name` (string, Optional), `catalog_name` (string,
Optional), `timeout` (integer, Optional) |
-| `get_table_data_size` | Get table data size information via FE HTTP
API. | `db_name` (string, Optional), `table_name` (string, Optional),
`single_replica` (boolean, Optional) |
-| `get_monitoring_metrics_info` | Get Doris monitoring metrics definitions and
descriptions. | `role` (string, Optional), `monitor_type` (string, Optional),
`priority` (string, Optional) |
-| `get_monitoring_metrics_data` | Get actual Doris monitoring metrics data
from nodes with flexible BE discovery. | `role` (string, Optional),
`monitor_type` (string, Optional), `priority` (string, Optional) |
-| `get_realtime_memory_stats` | Get real-time memory statistics via BE Memory
Tracker with auto/manual BE discovery. | `tracker_type` (string, Optional),
`include_details` (boolean, Optional) |
-| `get_historical_memory_stats` | Get historical memory statistics via BE Bvar
interface with flexible BE configuration. | `tracker_names` (array,
Optional), `time_range` (string, Optional) |
-| `analyze_data_quality` | Comprehensive data quality analysis combining
completeness and distribution analysis. | `table_name` (string, Required),
`analysis_scope` (string, Optional), `sample_size` (integer, Optional),
`business_rules` (array, Optional) |
-| `trace_column_lineage` | End-to-end column lineage tracking through SQL
analysis and dependency mapping. | `target_columns` (array, Required),
`analysis_depth` (integer, Optional), `include_transformations` (boolean,
Optional) |
-| `monitor_data_freshness` | Real-time data staleness monitoring with
configurable freshness thresholds. | `table_names` (array, Optional),
`freshness_threshold_hours` (integer, Optional), `include_update_patterns`
(boolean, Optional) |
-| `analyze_data_access_patterns` | User behavior analysis and security anomaly
detection with access pattern monitoring. | `days` (integer, Optional),
`include_system_users` (boolean, Optional), `min_query_threshold` (integer,
Optional) |
-| `analyze_data_flow_dependencies` | Data flow impact analysis and dependency
mapping between tables and views. | `target_table` (string, Optional),
`analysis_depth` (integer, Optional), `include_views` (boolean, Optional) |
-| `analyze_slow_queries_topn` | Performance bottleneck identification with
top-N slow query analysis and patterns. | `days` (integer, Optional), `top_n`
(integer, Optional), `min_execution_time_ms` (integer, Optional),
`include_patterns` (boolean, Optional) |
-| `analyze_resource_growth_curves` | Capacity planning with resource growth
analysis and trend forecasting. | `days` (integer, Optional), `resource_types`
(array, Optional), `include_predictions` (boolean, Optional) |
-| `exec_adbc_query` | High-performance SQL execution using ADBC (Arrow Flight
SQL) protocol. | `sql` (string, Required), `max_rows` (integer, Optional),
`timeout` (integer, Optional), `return_format` (string, Optional) |
-| `get_adbc_connection_info` | ADBC connection diagnostics and status
monitoring for Arrow Flight SQL. | No parameters required |
-
-**Note:** All metadata tools support catalog federation for multi-catalog
environments. Enhanced monitoring tools provide comprehensive memory tracking
and metrics collection capabilities. **New in v0.5.0**: 7 advanced analytics
tools for enterprise data governance and 2 ADBC tools for high-performance data
transfer with 3-10x performance improvements for large datasets.
-
-**Doris-backed OAuth note:** The table above 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 Doris RBA [...]
+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 [...]
### 4. Run the Service
@@ -1440,14 +1413,20 @@ The server provides comprehensive utility modules for
common database operations
### 2. Implement Tool Logic
-Add your new tool to the `DorisToolsManager` class in
`doris_mcp_server/tools/tools_manager.py`. The tools manager provides a
centralized approach to tool registration and execution with unified interfaces.
+Add a private handler to `DorisToolsManager` in
+`doris_mcp_server/tools/tools_manager.py`. Handler names follow
+`_<tool_name>_tool`; the registry resolves this name and validates that the
+handler exists during manager construction.
**Example:** Adding a new analysis tool:
```python
# In doris_mcp_server/tools/tools_manager.py
-async def your_new_analysis_tool(self, arguments: Dict[str, Any]) ->
List[Dict[str, Any]]:
+async def _your_new_analysis_tool(
+ self,
+ arguments: dict[str, Any],
+) -> dict[str, Any]:
"""
Your new analysis tool implementation
@@ -1455,7 +1434,7 @@ async def your_new_analysis_tool(self, arguments:
Dict[str, Any]) -> List[Dict[s
arguments: Tool arguments from MCP client
Returns:
- List of MCP response messages
+ JSON-serializable tool result
"""
try:
# Use existing utilities
@@ -1464,30 +1443,24 @@ async def your_new_analysis_tool(self, arguments:
Dict[str, Any]) -> List[Dict[s
max_rows=arguments.get("max_rows", 100)
)
- return [{
- "type": "text",
- "text": json.dumps(result, ensure_ascii=False, indent=2)
- }]
+ return result
except Exception as e:
logger.error(f"Tool execution failed: {str(e)}", exc_info=True)
- return [{
- "type": "text",
- "text": f"Error: {str(e)}"
- }]
+ return {"success": False, "error": "Analysis failed"}
```
-### 3. Register the Tool
+### 3. Add the Registry Definition
-Add your tool to the `_register_tools` method in the same class:
+Add one `Tool` schema to `DorisToolsManager._build_tool_registry`, then
classify
+its policy once in `doris_mcp_server/tools/tool_registry.py`. Do not add a
+decorator wrapper or an `if/elif` dispatch branch:
```python
-# In the _register_tools method of DorisToolsManager
-
[email protected](
+Tool(
name="your_new_analysis_tool",
description="Description of your new analysis tool",
- inputSchema={
+ input_schema={
"type": "object",
"properties": {
"parameter1": {
@@ -1500,13 +1473,17 @@ Add your tool to the `_register_tools` method in the
same class:
"default": 100
}
},
- "required": ["parameter1"]
- }
+ "required": ["parameter1"],
+ },
)
-async def your_new_analysis_tool_wrapper(arguments: Dict[str, Any]) ->
List[Dict[str, Any]]:
- return await self.your_new_analysis_tool(arguments)
```
+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.
+
### 4. Advanced Features
For more complex tools, you can leverage the comprehensive framework:
diff --git a/docs/tool-registry.md b/docs/tool-registry.md
new file mode 100644
index 0000000..2001621
--- /dev/null
+++ b/docs/tool-registry.md
@@ -0,0 +1,33 @@
+# Doris MCP Tool Registry
+
+<!-- Generated from ToolDefinitionRegistry; 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_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_rows`, `return_format`, `sql`*,
`timeout` |
+| `get_adbc_connection_info` | `restricted` | `high` |
`_get_adbc_connection_info_tool` | `mcp.tool.call.get_adbc_connection_info` |
None |
+
+Required parameters are marked with `*`. Tool descriptions and JSON Schemas
are exposed directly by MCP `tools/list` from the same registry entries.
diff --git a/doris_mcp_server/auth/doris_oauth_scope_policy.py
b/doris_mcp_server/auth/doris_oauth_scope_policy.py
index 29c5128..517a5f5 100644
--- a/doris_mcp_server/auth/doris_oauth_scope_policy.py
+++ b/doris_mcp_server/auth/doris_oauth_scope_policy.py
@@ -4,6 +4,11 @@
from collections.abc import Iterable
from typing import TYPE_CHECKING
+from ..tools.tool_registry import (
+ DORIS_OAUTH_EXPLAIN_TOOL_SET,
+ DORIS_OAUTH_METADATA_TOOL_NAMES,
+ DORIS_OAUTH_QUERY_TOOL_SET,
+)
from .doris_oauth_types import TokenEndpointError
if TYPE_CHECKING:
@@ -12,15 +17,7 @@ if TYPE_CHECKING:
BASE_DORIS_OAUTH_SCOPES = frozenset({"tool:list"})
RESOURCE_SCOPES = frozenset({"resource:list", "resource:read"})
-DORIS_OAUTH_METADATA_TOOLS = (
- "get_db_list",
- "get_db_table_list",
- "get_table_schema",
- "get_table_comment",
- "get_table_column_comments",
- "get_table_indexes",
- "get_catalog_list",
-)
+DORIS_OAUTH_METADATA_TOOLS = DORIS_OAUTH_METADATA_TOOL_NAMES
METADATA_DB_SCOPES = frozenset(
{
@@ -28,12 +25,20 @@ METADATA_DB_SCOPES = frozenset(
}
)
-QUERY_DB_SCOPES = frozenset({"tool:call:exec_query"})
-EXPLAIN_DB_SCOPES = frozenset({"tool:call:get_sql_explain"})
+QUERY_DB_SCOPES = frozenset(
+ f"tool:call:{tool_name}" for tool_name in DORIS_OAUTH_QUERY_TOOL_SET
+)
+EXPLAIN_DB_SCOPES = frozenset(
+ f"tool:call:{tool_name}" for tool_name in DORIS_OAUTH_EXPLAIN_TOOL_SET
+)
PENDING_DB_SCOPES = METADATA_DB_SCOPES | QUERY_DB_SCOPES | EXPLAIN_DB_SCOPES
TOOL_SCOPE_ALIASES = {
tool_name: f"tool:call:{tool_name}"
- for tool_name in (*DORIS_OAUTH_METADATA_TOOLS, "exec_query",
"get_sql_explain")
+ for tool_name in (
+ *DORIS_OAUTH_METADATA_TOOLS,
+ *DORIS_OAUTH_QUERY_TOOL_SET,
+ *DORIS_OAUTH_EXPLAIN_TOOL_SET,
+ )
}
FORBIDDEN_DORIS_OAUTH_SCOPES = frozenset(
@@ -99,16 +104,24 @@ class DorisOAuthScopePolicy:
if getattr(security_config, "doris_oauth_query_tools_enabled", False):
tool_names = self._configured_tool_names(
- getattr(security_config, "doris_oauth_query_tool_allowlist",
("exec_query",))
+ getattr(
+ security_config,
+ "doris_oauth_query_tool_allowlist",
+ tuple(DORIS_OAUTH_QUERY_TOOL_SET),
+ )
)
- if "exec_query" in tool_names:
+ if DORIS_OAUTH_QUERY_TOOL_SET.intersection(tool_names):
allowed.update(QUERY_DB_SCOPES)
if getattr(security_config, "doris_oauth_explain_tools_enabled",
False):
tool_names = self._configured_tool_names(
- getattr(security_config, "doris_oauth_explain_tool_allowlist",
("get_sql_explain",))
+ getattr(
+ security_config,
+ "doris_oauth_explain_tool_allowlist",
+ tuple(DORIS_OAUTH_EXPLAIN_TOOL_SET),
+ )
)
- if "get_sql_explain" in tool_names:
+ if DORIS_OAUTH_EXPLAIN_TOOL_SET.intersection(tool_names):
allowed.update(EXPLAIN_DB_SCOPES)
return allowed
diff --git a/doris_mcp_server/auth/operation_policy.py
b/doris_mcp_server/auth/operation_policy.py
index dea8d20..28ebf06 100644
--- a/doris_mcp_server/auth/operation_policy.py
+++ b/doris_mcp_server/auth/operation_policy.py
@@ -20,6 +20,15 @@
from dataclasses import dataclass
from typing import Any
+from ..tools.tool_registry import (
+ DORIS_OAUTH_EXPLAIN_TOOL_SET,
+ DORIS_OAUTH_METADATA_TOOL_NAMES,
+ DORIS_OAUTH_METADATA_TOOL_SET,
+ DORIS_OAUTH_QUERY_TOOL_SET,
+ RESTRICTED_TOOL_NAMES,
+ policy_definition_for_tool,
+)
+
@dataclass(frozen=True)
class OperationPolicy:
@@ -62,29 +71,10 @@ class OperationAuthorizationError(Exception):
return payload
-P4_DORIS_OAUTH_METADATA_TOOLS = frozenset(
- {
- "get_db_list",
- "get_db_table_list",
- "get_table_schema",
- "get_table_comment",
- "get_table_column_comments",
- "get_table_indexes",
- "get_catalog_list",
- }
-)
-
-P4_DORIS_OAUTH_QUERY_TOOLS = frozenset({"exec_query"})
-P4_DORIS_OAUTH_EXPLAIN_TOOLS = frozenset({"get_sql_explain"})
-P4_DORIS_OAUTH_DEFAULT_METADATA_TOOL_ALLOWLIST = (
- "get_db_list",
- "get_db_table_list",
- "get_table_schema",
- "get_table_comment",
- "get_table_column_comments",
- "get_table_indexes",
- "get_catalog_list",
- )
+P4_DORIS_OAUTH_METADATA_TOOLS = DORIS_OAUTH_METADATA_TOOL_SET
+P4_DORIS_OAUTH_QUERY_TOOLS = DORIS_OAUTH_QUERY_TOOL_SET
+P4_DORIS_OAUTH_EXPLAIN_TOOLS = DORIS_OAUTH_EXPLAIN_TOOL_SET
+P4_DORIS_OAUTH_DEFAULT_METADATA_TOOL_ALLOWLIST =
DORIS_OAUTH_METADATA_TOOL_NAMES
# Phase 4 keeps DB-backed Doris OAuth tools fail-closed unless configuration
and
# exact tool scope both allow a reviewed metadata tool.
@@ -96,28 +86,7 @@ DORIS_OAUTH_QUERY_TOOL_ALLOWLIST = ("exec_query",)
DORIS_OAUTH_EXPLAIN_TOOLS_ENABLED = False
DORIS_OAUTH_EXPLAIN_TOOL_ALLOWLIST = ("get_sql_explain",)
-HIGH_RISK_TOOLS = {
- "get_recent_audit_logs",
- "get_sql_profile",
- "get_table_data_size",
- "get_monitoring_metrics",
- "get_memory_stats",
- "get_monitoring_metrics_info",
- "get_monitoring_metrics_data",
- "get_realtime_memory_stats",
- "get_historical_memory_stats",
- "get_table_basic_info",
- "analyze_columns",
- "analyze_table_storage",
- "trace_column_lineage",
- "monitor_data_freshness",
- "analyze_data_access_patterns",
- "analyze_data_flow_dependencies",
- "analyze_slow_queries_topn",
- "analyze_resource_growth_curves",
- "exec_adbc_query",
- "get_adbc_connection_info",
-}
+HIGH_RISK_TOOLS = RESTRICTED_TOOL_NAMES
def normalize_doris_oauth_metadata_tool_allowlist(tools: Any = None) ->
tuple[str, ...]:
@@ -240,7 +209,16 @@ def _allowed_metadata_tool_policy(tool_name: str) ->
OperationPolicy:
def _tool_policy(tool_name: str, auth_context: Any = None) -> OperationPolicy:
- if tool_name in P4_DORIS_OAUTH_METADATA_TOOLS:
+ registry_policy = policy_definition_for_tool(tool_name)
+ if registry_policy is None:
+ return _denied_tool_policy(
+ tool_name,
+ channel="unknown",
+ risk="unknown",
+ error_code="UNKNOWN_OPERATION",
+ )
+
+ if registry_policy.policy_class == "metadata":
if not _metadata_tools_enabled(auth_context):
return _denied_tool_policy(
tool_name,
@@ -266,7 +244,7 @@ def _tool_policy(tool_name: str, auth_context: Any = None)
-> OperationPolicy:
)
return _allowed_metadata_tool_policy(tool_name)
- if tool_name in P4_DORIS_OAUTH_QUERY_TOOLS:
+ if registry_policy.policy_class == "query":
if not _query_tools_enabled(auth_context):
return _denied_tool_policy(
tool_name,
@@ -289,7 +267,7 @@ def _tool_policy(tool_name: str, auth_context: Any = None)
-> OperationPolicy:
risk="query",
)
- if tool_name in P4_DORIS_OAUTH_EXPLAIN_TOOLS:
+ if registry_policy.policy_class == "explain":
if not _explain_tools_enabled(auth_context):
return _denied_tool_policy(
tool_name,
@@ -312,20 +290,16 @@ def _tool_policy(tool_name: str, auth_context: Any =
None) -> OperationPolicy:
risk="explain",
)
- if tool_name in HIGH_RISK_TOOLS:
+ if registry_policy.policy_class == "restricted":
return _denied_tool_policy(
tool_name,
- channel="unsupported",
- risk="high",
- error_code="UNSUPPORTED_FOR_DORIS_OAUTH",
+ channel=registry_policy.channel,
+ risk=registry_policy.risk,
+ error_code=registry_policy.error_code
+ or "UNSUPPORTED_FOR_DORIS_OAUTH",
)
- return _denied_tool_policy(
- tool_name,
- channel="unknown",
- risk="unknown",
- error_code="UNKNOWN_OPERATION",
- )
+ raise AssertionError(f"Unhandled registry policy:
{registry_policy.policy_class}")
OPERATION_POLICIES: dict[str, OperationPolicy] = {
diff --git a/doris_mcp_server/tools/tool_registry.py
b/doris_mcp_server/tools/tool_registry.py
new file mode 100644
index 0000000..2f742ef
--- /dev/null
+++ b/doris_mcp_server/tools/tool_registry.py
@@ -0,0 +1,356 @@
+# 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.
+"""Single source of truth for Doris MCP tool definitions."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable, Iterable
+from dataclasses import dataclass
+from types import MappingProxyType
+from typing import Any, Literal, Protocol, cast
+
+from mcp.types import Tool
+
+ToolPolicyClass = Literal["metadata", "query", "explain", "restricted"]
+ToolHandler = Callable[[dict[str, Any]], Awaitable[dict[str, Any]]]
+
+DORIS_OAUTH_METADATA_TOOL_NAMES = (
+ "get_db_list",
+ "get_db_table_list",
+ "get_table_schema",
+ "get_table_comment",
+ "get_table_column_comments",
+ "get_table_indexes",
+ "get_catalog_list",
+)
+DORIS_OAUTH_METADATA_TOOL_SET = frozenset(DORIS_OAUTH_METADATA_TOOL_NAMES)
+DORIS_OAUTH_QUERY_TOOL_SET = frozenset({"exec_query"})
+DORIS_OAUTH_EXPLAIN_TOOL_SET = frozenset({"get_sql_explain"})
+
+_LEGACY_ALIASES: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...] = (
+ (
+ "get_monitoring_metrics_info",
+ "get_monitoring_metrics",
+ (("content_type", "definitions"),),
+ ),
+ (
+ "get_monitoring_metrics_data",
+ "get_monitoring_metrics",
+ (("content_type", "data"),),
+ ),
+ (
+ "get_realtime_memory_stats",
+ "get_memory_stats",
+ (("data_type", "realtime"),),
+ ),
+ (
+ "get_historical_memory_stats",
+ "get_memory_stats",
+ (("data_type", "historical"),),
+ ),
+)
+LEGACY_TOOL_ALIAS_NAMES = frozenset(alias for alias, _, _ in _LEGACY_ALIASES)
+RESTRICTED_TOOL_NAMES = frozenset(
+ {
+ "get_recent_audit_logs",
+ "get_sql_profile",
+ "get_table_data_size",
+ "get_monitoring_metrics",
+ "get_memory_stats",
+ "get_table_basic_info",
+ "analyze_columns",
+ "analyze_table_storage",
+ "trace_column_lineage",
+ "monitor_data_freshness",
+ "analyze_data_access_patterns",
+ "analyze_data_flow_dependencies",
+ "analyze_slow_queries_topn",
+ "analyze_resource_growth_curves",
+ "exec_adbc_query",
+ "get_adbc_connection_info",
+ }
+) | LEGACY_TOOL_ALIAS_NAMES
+
+
+class ToolRegistryError(ValueError):
+ """Raised when the tool registry is invalid or a tool is unknown."""
+
+
+class ToolHandlerOwner(Protocol):
+ """Structural type for objects that own registry handlers."""
+
+
+@dataclass(frozen=True)
+class ToolPolicyDefinition:
+ """Authorization metadata consumed by the operation policy."""
+
+ policy_class: ToolPolicyClass
+ channel: str
+ risk: str
+ error_code: str | None = None
+
+
+@dataclass(frozen=True)
+class ToolAuditDefinition:
+ """Safe audit metadata derived from the advertised tool schema."""
+
+ event_name: str
+ category: str
+ risk: str
+ argument_names: tuple[str, ...]
+ sensitive_arguments: tuple[str, ...]
+
+
+@dataclass(frozen=True)
+class ToolDefinition:
+ """Schema, policy, handler, audit, and documentation for one tool."""
+
+ name: str
+ canonical_name: str
+ tool: Tool | None
+ handler_name: str
+ policy: ToolPolicyDefinition
+ audit: ToolAuditDefinition
+ advertised: bool = True
+ argument_overrides: tuple[tuple[str, Any], ...] = ()
+
+ def bind_handler(self, owner: ToolHandlerOwner) -> ToolHandler:
+ handler = getattr(owner, self.handler_name, None)
+ if not callable(handler):
+ raise ToolRegistryError(
+ f"Tool {self.name!r} references missing handler
{self.handler_name!r}"
+ )
+ return cast(ToolHandler, handler)
+
+ def prepare_arguments(self, arguments: dict[str, Any]) -> dict[str, Any]:
+ prepared = dict(arguments)
+ prepared.update(self.argument_overrides)
+ return prepared
+
+
+def _policy_for_name(name: str) -> ToolPolicyDefinition:
+ if name in DORIS_OAUTH_METADATA_TOOL_SET:
+ return ToolPolicyDefinition("metadata", "mysql_metadata", "metadata")
+ if name in DORIS_OAUTH_QUERY_TOOL_SET:
+ return ToolPolicyDefinition("query", "mysql_query", "query")
+ if name in DORIS_OAUTH_EXPLAIN_TOOL_SET:
+ return ToolPolicyDefinition("explain", "mysql_explain", "explain")
+ if name in RESTRICTED_TOOL_NAMES:
+ return ToolPolicyDefinition(
+ "restricted",
+ "unsupported",
+ "high",
+ "UNSUPPORTED_FOR_DORIS_OAUTH",
+ )
+ raise ToolRegistryError(f"Tool {name!r} has no policy definition")
+
+
+def _audit_category(name: str, policy: ToolPolicyDefinition) -> str:
+ if name == "get_recent_audit_logs":
+ return "audit"
+ if name.startswith(("get_monitoring_", "get_memory_", "get_realtime_")):
+ return "monitoring"
+ if name.startswith("get_historical_") or name == "get_table_data_size":
+ return "monitoring"
+ if "adbc" in name:
+ return "adbc"
+ if name.startswith(("analyze_", "trace_", "monitor_")):
+ return "analytics"
+ if name in {"get_sql_profile", "get_sql_explain", "exec_query"}:
+ return "query"
+ return policy.policy_class
+
+
+def _audit_for_tool(
+ name: str,
+ tool: Tool | None,
+ policy: ToolPolicyDefinition,
+) -> ToolAuditDefinition:
+ properties: dict[str, Any] = {}
+ if tool is not None:
+ raw_properties = tool.input_schema.get("properties", {})
+ if isinstance(raw_properties, dict):
+ properties = raw_properties
+ argument_names = tuple(sorted(str(key) for key in properties))
+ sensitive_arguments = tuple(
+ argument for argument in argument_names if argument in {"sql"}
+ )
+ return ToolAuditDefinition(
+ event_name=f"mcp.tool.call.{name}",
+ category=_audit_category(name, policy),
+ risk=policy.risk,
+ argument_names=argument_names,
+ sensitive_arguments=sensitive_arguments,
+ )
+
+
+class ToolDefinitionRegistry:
+ """Immutable registry used by listing, dispatch, policy, audit, and
docs."""
+
+ def __init__(
+ self,
+ definitions: Iterable[ToolDefinition],
+ owner: ToolHandlerOwner,
+ ) -> None:
+ definitions_by_name: dict[str, ToolDefinition] = {}
+ ordered_names: list[str] = []
+ for definition in definitions:
+ if definition.name in definitions_by_name:
+ raise ToolRegistryError(
+ f"Duplicate tool definition: {definition.name}"
+ )
+ definition.bind_handler(owner)
+ if definition.advertised and definition.tool is None:
+ raise ToolRegistryError(
+ f"Advertised tool {definition.name!r} has no MCP schema"
+ )
+ definitions_by_name[definition.name] = definition
+ ordered_names.append(definition.name)
+
+ self._definitions = MappingProxyType(definitions_by_name)
+ self._ordered_names = tuple(ordered_names)
+
+ @classmethod
+ def from_tools(
+ cls,
+ tools: Iterable[Tool],
+ owner: ToolHandlerOwner,
+ ) -> ToolDefinitionRegistry:
+ tools_by_name: dict[str, Tool] = {}
+ definitions: list[ToolDefinition] = []
+ for tool in tools:
+ if tool.name in tools_by_name:
+ raise ToolRegistryError(f"Duplicate tool schema: {tool.name}")
+ tools_by_name[tool.name] = tool
+ policy = _policy_for_name(tool.name)
+ definitions.append(
+ ToolDefinition(
+ name=tool.name,
+ canonical_name=tool.name,
+ tool=tool,
+ handler_name=f"_{tool.name}_tool",
+ policy=policy,
+ audit=_audit_for_tool(tool.name, tool, policy),
+ )
+ )
+
+ for alias, canonical_name, overrides in _LEGACY_ALIASES:
+ if canonical_name not in tools_by_name:
+ raise ToolRegistryError(
+ f"Legacy alias {alias!r} references unknown tool "
+ f"{canonical_name!r}"
+ )
+ policy = _policy_for_name(alias)
+ definitions.append(
+ ToolDefinition(
+ name=alias,
+ canonical_name=canonical_name,
+ tool=tools_by_name[canonical_name],
+ handler_name=f"_{canonical_name}_tool",
+ policy=policy,
+ audit=_audit_for_tool(
+ alias,
+ tools_by_name[canonical_name],
+ policy,
+ ),
+ advertised=False,
+ argument_overrides=overrides,
+ )
+ )
+
+ return cls(definitions, owner)
+
+ @property
+ def definitions(self) -> tuple[ToolDefinition, ...]:
+ return tuple(
+ self._definitions[name] for name in self._ordered_names
+ )
+
+ @property
+ def advertised_definitions(self) -> tuple[ToolDefinition, ...]:
+ return tuple(
+ definition
+ for definition in self.definitions
+ if definition.advertised
+ )
+
+ @property
+ def advertised_names(self) -> tuple[str, ...]:
+ return tuple(
+ definition.name for definition in self.advertised_definitions
+ )
+
+ def resolve(self, name: str) -> ToolDefinition:
+ try:
+ return self._definitions[name]
+ except KeyError as exc:
+ raise ToolRegistryError(f"Unknown tool: {name}") from exc
+
+ def listed_tools(self) -> list[Tool]:
+ return [
+ cast(Tool, definition.tool)
+ for definition in self.advertised_definitions
+ ]
+
+ def render_markdown(self) -> str:
+ """Render the checked-in public catalog from registry definitions."""
+ lines = [
+ "# Doris MCP Tool Registry",
+ "",
+ "<!-- Generated from ToolDefinitionRegistry; do not edit by hand.
-->",
+ "",
+ "| Tool | Policy | Risk | Handler | Audit event | Parameters |",
+ "|---|---|---|---|---|---|",
+ ]
+ for definition in self.advertised_definitions:
+ tool = cast(Tool, definition.tool)
+ required = set(tool.input_schema.get("required", []))
+ parameters = ", ".join(
+ f"`{name}`{'*' if name in required else ''}"
+ for name in definition.audit.argument_names
+ )
+ lines.append(
+ "| "
+ f"`{definition.name}` | "
+ f"`{definition.policy.policy_class}` | "
+ f"`{definition.policy.risk}` | "
+ f"`{definition.handler_name}` | "
+ f"`{definition.audit.event_name}` | "
+ f"{parameters or 'None'} |"
+ )
+ lines.extend(
+ [
+ "",
+ "Required parameters are marked with `*`. Tool descriptions
and "
+ "JSON Schemas are exposed directly by MCP `tools/list` from
the "
+ "same registry entries.",
+ "",
+ ]
+ )
+ return "\n".join(lines)
+
+
+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
+ 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
diff --git a/doris_mcp_server/tools/tools_manager.py
b/doris_mcp_server/tools/tools_manager.py
index 8c6cc0c..b103fe2 100644
--- a/doris_mcp_server/tools/tools_manager.py
+++ b/doris_mcp_server/tools/tools_manager.py
@@ -24,7 +24,6 @@ import time
from datetime import datetime
from typing import Any
-from mcp.server.mcpserver import MCPServer
from mcp.types import Tool
from ..auth.operation_policy import (
@@ -34,18 +33,20 @@ from ..auth.operation_policy import (
)
from ..utils.adbc_query_tools import DorisADBCQueryTools
from ..utils.analysis_tools import MemoryTracker, SQLAnalyzer, TableAnalyzer
+from ..utils.config import ADBCConfig
from ..utils.data_exploration_tools import DataExplorationTools
from ..utils.data_governance_tools import DataGovernanceTools
from ..utils.data_quality_tools import DataQualityTools
from ..utils.db import DorisConnectionManager
from ..utils.dependency_analysis_tools import DependencyAnalysisTools
-from ..utils.logger import get_logger
+from ..utils.logger import get_audit_logger, get_logger
from ..utils.monitoring_tools import DorisMonitoringTools
from ..utils.performance_analytics_tools import PerformanceAnalyticsTools
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 .tool_registry import ToolDefinition, ToolDefinitionRegistry
logger = get_logger(__name__)
@@ -78,6 +79,7 @@ class DorisToolsManager:
# Initialize ADBC query tools
self.adbc_query_tools = DorisADBCQueryTools(connection_manager)
+ self._tool_registry = self._build_tool_registry()
logger.info(
"DorisToolsManager initialized with business logic processors,
v0.5.0 analytics tools, and ADBC query tools"
@@ -112,758 +114,12 @@ class DorisToolsManager:
raise ValueError(f"{name} must be a non-empty list of strings")
return value
- async def register_tools_with_mcp(self, mcp: MCPServer[Any]) -> None:
- """Register all tools to MCP server"""
- logger.info("Starting to register MCP tools")
-
- # SQL query execution tool (supports catalog federation queries)
- @mcp.tool(
- "exec_query",
- description="""[Function Description]: Execute SQL query and
return result command with catalog federation support.
-
-[Parameter Content]:
-
-- sql (string) [Required] - SQL statement to execute. MUST use three-part
naming for all table references: 'catalog_name.db_name.table_name'. For
internal tables use 'internal.db_name.table_name', for external tables use
'catalog_name.db_name.table_name'
-
-- db_name (string) [Optional] - Target database name, defaults to the current
database
-
-- catalog_name (string) [Optional] - Reference catalog name for context,
defaults to current catalog
-
-- max_rows (integer) [Optional] - Maximum number of rows to return, default 100
-
-- timeout (integer) [Optional] - Query timeout in seconds, default 30
-""",
- )
- async def exec_query_tool(
- sql: str,
- db_name: str | None = None,
- catalog_name: str | None = None,
- max_rows: int = 100,
- timeout: int = 30,
- ) -> str:
- """Execute SQL query (supports federation queries)"""
- return await self.call_tool(
- "exec_query",
- {
- "sql": sql,
- "db_name": db_name,
- "catalog_name": catalog_name,
- "max_rows": max_rows,
- "timeout": timeout,
- },
- )
-
- # Get table schema tool
- @mcp.tool(
- "get_table_schema",
- description="""[Function Description]: Get detailed structure
information of the specified table (columns, types, comments, etc.).
-
-[Parameter Content]:
-
-- table_name (string) [Required] - Name of the table to query
-
-- db_name (string) [Optional] - Target database name, defaults to the current
database
-
-- catalog_name (string) [Optional] - Target catalog name for federation
queries, defaults to current catalog
-""",
- )
- async def get_table_schema_tool(
- table_name: str, db_name: str | None = None, catalog_name: str |
None = None
- ) -> str:
- """Get table schema information"""
- return await self.call_tool(
- "get_table_schema",
- {
- "table_name": table_name,
- "db_name": db_name,
- "catalog_name": catalog_name,
- },
- )
-
- # Get database table list tool
- @mcp.tool(
- "get_db_table_list",
- description="""[Function Description]: Get a list of all table
names in the specified database.
-
-[Parameter Content]:
-
-- db_name (string) [Optional] - Target database name, defaults to the current
database
-
-- catalog_name (string) [Optional] - Target catalog name for federation
queries, defaults to current catalog
-""",
- )
- async def get_db_table_list_tool(
- db_name: str | None = None, catalog_name: str | None = None
- ) -> str:
- """Get database table list"""
- return await self.call_tool(
- "get_db_table_list", {"db_name": db_name, "catalog_name":
catalog_name}
- )
-
- # Get database list tool
- @mcp.tool(
- "get_db_list",
- description="""[Function Description]: Get a list of all database
names on the server.
-
-[Parameter Content]:
-
-- catalog_name (string) [Optional] - Target catalog name for federation
queries, defaults to current catalog
-""",
- )
- async def get_db_list_tool(catalog_name: str | None = None) -> str:
- """Get database list"""
- return await self.call_tool("get_db_list", {"catalog_name":
catalog_name})
-
- # Get table comment tool
- @mcp.tool(
- "get_table_comment",
- description="""[Function Description]: Get the comment information
for the specified table.
-
-[Parameter Content]:
-
-- table_name (string) [Required] - Name of the table to query
-
-- db_name (string) [Optional] - Target database name, defaults to the current
database
-
-- catalog_name (string) [Optional] - Target catalog name for federation
queries, defaults to current catalog
-""",
- )
- async def get_table_comment_tool(
- table_name: str, db_name: str | None = None, catalog_name: str |
None = None
- ) -> str:
- """Get table comment"""
- return await self.call_tool(
- "get_table_comment",
- {
- "table_name": table_name,
- "db_name": db_name,
- "catalog_name": catalog_name,
- },
- )
-
- # Get table column comments tool
- @mcp.tool(
- "get_table_column_comments",
- description="""[Function Description]: Get comment information for
all columns in the specified table.
-
-[Parameter Content]:
-
-- table_name (string) [Required] - Name of the table to query
-
-- db_name (string) [Optional] - Target database name, defaults to the current
database
-
-- catalog_name (string) [Optional] - Target catalog name for federation
queries, defaults to current catalog
-""",
- )
- async def get_table_column_comments_tool(
- table_name: str, db_name: str | None = None, catalog_name: str |
None = None
- ) -> str:
- """Get table column comments"""
- return await self.call_tool(
- "get_table_column_comments",
- {
- "table_name": table_name,
- "db_name": db_name,
- "catalog_name": catalog_name,
- },
- )
-
- # Get table indexes tool
- @mcp.tool(
- "get_table_indexes",
- description="""[Function Description]: Get index information for
the specified table.
-
-[Parameter Content]:
-
-- table_name (string) [Required] - Name of the table to query
-
-- db_name (string) [Optional] - Target database name, defaults to the current
database
-
-- catalog_name (string) [Optional] - Target catalog name for federation
queries, defaults to current catalog
-""",
- )
- async def get_table_indexes_tool(
- table_name: str, db_name: str | None = None, catalog_name: str |
None = None
- ) -> str:
- """Get table indexes"""
- return await self.call_tool(
- "get_table_indexes",
- {
- "table_name": table_name,
- "db_name": db_name,
- "catalog_name": catalog_name,
- },
- )
-
- # Get audit logs tool
- @mcp.tool(
- "get_recent_audit_logs",
- description="""[Function Description]: Get audit log records for a
recent period.
-
-[Parameter Content]:
-
-- days (integer) [Optional] - Number of recent days of logs to retrieve,
default is 7
-
-- limit (integer) [Optional] - Maximum number of records to return, default is
100
-""",
- )
- async def get_recent_audit_logs_tool(days: int = 7, limit: int = 100)
-> str:
- """Get audit logs"""
- return await self.call_tool(
- "get_recent_audit_logs", {"days": days, "limit": limit}
- )
-
- # Get catalog list tool
- @mcp.tool(
- "get_catalog_list",
- description="""[Function Description]: Get a list of all catalog
names on the server.
-
-[Parameter Content]:
-
-- random_string (string) [Required] - Unique identifier for the tool call
-""",
- )
- async def get_catalog_list_tool(random_string: str) -> str:
- """Get catalog list"""
- return await self.call_tool(
- "get_catalog_list", {"random_string": random_string}
- )
-
- # SQL Explain tool
- @mcp.tool(
- "get_sql_explain",
- description="""[Function Description]: Get SQL execution plan
using EXPLAIN command based on Doris syntax.
-
-[Parameter Content]:
-
-- sql (string) [Required] - SQL statement to explain
-
-- verbose (boolean) [Optional] - Whether to show verbose information, default
is false
-
-- db_name (string) [Optional] - Target database name, defaults to the current
database
-
-- catalog_name (string) [Optional] - Target catalog name for federation
queries, defaults to current catalog
-""",
- )
- async def get_sql_explain_tool(
- sql: str,
- verbose: bool = False,
- db_name: str | None = None,
- catalog_name: str | None = None,
- ) -> str:
- """Get SQL execution plan"""
- return await self.call_tool(
- "get_sql_explain",
- {
- "sql": sql,
- "verbose": verbose,
- "db_name": db_name,
- "catalog_name": catalog_name,
- },
- )
-
- # SQL Profile tool
- @mcp.tool(
- "get_sql_profile",
- description="""[Function Description]: Get SQL execution profile
by setting trace ID and fetching profile via FE HTTP API.
-
-[Parameter Content]:
-
-- sql (string) [Required] - SQL statement to profile
-
-- db_name (string) [Optional] - Target database name, defaults to the current
database
-
-- catalog_name (string) [Optional] - Target catalog name for federation
queries, defaults to current catalog
-
-- timeout (integer) [Optional] - Query timeout in seconds, default is 30
-""",
- )
- async def get_sql_profile_tool(
- sql: str,
- db_name: str | None = None,
- catalog_name: str | None = None,
- timeout: int = 30,
- ) -> str:
- """Get SQL execution profile"""
- return await self.call_tool(
- "get_sql_profile",
- {
- "sql": sql,
- "db_name": db_name,
- "catalog_name": catalog_name,
- "timeout": timeout,
- },
- )
-
- # Table data size tool
- @mcp.tool(
- "get_table_data_size",
- description="""[Function Description]: Get table data size
information via FE HTTP API.
-
-[Parameter Content]:
-
-- db_name (string) [Optional] - Database name, if not specified returns all
databases
-
-- table_name (string) [Optional] - Table name, if not specified returns all
tables in the database
-
-- single_replica (boolean) [Optional] - Whether to get single replica data
size, default is false
-""",
- )
- async def get_table_data_size_tool(
- db_name: str | None = None,
- table_name: str | None = None,
- single_replica: bool = False,
- ) -> str:
- """Get table data size information"""
- return await self.call_tool(
- "get_table_data_size",
- {
- "db_name": db_name,
- "table_name": table_name,
- "single_replica": single_replica,
- },
- )
-
- # Unified Monitoring Metrics Tool (combines definitions and data)
- @mcp.tool(
- "get_monitoring_metrics",
- description="""[Function Description]: Get comprehensive Doris
monitoring metrics including definitions and/or actual data from FE and BE
nodes.
-
-[Parameter Content]:
-
-- content_type (string) [Optional] - Type of monitoring content to retrieve,
default is "data"
- * "definitions": Only metric definitions and descriptions
- * "data": Only actual metric data from nodes
- * "both": Both definitions and data
-
-- role (string) [Optional] - Node role to monitor, default is "all"
- * "fe": Only FE nodes/metrics
- * "be": Only BE nodes/metrics
- * "all": Both FE and BE nodes/metrics
-
-- monitor_type (string) [Optional] - Type of monitoring metrics, default is
"all"
- * "process": Process monitoring metrics
- * "jvm": JVM monitoring metrics (FE only)
- * "machine": Machine monitoring metrics
- * "all": All monitoring types
-
-- priority (string) [Optional] - Metric priority level, default is "core"
- * "core": Only core essential metrics (10-12 items for production use)
- * "p0": Only P0 (highest priority) metrics
- * "all": All metrics (P0 and non-P0)
-
-- include_raw_metrics (boolean) [Optional] - Whether to include raw detailed
metrics data (can be very large), default is false
-""",
- )
- async def get_monitoring_metrics_tool(
- content_type: str = "data",
- role: str = "all",
- monitor_type: str = "all",
- priority: str = "core",
- include_raw_metrics: bool = False,
- ) -> str:
- """Get comprehensive monitoring metrics (definitions and/or
data)"""
- return await self.call_tool(
- "get_monitoring_metrics",
- {
- "content_type": content_type,
- "role": role,
- "monitor_type": monitor_type,
- "priority": priority,
- "include_raw_metrics": include_raw_metrics,
- },
- )
-
- # Unified Memory Statistics Tool (combines real-time and historical)
- @mcp.tool(
- "get_memory_stats",
- description="""[Function Description]: Get comprehensive memory
statistics from Doris BE nodes, supporting both real-time and historical data.
-
-[Parameter Content]:
-
-- data_type (string) [Optional] - Type of memory data to retrieve, default is
"realtime"
- * "realtime": Real-time memory statistics via Memory Tracker web interface
- * "historical": Historical memory statistics via Bvar interface
- * "both": Both real-time and historical data
-
-- tracker_type (string) [Optional] - Type of memory trackers to retrieve (for
real-time), default is "overview"
- * "overview": Overview type trackers (process memory, tracked memory summary)
- * "global": Global shared memory trackers (cache, metadata)
- * "query": Query-related memory trackers
- * "load": Load-related memory trackers
- * "compaction": Compaction-related memory trackers
- * "all": All memory tracker types
-
-- tracker_names (array) [Optional] - List of specific tracker names for
historical data
- * Example: ["process_resident_memory", "global", "query", "load",
"compaction"]
-
-- time_range (string) [Optional] - Time range for historical data, default is
"1h"
- * "1h": Last 1 hour
- * "6h": Last 6 hours
- * "24h": Last 24 hours
-
-- include_details (boolean) [Optional] - Whether to include detailed tracker
information and definitions, default is true
-""",
- )
- async def get_memory_stats_tool(
- data_type: str = "realtime",
- tracker_type: str = "overview",
- tracker_names: list[str] | None = None,
- time_range: str = "1h",
- include_details: bool = True,
- ) -> str:
- """Get comprehensive memory statistics (real-time and/or
historical)"""
- return await self.call_tool(
- "get_memory_stats",
- {
- "data_type": data_type,
- "tracker_type": tracker_type,
- "tracker_names": tracker_names,
- "time_range": time_range,
- "include_details": include_details,
- },
- )
-
- # ==================== v0.5.0 Advanced Analytics Tools
====================
-
- # 🔄 Unified Data Quality Analysis Tool (New in v0.5.0)
- @mcp.tool(
- "get_table_basic_info",
- description="""[Function Description]: Get basic information about
a table including row count, column count, partitions, and size.
-
-[Parameter Content]:
-
-- table_name (string) [Required] - Name of the table to analyze
-- catalog_name (string) [Optional] - Target catalog name
-- db_name (string) [Optional] - Target database name
-""",
- )
- async def get_table_basic_info_tool(
- table_name: str, catalog_name: str | None = None, db_name: str |
None = None
- ) -> str:
- """Get table basic information"""
- return await self.call_tool(
- "get_table_basic_info",
- {
- "table_name": table_name,
- "catalog_name": catalog_name,
- "db_name": db_name,
- },
- )
-
- @mcp.tool(
- "analyze_columns",
- description="""[Function Description]: Analyze completeness and
distribution of specified columns in a table.
-
-[Parameter Content]:
-
-- table_name (string) [Required] - Name of the table to analyze
-- columns (array) [Required] - List of column names to analyze
-- analysis_types (array) [Optional] - Types of analysis to perform, default is
["both"]
- * "completeness": Only completeness analysis (null rates, non-null counts)
- * "distribution": Only distribution analysis (statistical patterns by data
type)
- * "both": Both completeness and distribution analysis
-- sample_size (integer) [Optional] - Maximum number of rows to sample, default
is 100000
-- catalog_name (string) [Optional] - Target catalog name
-- db_name (string) [Optional] - Target database name
-- detailed_response (boolean) [Optional] - Whether to return detailed response
including raw data, default is false
-""",
- )
- async def analyze_columns_tool(
- table_name: str,
- columns: list[str],
- analysis_types: list[str] | None = None,
- sample_size: int = 100000,
- catalog_name: str | None = None,
- db_name: str | None = None,
- detailed_response: bool = False,
- ) -> str:
- """Analyze table columns"""
- return await self.call_tool(
- "analyze_columns",
- {
- "table_name": table_name,
- "columns": columns,
- "analysis_types": analysis_types or ["both"],
- "sample_size": sample_size,
- "catalog_name": catalog_name,
- "db_name": db_name,
- "detailed_response": detailed_response,
- },
- )
-
- @mcp.tool(
- "analyze_table_storage",
- description="""[Function Description]: Analyze table's physical
distribution and storage information.
-
-[Parameter Content]:
-
-- table_name (string) [Required] - Name of the table to analyze
-- catalog_name (string) [Optional] - Target catalog name
-- db_name (string) [Optional] - Target database name
-- detailed_response (boolean) [Optional] - Whether to return detailed response
including raw data, default is false
-""",
- )
- async def analyze_table_storage_tool(
- table_name: str,
- catalog_name: str | None = None,
- db_name: str | None = None,
- detailed_response: bool = False,
- ) -> str:
- """Analyze table storage"""
- return await self.call_tool(
- "analyze_table_storage",
- {
- "table_name": table_name,
- "catalog_name": catalog_name,
- "db_name": db_name,
- "detailed_response": detailed_response,
- },
- )
-
- @mcp.tool(
- "trace_column_lineage",
- description="""[Function Description]: Trace data lineage for
specified columns through SQL analysis and dependency mapping.
-
-[Parameter Content]:
-
-- target_columns (array) [Required] - List of column specifications in format
"table.column" or "db.table.column"
-- analysis_depth (integer) [Optional] - Maximum depth for lineage tracing,
default is 3
-- include_transformations (boolean) [Optional] - Whether to include
transformation details, default is true
-- catalog_name (string) [Optional] - Target catalog name
-""",
- )
- async def trace_column_lineage_tool(
- target_columns: list[str],
- analysis_depth: int = 3,
- include_transformations: bool = True,
- catalog_name: str | None = None,
- ) -> str:
- """Trace column data lineage"""
- return await self.call_tool(
- "trace_column_lineage",
- {
- "target_columns": target_columns,
- "analysis_depth": analysis_depth,
- "include_transformations": include_transformations,
- "catalog_name": catalog_name,
- },
- )
-
- @mcp.tool(
- "monitor_data_freshness",
- description="""[Function Description]: Monitor data freshness and
staleness patterns for specified tables.
-
-[Parameter Content]:
-
-- table_names (array) [Optional] - List of table names to monitor, if not
specified monitors all tables
-- freshness_threshold_hours (integer) [Optional] - Freshness threshold in
hours, default is 24
-- include_update_patterns (boolean) [Optional] - Whether to include update
pattern analysis, default is true
-- catalog_name (string) [Optional] - Target catalog name
-- db_name (string) [Optional] - Target database name
-""",
- )
- async def monitor_data_freshness_tool(
- table_names: list[str] | None = None,
- freshness_threshold_hours: int = 24,
- include_update_patterns: bool = True,
- catalog_name: str | None = None,
- db_name: str | None = None,
- ) -> str:
- """Monitor data freshness and staleness"""
- return await self.call_tool(
- "monitor_data_freshness",
- {
- "table_names": table_names,
- "freshness_threshold_hours": freshness_threshold_hours,
- "include_update_patterns": include_update_patterns,
- "catalog_name": catalog_name,
- "db_name": db_name,
- },
- )
-
- # Security Analytics Tools
- @mcp.tool(
- "analyze_data_access_patterns",
- description="""[Function Description]: Analyze user data access
patterns, security anomalies, and access behavior.
-
-[Parameter Content]:
-
-- days (integer) [Optional] - Number of days to analyze, default is 7
-- include_system_users (boolean) [Optional] - Whether to include system users
in analysis, default is false
-- min_query_threshold (integer) [Optional] - Minimum queries for user
inclusion, default is 5
-""",
- )
- async def analyze_data_access_patterns_tool(
- days: int = 7,
- include_system_users: bool = False,
- min_query_threshold: int = 5,
- ) -> str:
- """Analyze data access patterns and security insights"""
- return await self.call_tool(
- "analyze_data_access_patterns",
- {
- "days": days,
- "include_system_users": include_system_users,
- "min_query_threshold": min_query_threshold,
- },
- )
-
- # Dependency Analysis Tools
- @mcp.tool(
- "analyze_data_flow_dependencies",
- description="""[Function Description]: Analyze data flow
dependencies and impact relationships between tables.
-
-[Parameter Content]:
-
-- target_table (string) [Optional] - Specific table to analyze, if not
specified analyzes all tables
-- analysis_depth (integer) [Optional] - Maximum depth for dependency
traversal, default is 3
-- include_views (boolean) [Optional] - Whether to include views in analysis,
default is true
-- catalog_name (string) [Optional] - Target catalog name
-- db_name (string) [Optional] - Target database name
-""",
- )
- async def analyze_data_flow_dependencies_tool(
- target_table: str | None = None,
- analysis_depth: int = 3,
- include_views: bool = True,
- catalog_name: str | None = None,
- db_name: str | None = None,
- ) -> str:
- """Analyze data flow dependencies and impact"""
- return await self.call_tool(
- "analyze_data_flow_dependencies",
- {
- "target_table": target_table,
- "analysis_depth": analysis_depth,
- "include_views": include_views,
- "catalog_name": catalog_name,
- "db_name": db_name,
- },
- )
-
- # Performance Analytics Tools
- @mcp.tool(
- "analyze_slow_queries_topn",
- description="""[Function Description]: Analyze top N slowest
queries and identify performance patterns and issues.
-
-[Parameter Content]:
-
-- days (integer) [Optional] - Number of days to analyze, default is 7
-- top_n (integer) [Optional] - Number of top slow queries to return, default
is 20
-- min_execution_time_ms (integer) [Optional] - Minimum execution time
threshold in milliseconds, default is 1000
-- include_patterns (boolean) [Optional] - Whether to include query pattern
analysis, default is true
-""",
- )
- async def analyze_slow_queries_topn_tool(
- days: int = 7,
- top_n: int = 20,
- min_execution_time_ms: int = 1000,
- include_patterns: bool = True,
- ) -> str:
- """Analyze top N slow queries and performance patterns"""
- return await self.call_tool(
- "analyze_slow_queries_topn",
- {
- "days": days,
- "top_n": top_n,
- "min_execution_time_ms": min_execution_time_ms,
- "include_patterns": include_patterns,
- },
- )
-
- @mcp.tool(
- "analyze_resource_growth_curves",
- description="""[Function Description]: Analyze resource growth
patterns and trends for capacity planning.
-
-[Parameter Content]:
-
-- days (integer) [Optional] - Number of days to analyze, default is 30
-- resource_types (array) [Optional] - Types of resources to analyze, default
is ["storage", "query_volume", "user_activity"]
-- include_predictions (boolean) [Optional] - Whether to include growth
predictions, default is false
-- detailed_response (boolean) [Optional] - Whether to return detailed data
including daily breakdowns, default is false
-""",
- )
- async def analyze_resource_growth_curves_tool(
- days: int = 30,
- resource_types: list[str] | None = None,
- include_predictions: bool = False,
- detailed_response: bool = False,
- ) -> str:
- """Analyze resource growth patterns and capacity planning"""
- return await self.call_tool(
- "analyze_resource_growth_curves",
- {
- "days": days,
- "resource_types": resource_types
- or ["storage", "query_volume", "user_activity"],
- "include_predictions": include_predictions,
- "detailed_response": detailed_response,
- },
- )
-
- # ==================== ADBC Query Tools ====================
-
- # ADBC Query Execution Tool
- @mcp.tool(
- "exec_adbc_query",
- description=f"""[Function Description]: Execute SQL query using
ADBC (Arrow Flight SQL) protocol for high-performance data transfer.
-
-[Parameter Content]:
-
-- sql (string) [Required] - SQL statement to execute
-- max_rows (integer) [Optional] - Maximum number of rows to return, default is
{self.connection_manager.config.adbc.default_max_rows}
-- timeout (integer) [Optional] - Query timeout in seconds, default is
{self.connection_manager.config.adbc.default_timeout}
-- return_format (string) [Optional] - Format for returned data, default is
"{self.connection_manager.config.adbc.default_return_format}"
- * "arrow": Return Arrow format with metadata
- * "pandas": Return Pandas DataFrame format
- * "dict": Return dictionary format
-
-[Prerequisites]:
-- Environment variables FE_ARROW_FLIGHT_SQL_PORT and BE_ARROW_FLIGHT_SQL_PORT
must be configured
-- Required Python packages: adbc_driver_manager, adbc_driver_flightsql
-- Arrow Flight SQL services must be running on FE and BE nodes
-""",
- )
- async def exec_adbc_query_tool(
- sql: str,
- max_rows: int | None = None,
- timeout: int | None = None,
- return_format: str | None = None,
- ) -> str:
- """Execute SQL query using ADBC (Arrow Flight SQL) protocol"""
- return await self.call_tool(
- "exec_adbc_query",
- {
- "sql": sql,
- "max_rows": max_rows,
- "timeout": timeout,
- "return_format": return_format,
- },
- )
-
- # ADBC Connection Information Tool
- @mcp.tool(
- "get_adbc_connection_info",
- description="""[Function Description]: Get ADBC (Arrow Flight SQL)
connection information and status.
-
-[Parameter Content]:
-
-No parameters required. Returns connection status, configuration, and
diagnostic information.
-""",
- )
- async def get_adbc_connection_info_tool() -> str:
- """Get ADBC connection information and status"""
- return await self.call_tool("get_adbc_connection_info", {})
-
- logger.info(
- "Successfully registered 25 tools to MCP server (14 basic + 9
advanced analytics + 2 ADBC tools)"
- )
-
- async def list_tools(self) -> list[Tool]:
- """List all available query tools (for stdio mode)"""
+ def _build_tool_registry(self) -> ToolDefinitionRegistry:
+ """Build the immutable tool registry from the MCP tool catalog."""
# Get ADBC configuration defaults
- adbc_config = self.connection_manager.config.adbc
+ connection_manager = getattr(self, "connection_manager", None)
+ config = getattr(connection_manager, "config", None)
+ adbc_config = getattr(config, "adbc", None) or ADBCConfig()
tools = [
Tool(
@@ -1734,100 +990,66 @@ No parameters required. Returns connection status,
configuration, and diagnostic
),
]
- return filter_tools_for_auth_context(get_current_auth_context(), tools)
+ return ToolDefinitionRegistry.from_tools(tools, self)
+
+ @property
+ def tool_registry(self) -> ToolDefinitionRegistry:
+ """Return the registry, constructing it for legacy test fixtures."""
+ registry = getattr(self, "_tool_registry", None)
+ if registry is None:
+ registry = self._build_tool_registry()
+ 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)
"""
authorize_operation(get_current_auth_context(), f"tool:{name}")
+ definition: ToolDefinition | None = None
+ start_time = time.time()
try:
- start_time = time.time()
-
- # Tool routing - dispatch requests to corresponding business logic
processors
- if name == "exec_query":
- result = await self._exec_query_tool(arguments)
- elif name == "get_table_schema":
- result = await self._get_table_schema_tool(arguments)
- elif name == "get_db_table_list":
- result = await self._get_db_table_list_tool(arguments)
- elif name == "get_db_list":
- result = await self._get_db_list_tool(arguments)
- elif name == "get_table_comment":
- result = await self._get_table_comment_tool(arguments)
- elif name == "get_table_column_comments":
- result = await self._get_table_column_comments_tool(arguments)
- elif name == "get_table_indexes":
- result = await self._get_table_indexes_tool(arguments)
- elif name == "get_recent_audit_logs":
- result = await self._get_recent_audit_logs_tool(arguments)
- elif name == "get_catalog_list":
- result = await self._get_catalog_list_tool(arguments)
- elif name == "get_sql_explain":
- result = await self._get_sql_explain_tool(arguments)
- elif name == "get_sql_profile":
- result = await self._get_sql_profile_tool(arguments)
- elif name == "get_table_data_size":
- result = await self._get_table_data_size_tool(arguments)
- elif name == "get_monitoring_metrics":
- result = await self._get_monitoring_metrics_tool(arguments)
- elif name == "get_memory_stats":
- result = await self._get_memory_stats_tool(arguments)
- # Legacy support for old tool names (deprecated)
- elif name == "get_monitoring_metrics_info":
- arguments["content_type"] = "definitions"
- result = await self._get_monitoring_metrics_tool(arguments)
- elif name == "get_monitoring_metrics_data":
- arguments["content_type"] = "data"
- result = await self._get_monitoring_metrics_tool(arguments)
- elif name == "get_realtime_memory_stats":
- arguments["data_type"] = "realtime"
- result = await self._get_memory_stats_tool(arguments)
- elif name == "get_historical_memory_stats":
- arguments["data_type"] = "historical"
- result = await self._get_memory_stats_tool(arguments)
- # v0.5.0 Advanced Analytics Tools - Atomic Data Quality Tools
- elif name == "get_table_basic_info":
- result = await self._get_table_basic_info_tool(arguments)
- elif name == "analyze_columns":
- result = await self._analyze_columns_tool(arguments)
- elif name == "analyze_table_storage":
- result = await self._analyze_table_storage_tool(arguments)
- elif name == "trace_column_lineage":
- result = await self._trace_column_lineage_tool(arguments)
- elif name == "monitor_data_freshness":
- result = await self._monitor_data_freshness_tool(arguments)
- elif name == "analyze_data_access_patterns":
- result = await
self._analyze_data_access_patterns_tool(arguments)
- elif name == "analyze_data_flow_dependencies":
- result = await
self._analyze_data_flow_dependencies_tool(arguments)
- elif name == "analyze_slow_queries_topn":
- result = await self._analyze_slow_queries_topn_tool(arguments)
- elif name == "analyze_resource_growth_curves":
- result = await
self._analyze_resource_growth_curves_tool(arguments)
- # ADBC Query Tools
- elif name == "exec_adbc_query":
- result = await self._exec_adbc_query_tool(arguments)
- elif name == "get_adbc_connection_info":
- result = await self._get_adbc_connection_info_tool(arguments)
- else:
- raise ValueError(f"Unknown tool: {name}")
-
+ definition = self.tool_registry.resolve(name)
+ handler = definition.bind_handler(self)
+ prepared_arguments = definition.prepare_arguments(arguments)
+ result = await handler(prepared_arguments)
execution_time = time.time() - start_time
# Add execution information
if isinstance(result, dict):
result["_execution_info"] = {
"tool_name": name,
+ "canonical_tool_name": definition.canonical_name,
+ "audit_event": definition.audit.event_name,
"execution_time": round(execution_time, 3),
"timestamp": datetime.now().isoformat(),
}
+ self._audit_tool_call(
+ definition,
+ arguments=prepared_arguments,
+ status="success",
+ execution_time=execution_time,
+ )
return json.dumps(result, ensure_ascii=False, indent=2)
except OperationAuthorizationError:
raise
except Exception as e:
+ if definition is not None:
+ self._audit_tool_call(
+ definition,
+ arguments=arguments,
+ status="error",
+ execution_time=time.time() - start_time,
+ )
logger.error(
"Tool call failed (%s)",
type(e).__name__,
@@ -1839,6 +1061,31 @@ No parameters required. Returns connection status,
configuration, and diagnostic
}
return json.dumps(error_result, ensure_ascii=False, indent=2)
+ @staticmethod
+ def _audit_tool_call(
+ definition: ToolDefinition,
+ *,
+ arguments: dict[str, Any],
+ status: str,
+ execution_time: float,
+ ) -> None:
+ audit = definition.audit
+ provided_names = sorted(
+ set(arguments).intersection(audit.argument_names)
+ )
+ get_audit_logger().info(
+ "event=%s tool=%s canonical_tool=%s category=%s risk=%s "
+ "status=%s duration_ms=%d argument_names=%s",
+ audit.event_name,
+ definition.name,
+ definition.canonical_name,
+ audit.category,
+ audit.risk,
+ status,
+ round(execution_time * 1000),
+ ",".join(provided_names),
+ )
+
async def _exec_query_tool(self, arguments: dict[str, Any]) -> dict[str,
Any]:
"""SQL query execution tool routing (supports federation queries)"""
sql = self._required_string(arguments, "sql")
diff --git a/doris_mcp_server/utils/config.py b/doris_mcp_server/utils/config.py
index 00dd3dc..5772623 100644
--- a/doris_mcp_server/utils/config.py
+++ b/doris_mcp_server/utils/config.py
@@ -33,6 +33,12 @@ from urllib.parse import urlparse
from dotenv import load_dotenv
from .._version import __version__
+from ..tools.tool_registry import (
+ DORIS_OAUTH_EXPLAIN_TOOL_SET,
+ DORIS_OAUTH_METADATA_TOOL_NAMES,
+ DORIS_OAUTH_METADATA_TOOL_SET,
+ DORIS_OAUTH_QUERY_TOOL_SET,
+)
from .logger import get_logger
from .secret_policy import (
is_static_token_environment_variable,
@@ -46,18 +52,9 @@ class AuthConfigError(ValueError):
"""Raised when authentication configuration is inconsistent."""
-DORIS_OAUTH_METADATA_TOOL_ALLOWLIST_DEFAULT = [
- "get_db_list",
- "get_db_table_list",
- "get_table_schema",
- "get_table_comment",
- "get_table_column_comments",
- "get_table_indexes",
- "get_catalog_list",
-]
-DORIS_OAUTH_METADATA_TOOL_SET =
frozenset(DORIS_OAUTH_METADATA_TOOL_ALLOWLIST_DEFAULT)
-DORIS_OAUTH_QUERY_TOOL_SET = frozenset({"exec_query"})
-DORIS_OAUTH_EXPLAIN_TOOL_SET = frozenset({"get_sql_explain"})
+DORIS_OAUTH_METADATA_TOOL_ALLOWLIST_DEFAULT = list(
+ DORIS_OAUTH_METADATA_TOOL_NAMES
+)
@dataclass(frozen=True)
diff --git a/test/protocol/test_mcp_v2_protocol.py
b/test/protocol/test_mcp_v2_protocol.py
index d6e0e1e..f7941dc 100644
--- a/test/protocol/test_mcp_v2_protocol.py
+++ b/test/protocol/test_mcp_v2_protocol.py
@@ -51,6 +51,7 @@ from doris_mcp_server.protocol import (
)
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
REQUIRED_EXTENSION = "io.apache.doris/read"
PROFILE_TOOL_NAMES = sorted(
@@ -361,6 +362,64 @@ def modern_tool_headers(name: str) -> dict[str, str]:
}
[email protected]
+async def
test_http_uses_production_tool_registry_for_list_validation_and_dispatch():
+ server = create_registry_test_server()
+ app = server.streamable_http_app(
+ json_response=True,
+ stateless_http=True,
+ host="127.0.0.1",
+ transport_security=create_transport_security("127.0.0.1"),
+ )
+
+ async with (
+ app.router.lifespan_context(app),
+ httpx2.ASGITransport(app) as transport,
+ httpx2.AsyncClient(
+ transport=transport,
+ base_url="http://127.0.0.1:3000",
+ ) as client,
+ ):
+ listed = await client.post(
+ "/mcp",
+ json=modern_request(1, "tools/list"),
+ headers=modern_headers("tools/list"),
+ )
+ assert listed.status_code == 200
+ tools = {
+ tool["name"]: tool
+ for tool in listed.json()["result"]["tools"]
+ }
+ assert len(tools) == 25
+ assert tools["exec_query"]["inputSchema"]["required"] == ["sql"]
+ 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"),
+ )
+ assert invalid.status_code == 400
+ assert invalid.json()["error"]["code"] == -32602
+
+ called = await client.post(
+ "/mcp",
+ json=modern_tool_request(
+ 3,
+ "exec_query",
+ {"sql": "SELECT 1"},
+ ),
+ headers=modern_tool_headers("exec_query"),
+ )
+ 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"
+ )
+
+
@pytest.mark.asyncio
async def
test_http_discover_is_stateless_and_unknown_method_does_not_kill_server():
app = create_test_server().streamable_http_app(
@@ -1243,6 +1302,43 @@ async def
test_true_subprocess_stdio_does_not_advertise_or_serve_subscriptions()
assert recovered.result_type == "complete"
[email protected]
+async def test_true_subprocess_stdio_uses_production_tool_registry():
+ server_script = Path(__file__).with_name("tool_registry_server.py")
+ server_params = StdioServerParameters(
+ command=sys.executable,
+ args=[str(server_script)],
+ )
+
+ async with Client(stdio_client(server_params)) as modern:
+ tools = {
+ tool.name: tool
+ for tool in (await modern.list_tools(cache_mode="bypass")).tools
+ }
+ assert len(tools) == 25
+ assert tools["exec_query"].input_schema["required"] == ["sql"]
+ assert "get_monitoring_metrics_info" not in tools
+
+ with pytest.raises(MCPError) as invalid:
+ await modern.call_tool("exec_query", {})
+ 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"
+
+ 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
+
+
@pytest.mark.asyncio
async def test_true_subprocess_stdio_enforces_tool_schemas_for_both_eras():
server_script = Path(__file__).with_name("schema_validation_server.py")
diff --git a/test/protocol/tool_registry_server.py
b/test/protocol/tool_registry_server.py
new file mode 100644
index 0000000..3058305
--- /dev/null
+++ b/test/protocol/tool_registry_server.py
@@ -0,0 +1,95 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Production-registry fixture for HTTP and true subprocess Stdio tests."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+from typing import Any
+
+from mcp.server.stdio import stdio_server
+from mcp.types import GetPromptResult, Prompt, Resource
+
+from doris_mcp_server import __version__
+from doris_mcp_server.protocol import create_doris_mcp_server
+from doris_mcp_server.tools.tools_manager import DorisToolsManager
+from doris_mcp_server.utils.config import DorisConfig
+from doris_mcp_server.utils.db import DorisConnectionManager
+
+
+class EmptyResourcesManager:
+ async def list_resources(self) -> list[Resource]:
+ return []
+
+ async def read_resource(self, uri: str) -> str:
+ return json.dumps({"uri": uri})
+
+
+class EmptyPromptsManager:
+ async def list_prompts(self) -> list[Prompt]:
+ return []
+
+ async def get_prompt(
+ self,
+ name: str,
+ arguments: dict[str, Any],
+ ) -> GetPromptResult:
+ del name, arguments
+ raise ValueError("Unknown prompt")
+
+
+class RegistryToolsManager(DorisToolsManager):
+ """Use the production registry while replacing database execution."""
+
+ async def _exec_query_tool(
+ self,
+ arguments: dict[str, Any],
+ ) -> dict[str, Any]:
+ return {
+ "registry_dispatch": True,
+ "sql_length": len(self._required_string(arguments, "sql")),
+ }
+
+
+def create_registry_test_server():
+ config = DorisConfig()
+ connection_manager = DorisConnectionManager(config)
+ return create_doris_mcp_server(
+ resources_manager=EmptyResourcesManager(),
+ tools_manager=RegistryToolsManager(connection_manager),
+ prompts_manager=EmptyPromptsManager(),
+ name="doris-mcp-tool-registry-test",
+ version=__version__,
+ logger=logging.getLogger(__name__),
+ )
+
+
+async def main() -> None:
+ server = create_registry_test_server()
+ async with stdio_server() as (read_stream, write_stream):
+ await server.run(
+ read_stream,
+ write_stream,
+ server.create_initialization_options(),
+ )
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
diff --git a/test/tools/test_tool_registry.py b/test/tools/test_tool_registry.py
new file mode 100644
index 0000000..5a5631c
--- /dev/null
+++ b/test/tools/test_tool_registry.py
@@ -0,0 +1,200 @@
+# 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 the single Doris MCP tool definition registry."""
+
+from __future__ import annotations
+
+import inspect
+import json
+from pathlib import Path
+from unittest.mock import AsyncMock, Mock, patch
+
+import pytest
+from mcp.types import Tool
+
+from doris_mcp_server.schema_validation import ToolSchemaGuard
+from doris_mcp_server.tools.tool_registry import (
+ DORIS_OAUTH_EXPLAIN_TOOL_SET,
+ DORIS_OAUTH_METADATA_TOOL_SET,
+ DORIS_OAUTH_QUERY_TOOL_SET,
+ LEGACY_TOOL_ALIAS_NAMES,
+ RESTRICTED_TOOL_NAMES,
+ ToolDefinitionRegistry,
+ ToolRegistryError,
+ policy_definition_for_tool,
+)
+from doris_mcp_server.tools.tools_manager import DorisToolsManager
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+
+
[email protected]
+def tools_manager() -> DorisToolsManager:
+ connection_manager = Mock()
+ connection_manager.config.adbc.default_max_rows = 1000
+ connection_manager.config.adbc.default_timeout = 30
+ connection_manager.config.adbc.default_return_format = "dict"
+ return DorisToolsManager(connection_manager)
+
+
+def test_registry_is_the_complete_schema_policy_handler_and_audit_source(
+ tools_manager: DorisToolsManager,
+) -> None:
+ registry = tools_manager.tool_registry
+ advertised = registry.advertised_definitions
+
+ assert len(advertised) == 25
+ assert len(registry.definitions) == 25 + len(LEGACY_TOOL_ALIAS_NAMES)
+ assert len(registry.advertised_names) ==
len(set(registry.advertised_names))
+
+ ToolSchemaGuard().compile_catalog(registry.listed_tools())
+ for definition in registry.definitions:
+ assert definition.bind_handler(tools_manager)
+ assert definition.audit.event_name == (
+ f"mcp.tool.call.{definition.name}"
+ )
+ assert definition.policy == policy_definition_for_tool(definition.name)
+ if definition.tool is not None:
+ properties = definition.tool.input_schema.get("properties", {})
+ assert set(definition.audit.argument_names) == set(properties)
+
+
+def test_registry_policy_classes_cover_every_executable_tool(
+ tools_manager: DorisToolsManager,
+) -> None:
+ names_by_policy = {
+ policy_class: {
+ definition.name
+ for definition in tools_manager.tool_registry.definitions
+ if definition.policy.policy_class == policy_class
+ }
+ for policy_class in ("metadata", "query", "explain", "restricted")
+ }
+
+ assert names_by_policy["metadata"] == DORIS_OAUTH_METADATA_TOOL_SET
+ assert names_by_policy["query"] == DORIS_OAUTH_QUERY_TOOL_SET
+ assert names_by_policy["explain"] == DORIS_OAUTH_EXPLAIN_TOOL_SET
+ assert names_by_policy["restricted"] == RESTRICTED_TOOL_NAMES
+
+
[email protected]
+async def test_registry_dispatch_and_audit_do_not_log_sql_values(
+ tools_manager: DorisToolsManager,
+) -> None:
+ sql = "SELECT 'registry-secret-value'"
+ tools_manager._exec_query_tool = AsyncMock(return_value={"ok": True})
+ audit_logger = Mock()
+
+ with patch(
+ "doris_mcp_server.tools.tools_manager.get_audit_logger",
+ return_value=audit_logger,
+ ):
+ payload = json.loads(
+ await tools_manager.call_tool("exec_query", {"sql": sql})
+ )
+
+ tools_manager._exec_query_tool.assert_awaited_once_with({"sql": sql})
+ assert payload["ok"] is True
+ assert payload["_execution_info"]["canonical_tool_name"] == "exec_query"
+ assert payload["_execution_info"]["audit_event"] == (
+ "mcp.tool.call.exec_query"
+ )
+ assert sql not in repr(audit_logger.info.call_args)
+ assert "sql" in repr(audit_logger.info.call_args)
+ assert tools_manager.tool_registry.resolve(
+ "exec_query"
+ ).audit.sensitive_arguments == ("sql",)
+
+
[email protected]
+async def test_legacy_alias_uses_registry_override_without_mutating_input(
+ tools_manager: DorisToolsManager,
+) -> None:
+ tools_manager._get_monitoring_metrics_tool = AsyncMock(
+ return_value={"ok": True}
+ )
+ arguments = {"role": "fe"}
+
+ payload = json.loads(
+ await tools_manager.call_tool(
+ "get_monitoring_metrics_info",
+ arguments,
+ )
+ )
+
+ assert arguments == {"role": "fe"}
+ tools_manager._get_monitoring_metrics_tool.assert_awaited_once_with(
+ {"role": "fe", "content_type": "definitions"}
+ )
+ assert payload["_execution_info"]["canonical_tool_name"] == (
+ "get_monitoring_metrics"
+ )
+ assert "get_monitoring_metrics_info" not in (
+ tools_manager.tool_registry.advertised_names
+ )
+
+
+def test_registry_rejects_duplicate_unknown_and_missing_handler_definitions(
+ tools_manager: DorisToolsManager,
+) -> None:
+ tool = Tool(
+ name="exec_query",
+ description="Execute a query",
+ input_schema={
+ "type": "object",
+ "properties": {"sql": {"type": "string"}},
+ "required": ["sql"],
+ },
+ )
+
+ with pytest.raises(ToolRegistryError, match="Duplicate tool schema"):
+ ToolDefinitionRegistry.from_tools([tool, tool], Mock())
+
+ with pytest.raises(ToolRegistryError, match="has no policy definition"):
+ ToolDefinitionRegistry.from_tools(
+ [
+ Tool(
+ name="unclassified_tool",
+ description="Unclassified",
+ input_schema={"type": "object", "properties": {}},
+ )
+ ],
+ Mock(),
+ )
+
+ with pytest.raises(ToolRegistryError, match="missing handler"):
+ ToolDefinitionRegistry.from_tools(
+ tools_manager.tool_registry.listed_tools(),
+ object(),
+ )
+
+
+def test_checked_in_tool_catalog_is_generated_from_registry(
+ 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()
+
+
+def test_tools_manager_has_no_parallel_decorator_or_dispatch_registry() ->
None:
+ source = inspect.getsource(DorisToolsManager)
+
+ assert "register_tools_with_mcp" not in source
+ assert "@mcp.tool" not in source
+ assert 'elif name == "' not in source
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]