blob: 487e261967e7ad5013622c46997a9b9cfe40d2cd [file]
# 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
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.domain_dispatcher import ToolNotFoundError
from doris_mcp_server.tools.tool_catalog import build_tool_registry
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
@pytest.fixture
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 _migration_registry(
tools_manager: DorisToolsManager,
) -> ToolDefinitionRegistry:
return build_tool_registry(
tools_manager,
tools_manager.connection_manager.config,
)
def test_registry_is_the_complete_schema_policy_handler_and_audit_source(
tools_manager: DorisToolsManager,
) -> None:
registry = _migration_registry(tools_manager)
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:
registry = _migration_registry(tools_manager)
names_by_policy = {
policy_class: {
definition.name
for definition in 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
@pytest.mark.asyncio
async def test_migration_registry_is_not_a_runtime_dispatch_source(
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,
):
with pytest.raises(ToolNotFoundError):
await tools_manager.call_tool("exec_query", {"sql": sql})
tools_manager._exec_query_tool.assert_not_awaited()
audit_logger.info.assert_not_called()
assert sql not in repr(audit_logger.info.call_args)
assert _migration_registry(tools_manager).resolve(
"exec_query"
).audit.sensitive_arguments == ("sql",)
@pytest.mark.asyncio
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"}
registry = _migration_registry(tools_manager)
definition = registry.resolve("get_monitoring_metrics_info")
prepared = definition.prepare_arguments(arguments)
assert arguments == {"role": "fe"}
assert prepared == {"role": "fe", "content_type": "definitions"}
assert definition.canonical_name == (
"get_monitoring_metrics"
)
assert "get_monitoring_metrics_info" not in (
registry.advertised_names
)
with pytest.raises(ToolNotFoundError):
await tools_manager.call_tool(
"get_monitoring_metrics_info",
arguments,
)
tools_manager._get_monitoring_metrics_tool.assert_not_awaited()
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(
_migration_registry(tools_manager).listed_tools(),
object(),
)
def test_internal_migration_registry_rendering_is_deterministic(
tools_manager: DorisToolsManager,
) -> None:
registry = _migration_registry(tools_manager)
first = registry.render_markdown()
second = 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:
source = inspect.getsource(DorisToolsManager)
assert "register_tools_with_mcp" not in source
assert "@mcp.tool" not in source
assert 'elif name == "' not in source