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 f3d43fa feat: add allowlisted custom tool providers (#162)
f3d43fa is described below
commit f3d43fa36b9f86038a1a0034db0162aa5167626c
Author: Yijia Su <[email protected]>
AuthorDate: Thu Jul 30 20:02:42 2026 +0800
feat: add allowlisted custom tool providers (#162)
---
.env.example | 6 +
CHANGELOG.md | 3 +
README.md | 10 +
README.zh-CN.md | 6 +
docs/custom-tool-providers.md | 199 +++++++++++++
doris_mcp_server/auth/operation_policy.py | 14 +-
doris_mcp_server/main.py | 1 +
doris_mcp_server/tools/__init__.py | 14 +
doris_mcp_server/tools/tool_catalog.py | 9 +-
doris_mcp_server/tools/tool_provider.py | 371 ++++++++++++++++++++++++
doris_mcp_server/tools/tool_registry.py | 36 ++-
doris_mcp_server/tools/tools_manager.py | 56 +++-
doris_mcp_server/utils/config.py | 23 ++
test/protocol/test_multiworker_config.py | 29 ++
test/security/test_operation_policy.py | 31 ++
test/test_product_identity.py | 16 ++
test/tools/test_custom_tool_provider.py | 456 ++++++++++++++++++++++++++++++
17 files changed, 1271 insertions(+), 9 deletions(-)
diff --git a/.env.example b/.env.example
index 0f731a3..84b4686 100644
--- a/.env.example
+++ b/.env.example
@@ -67,6 +67,12 @@ DORIS_MAX_CONNECTION_AGE=3600
# FE_ARROW_FLIGHT_SQL_PORT=
# BE_ARROW_FLIGHT_SQL_PORT=
+# Explicit allowlist of installed Python entry points from the
+# doris_mcp_server.tool_providers group. Empty means no custom code is loaded.
+# Custom providers are supported with local/stdio, static token, and JWT auth;
+# they are hidden from OAuth modes until they have a reviewed OAuth policy.
+MCP_TOOL_PROVIDERS=
+
# ===================================================================
# Security Configuration
# ===================================================================
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18b3470..3e8daf8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -42,6 +42,9 @@ under **Unreleased** until a new version is selected and
published.
- W3C `traceparent`, `tracestate`, and `baggage` propagation from request
`_meta`, with value-safe validation, credential-like baggage redaction,
per-request isolation, and no trace metadata in model-facing results.
+- Explicitly installed and allowlisted custom tool providers for existing
+ business APIs, with lifecycle management, schema validation, safe audit
+ metadata, and bounded process-local rate limits.
- Bounded query result streaming with deployment and absolute ceilings for
rows, serialized bytes, and execution time, plus cancellation-safe database
connection disposal.
diff --git a/README.md b/README.md
index dad596a..a8a88b8 100644
--- a/README.md
+++ b/README.md
@@ -220,6 +220,8 @@ export ENABLE_LEGACY_HTTP_ADAPTER=false
# Bound each resources/list, tools/list, and prompts/list response.
export MCP_LIST_PAGE_SIZE=100
+# Load only these installed custom tool providers. Empty disables extensions.
+export MCP_TOOL_PROVIDERS="orders_api"
# A launch-local key is generated automatically. Configure one shared
# high-entropy value when independently launched replicas share traffic.
export MCP_STATE_HANDLE_SECRET="$(python -c 'import secrets;
print(secrets.token_urlsafe(32))')"
@@ -316,6 +318,8 @@ cp .env.example .env
modern traffic always uses `POST /mcp`
* `MCP_LIST_PAGE_SIZE`: Maximum resources, tools, or prompts returned
per protocol page (default: 100; range: 1-1000)
+ * `MCP_TOOL_PROVIDERS`: Comma-separated allowlist of installed
+ `doris_mcp_server.tool_providers` entry points (default: empty)
* `MCP_STATE_HANDLE_SECRET`: Optional shared high-entropy key (at least
32 bytes) used to authenticate explicit cross-call state handles
* `MCP_STATE_HANDLE_TTL_SECONDS`: Lifetime of an explicit state handle
@@ -1498,6 +1502,12 @@ doris-mcp-server/
This section outlines the process for adding new MCP tools to the Doris MCP
Server, based on the unified modular architecture with centralized tool
management.
+Existing business APIs do not need to be built into this repository. Package
+them as explicitly installed, allowlisted custom tool providers instead. The
+[custom tool provider guide](docs/custom-tool-providers.md) defines the entry
+point contract, lifecycle, process-local QPS limits, authentication boundary,
+FastGPT integration, and production security checklist.
+
### 1. Leverage Existing Utility Modules
The server provides comprehensive utility modules for common database
operations:
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 68f097d..ab05c1b 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -174,6 +174,7 @@ curl --fail http://127.0.0.1:3000/ready
| `MCP_STATE_HANDLE_SECRET` | 多副本共享的状态句柄签名密钥 | 启动时生成 |
| `MCP_ALLOWED_HOSTS` | HTTP Host 白名单 | 回环地址 |
| `ENABLE_LEGACY_HTTP_ADAPTER` | 启用 `/mcp/legacy` 迁移端点 | `false` |
+| `MCP_TOOL_PROVIDERS` | 已安装自定义工具 Provider 的显式白名单 | 空 |
多副本经负载均衡提供分页时,应配置同一个高强度
`MCP_STATE_HANDLE_SECRET`。分页 Cursor 是带签名的状态句柄,客户端不应解析或
@@ -446,6 +447,11 @@ uv run bandit -q -c pyproject.toml -r doris_mcp_server
4. 权限与审计元数据;
5. 单元测试及必要的真实 Doris 集成测试。
+已有业务 API 不必直接合入本仓库。可以把它封装为安装在服务运行环境中的
+自定义工具 Provider,再通过 `MCP_TOOL_PROVIDERS` 显式启用。入口协议、生命周期、
+进程内 QPS 限制、FastGPT 接入方式和生产安全边界见
+[自定义工具 Provider 指南](docs/custom-tool-providers.md)。
+
详细流程见[英文开发指南](README.md#developing-new-tools)。
## 常见问题
diff --git a/docs/custom-tool-providers.md b/docs/custom-tool-providers.md
new file mode 100644
index 0000000..54f1179
--- /dev/null
+++ b/docs/custom-tool-providers.md
@@ -0,0 +1,199 @@
+<!--
+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.
+-->
+
+# Custom tool providers
+
+Doris MCP Server can expose an existing business API as an MCP tool without
+adding business-specific code to this repository. The extension is a trusted
+Python package installed in the same environment as the server and enabled by
+an explicit deploy-time allowlist.
+
+This boundary intentionally does not accept an arbitrary target URL, method,
+headers, or credentials from MCP clients. Provider code executes inside the
+server process and therefore has the same operating-system privileges as the
+server. Install and enable only packages that the operator has reviewed.
+
+## Provider contract
+
+Declare a named entry point in the provider package:
+
+```toml
+[project.entry-points."doris_mcp_server.tool_providers"]
+orders_api = "acme_doris_tools.provider:create_provider"
+```
+
+The entry-point target must be a zero-argument callable. It returns an object
+whose `name` exactly matches the entry-point name and whose `tools()` method
+returns one or more `CustomTool` definitions.
+
+```python
+from __future__ import annotations
+
+import os
+from typing import Any
+from urllib.parse import quote
+
+import httpx
+from mcp.types import Tool
+
+from doris_mcp_server.tools import CustomTool, ToolRateLimit
+
+
+class OrdersApiProvider:
+ name = "orders_api"
+
+ def __init__(self) -> None:
+ self._base_url = os.environ["ORDERS_API_BASE_URL"].rstrip("/")
+ if not self._base_url.startswith("https://"):
+ raise ValueError("ORDERS_API_BASE_URL must use HTTPS")
+ self._token = os.environ["ORDERS_API_TOKEN"]
+ self._client: httpx.AsyncClient | None = None
+
+ async def start(self) -> None:
+ self._client = httpx.AsyncClient(
+ base_url=self._base_url,
+ headers={"Authorization": f"Bearer {self._token}"},
+ timeout=httpx.Timeout(5.0, connect=2.0),
+ follow_redirects=False,
+ )
+
+ async def close(self) -> None:
+ if self._client is not None:
+ await self._client.aclose()
+ self._client = None
+
+ def tools(self) -> tuple[CustomTool, ...]:
+ return (
+ CustomTool(
+ tool=Tool(
+ name="lookup_business_order",
+ description="Look up one order in the reviewed business
API",
+ input_schema={
+ "type": "object",
+ "properties": {
+ "order_id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128,
+ }
+ },
+ "required": ["order_id"],
+ "additionalProperties": False,
+ },
+ ),
+ handler=self._lookup_order,
+ risk="medium",
+ rate_limit=ToolRateLimit(
+ max_calls=10,
+ period_seconds=1,
+ scope="principal",
+ ),
+ ),
+ )
+
+ async def _lookup_order(
+ self,
+ arguments: dict[str, Any],
+ ) -> dict[str, Any]:
+ if self._client is None:
+ raise RuntimeError("provider is not started")
+ order_id = arguments["order_id"]
+ if not isinstance(order_id, str) or not order_id.strip():
+ raise ValueError("order_id must be a non-empty string")
+
+ response = await self._client.get(
+ f"/v1/orders/{quote(order_id, safe='')}"
+ )
+ response.raise_for_status()
+ if len(response.content) > 1_048_576:
+ raise ValueError("upstream response is too large")
+ payload = response.json()
+ if not isinstance(payload, dict):
+ raise ValueError("upstream response must be a JSON object")
+ return payload
+
+
+def create_provider() -> OrdersApiProvider:
+ return OrdersApiProvider()
+```
+
+Install the provider package in the same virtual environment, then enable only
+the reviewed entry-point name:
+
+```bash
+export MCP_TOOL_PROVIDERS=orders_api
+doris-mcp-server --transport http --host 127.0.0.1 --port 3000
+```
+
+An empty `MCP_TOOL_PROVIDERS` value loads no custom code. Startup fails if a
+configured provider is missing, has a mismatched name, returns an invalid
+definition, or shadows a built-in or another custom tool.
+
+Optional synchronous or asynchronous `start()` and `close()` methods can own
+HTTP clients or other runtime resources. A partial startup failure closes
+providers that were already started.
+
+## Authentication and rate limits
+
+Custom provider tools are available to local/stdio, static-token, and JWT
+deployments under their existing authorization boundary. External OAuth and
+Doris-backed OAuth hide custom tools and reject direct calls: they fail closed
+until the project defines a reviewed dynamic scope and policy model.
+
+`ToolRateLimit` is a bounded, process-local fixed-window limiter:
+
+- `scope="principal"` separates callers by the authenticated user or token
+ identity;
+- `scope="tool"` applies one shared limit to the tool in that server process;
+- rejected calls return `TOOL_RATE_LIMITED` and a bounded retry delay;
+- the provider handler is not invoked for a rejected call.
+
+Each worker and each replica has an independent limiter. For a global QPS,
+concurrency, quota, or cost limit, enforce the limit in an API gateway or
shared
+rate-limit service. With four workers, a process-local limit can allow roughly
+four times the configured rate.
+
+## FastGPT and other MCP clients
+
+Expose Doris MCP Server through its normal Streamable HTTP or stdio transport.
+FastGPT and other MCP clients discover the custom schema through `tools/list`
+and invoke it through `tools/call`; no provider-specific protocol is required.
+The same MCP `2026-07-28` request metadata and HTTP headers described in the
+main README still apply.
+
+## Production checklist
+
+- Keep target hosts and API paths fixed in reviewed provider code. Never accept
+ an arbitrary URL, scheme, host, redirect target, or authorization header from
+ MCP arguments.
+- Use HTTPS and a network egress allowlist. Disable redirects unless every
+ redirect target is separately validated.
+- Read credentials from environment variables or a secret manager. Never place
+ secrets in tool schemas, results, audit fields, or logs.
+- Apply bounded connect/read/total timeouts and response-size limits. Validate
+ the upstream content type and response structure.
+- Return only fields the MCP caller is authorized to see. Treat upstream error
+ bodies as sensitive; the server intentionally sanitizes uncaught exceptions.
+- Define retry and idempotency behavior explicitly. Do not automatically retry
+ unsafe mutations.
+- Add upstream concurrency limits, circuit breaking, and global rate limiting
+ outside the process when availability or cost requires them.
+- Test provider lifecycle, input validation, tool-name collisions, rate-limit
+ behavior, authentication visibility, audit metadata, and the real upstream
+ failure modes before production deployment.
diff --git a/doris_mcp_server/auth/operation_policy.py
b/doris_mcp_server/auth/operation_policy.py
index 7c5fba1..8bfad49 100644
--- a/doris_mcp_server/auth/operation_policy.py
+++ b/doris_mcp_server/auth/operation_policy.py
@@ -418,8 +418,18 @@ def authorize_operation(auth_context: Any | None,
operation: str) -> None:
def filter_tools_for_auth_context(auth_context: Any | None, tools: list[Any])
-> list[Any]:
- """Filter visible tools for Doris OAuth users."""
- if auth_context is None or auth_context.auth_method != "doris_oauth":
+ """Filter tools that do not have a reviewed policy for the OAuth mode."""
+ if auth_context is None:
+ return tools
+
+ if auth_context.auth_method == "external_oauth":
+ return [
+ tool
+ for tool in tools
+ if policy_definition_for_tool(getattr(tool, "name", "")) is not
None
+ ]
+
+ if auth_context.auth_method != "doris_oauth":
return tools
filtered = []
diff --git a/doris_mcp_server/main.py b/doris_mcp_server/main.py
index 6337fbb..40017fa 100644
--- a/doris_mcp_server/main.py
+++ b/doris_mcp_server/main.py
@@ -84,6 +84,7 @@ def _multiworker_environment(
config.enable_legacy_http_adapter
).lower(),
"MCP_LIST_PAGE_SIZE": str(config.mcp_list_page_size),
+ "MCP_TOOL_PROVIDERS": ",".join(config.mcp_tool_providers),
"MCP_STATE_HANDLE_SECRET": config.mcp_state_handle_secret,
"MCP_STATE_HANDLE_TTL_SECONDS":
str(config.mcp_state_handle_ttl_seconds),
"SERVER_NAME": config.server_name,
diff --git a/doris_mcp_server/tools/__init__.py
b/doris_mcp_server/tools/__init__.py
index 0c32176..70de15f 100644
--- a/doris_mcp_server/tools/__init__.py
+++ b/doris_mcp_server/tools/__init__.py
@@ -23,3 +23,17 @@ This package includes:
- Prompt managers
- Tool registration and initialization
"""
+
+from .tool_provider import (
+ CustomTool,
+ CustomToolProvider,
+ ToolProviderError,
+ ToolRateLimit,
+)
+
+__all__ = [
+ "CustomTool",
+ "CustomToolProvider",
+ "ToolProviderError",
+ "ToolRateLimit",
+]
diff --git a/doris_mcp_server/tools/tool_catalog.py
b/doris_mcp_server/tools/tool_catalog.py
index 8c26208..8956203 100644
--- a/doris_mcp_server/tools/tool_catalog.py
+++ b/doris_mcp_server/tools/tool_catalog.py
@@ -16,18 +16,21 @@
# under the License.
"""Immutable MCP tool catalog, separate from manager lifecycle and routing."""
+from collections.abc import Iterable
from typing import Any
from mcp.types import Tool
from ..result_limits import configured_default_result_rows,
configured_result_limits
from ..utils.config import ADBCConfig
+from .tool_provider import CustomTool
from .tool_registry import ToolDefinitionRegistry
def build_tool_registry(
handler_owner: Any,
config: Any | None,
+ custom_tools: Iterable[tuple[str, CustomTool]] = (),
) -> ToolDefinitionRegistry:
"""Build immutable tool metadata and bind it to a handler owner."""
adbc_config = getattr(config, "adbc", None) or ADBCConfig()
@@ -936,4 +939,8 @@ No parameters required. Returns connection status,
configuration, and diagnostic
),
]
- return ToolDefinitionRegistry.from_tools(tools, handler_owner)
+ return ToolDefinitionRegistry.from_tools(
+ tools,
+ handler_owner,
+ custom_tools=custom_tools,
+ )
diff --git a/doris_mcp_server/tools/tool_provider.py
b/doris_mcp_server/tools/tool_provider.py
new file mode 100644
index 0000000..49fe168
--- /dev/null
+++ b/doris_mcp_server/tools/tool_provider.py
@@ -0,0 +1,371 @@
+# 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.
+"""Explicit, deploy-time extension boundary for trusted custom MCP tools."""
+
+from __future__ import annotations
+
+import asyncio
+import inspect
+import logging
+import re
+import time
+from collections.abc import Awaitable, Callable, Iterable, Sequence
+from dataclasses import dataclass
+from importlib import metadata
+from typing import Any, Literal, Protocol
+
+from mcp.types import Tool
+
+CUSTOM_TOOL_PROVIDER_ENTRY_POINT = "doris_mcp_server.tool_providers"
+MAX_PROVIDER_NAME_LENGTH = 128
+MAX_RATE_LIMIT_KEYS = 10_000
+_PROVIDER_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
+logger = logging.getLogger(__name__)
+
+ToolHandler = Callable[[dict[str, Any]], Awaitable[dict[str, Any]]]
+ToolRisk = Literal["low", "medium", "high"]
+RateLimitScope = Literal["tool", "principal"]
+
+
+class ToolProviderError(ValueError):
+ """Raised when an explicitly configured tool provider is invalid."""
+
+
+@dataclass(frozen=True)
+class ToolRateLimit:
+ """Process-local fixed-window limit for one custom tool."""
+
+ max_calls: int
+ period_seconds: float
+ scope: RateLimitScope = "principal"
+
+ def __post_init__(self) -> None:
+ if isinstance(self.max_calls, bool) or not 1 <= self.max_calls <=
1_000_000:
+ raise ToolProviderError(
+ "Custom tool rate limit max_calls must be in the range
1-1000000"
+ )
+ if (
+ isinstance(self.period_seconds, bool)
+ or not 0.1 <= self.period_seconds <= 3600
+ ):
+ raise ToolProviderError(
+ "Custom tool rate limit period_seconds must be in the range
0.1-3600"
+ )
+ if self.scope not in {"tool", "principal"}:
+ raise ToolProviderError(
+ "Custom tool rate limit scope must be tool or principal"
+ )
+
+
+@dataclass(frozen=True)
+class CustomTool:
+ """One schema, handler, and bounded runtime policy supplied by a
provider."""
+
+ tool: Tool
+ handler: ToolHandler
+ risk: ToolRisk = "high"
+ rate_limit: ToolRateLimit | None = None
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.tool, Tool):
+ raise ToolProviderError("Custom tool schema must be an MCP Tool")
+ if not callable(self.handler):
+ raise ToolProviderError(
+ f"Custom tool {self.tool.name!r} handler must be callable"
+ )
+ if self.risk not in {"low", "medium", "high"}:
+ raise ToolProviderError(
+ f"Custom tool {self.tool.name!r} risk must be low, medium, or
high"
+ )
+
+
+class CustomToolProvider(Protocol):
+ """Provider contract returned by an installed Python entry point."""
+
+ name: str
+
+ def tools(self) -> Iterable[CustomTool]:
+ """Return immutable tool definitions without performing network I/O."""
+
+
+@dataclass(frozen=True)
+class LoadedToolProvider:
+ """Validated provider and its materialized custom tool definitions."""
+
+ name: str
+ provider: CustomToolProvider
+ tools: tuple[CustomTool, ...]
+
+
+def normalize_tool_provider_names(raw_names: Iterable[str]) -> tuple[str, ...]:
+ """Validate and deduplicate an explicit provider allowlist."""
+ normalized: list[str] = []
+ seen: set[str] = set()
+ for raw_name in raw_names:
+ if not isinstance(raw_name, str):
+ raise ToolProviderError("Custom tool provider names must be
strings")
+ name = raw_name.strip()
+ if not _PROVIDER_NAME.fullmatch(name):
+ raise ToolProviderError(
+ "Custom tool provider names must contain only letters, digits,
"
+ "dot, underscore, or hyphen and be at most "
+ f"{MAX_PROVIDER_NAME_LENGTH} characters"
+ )
+ if name in seen:
+ raise ToolProviderError(
+ f"Duplicate custom tool provider in allowlist: {name}"
+ )
+ normalized.append(name)
+ seen.add(name)
+ return tuple(normalized)
+
+
+def prepare_tool_provider(
+ provider: CustomToolProvider,
+ *,
+ expected_name: str | None = None,
+) -> LoadedToolProvider:
+ """Validate one trusted provider before it reaches the tool registry."""
+ provider_name = getattr(provider, "name", None)
+ if not isinstance(provider_name, str):
+ raise ToolProviderError("Custom tool provider must define a string
name")
+ name = normalize_tool_provider_names([provider_name])[0]
+ if expected_name is not None and name != expected_name:
+ raise ToolProviderError(
+ f"Custom tool provider {expected_name!r} returned mismatched name
{name!r}"
+ )
+
+ tools_factory = getattr(provider, "tools", None)
+ if not callable(tools_factory):
+ raise ToolProviderError(
+ f"Custom tool provider {name!r} must define a tools() method"
+ )
+ try:
+ tools = tuple(tools_factory())
+ except Exception as exc:
+ raise ToolProviderError(
+ f"Custom tool provider {name!r} failed to define tools"
+ ) from exc
+ if not tools:
+ raise ToolProviderError(
+ f"Custom tool provider {name!r} must define at least one tool"
+ )
+ for tool in tools:
+ if not isinstance(tool, CustomTool):
+ raise ToolProviderError(
+ f"Custom tool provider {name!r} returned an invalid tool
definition"
+ )
+ return LoadedToolProvider(name=name, provider=provider, tools=tools)
+
+
+def load_tool_providers(
+ provider_names: Sequence[str],
+) -> tuple[LoadedToolProvider, ...]:
+ """Load only explicitly enabled installed entry points."""
+ names = normalize_tool_provider_names(provider_names)
+ if not names:
+ return ()
+
+ installed: dict[str, metadata.EntryPoint] = {}
+ for entry_point in metadata.entry_points(
+ group=CUSTOM_TOOL_PROVIDER_ENTRY_POINT
+ ):
+ if entry_point.name in installed:
+ raise ToolProviderError(
+ f"Duplicate installed custom tool provider: {entry_point.name}"
+ )
+ installed[entry_point.name] = entry_point
+
+ loaded: list[LoadedToolProvider] = []
+ for name in names:
+ selected_entry_point = installed.get(name)
+ if selected_entry_point is None:
+ raise ToolProviderError(
+ f"Configured custom tool provider is not installed: {name}"
+ )
+ try:
+ factory = selected_entry_point.load()
+ if not callable(factory):
+ raise TypeError("entry point target is not callable")
+ provider = factory()
+ except Exception as exc:
+ raise ToolProviderError(
+ f"Unable to load custom tool provider: {name}"
+ ) from exc
+ loaded.append(prepare_tool_provider(provider, expected_name=name))
+ return tuple(loaded)
+
+
+async def call_provider_lifecycle_hook(
+ loaded_provider: LoadedToolProvider,
+ hook_name: Literal["start", "close"],
+) -> None:
+ """Call one optional synchronous or asynchronous provider lifecycle
hook."""
+ hook = getattr(loaded_provider.provider, hook_name, None)
+ if hook is None:
+ return
+ if not callable(hook):
+ raise ToolProviderError(
+ f"Custom tool provider {loaded_provider.name!r} "
+ f"{hook_name} attribute must be callable"
+ )
+ result = hook()
+ if inspect.isawaitable(result):
+ await result
+
+
+@dataclass
+class _RateWindow:
+ calls: int
+ expires_at: float
+
+
+class LocalToolRateLimiter:
+ """Bounded process-local fixed-window limiter for custom tools."""
+
+ def __init__(
+ self,
+ *,
+ clock: Callable[[], float] = time.monotonic,
+ max_keys: int = MAX_RATE_LIMIT_KEYS,
+ ) -> None:
+ self._clock = clock
+ self._max_keys = max_keys
+ self._windows: dict[tuple[str, str], _RateWindow] = {}
+ self._lock = asyncio.Lock()
+
+ async def retry_after(
+ self,
+ *,
+ tool_name: str,
+ principal: str,
+ limit: ToolRateLimit,
+ ) -> float | None:
+ """Record an allowed call or return the retry delay for a rejected
one."""
+ key_principal = principal if limit.scope == "principal" else "*"
+ key = (tool_name, key_principal)
+ now = self._clock()
+
+ async with self._lock:
+ window = self._windows.get(key)
+ if window is not None and window.expires_at <= now:
+ self._windows.pop(key, None)
+ window = None
+
+ if window is None:
+ if len(self._windows) >= self._max_keys:
+ self._discard_expired_keys(now)
+ if len(self._windows) >= self._max_keys:
+ return limit.period_seconds
+ self._windows[key] = _RateWindow(
+ calls=1,
+ expires_at=now + limit.period_seconds,
+ )
+ return None
+
+ if window.calls >= limit.max_calls:
+ return max(0.0, window.expires_at - now)
+ window.calls += 1
+ return None
+
+ def _discard_expired_keys(self, now: float) -> None:
+ empty_keys = [
+ key
+ for key, window in self._windows.items()
+ if window.expires_at <= now
+ ]
+ for key in empty_keys:
+ self._windows.pop(key, None)
+
+
+class ToolProviderRuntime:
+ """Own provider discovery, lifecycle, and process-local rate-limit
state."""
+
+ def __init__(self, providers: Sequence[LoadedToolProvider]) -> None:
+ self.providers = tuple(providers)
+ self._started: list[LoadedToolProvider] = []
+ self._rate_limiter = LocalToolRateLimiter()
+
+ @classmethod
+ def create(
+ cls,
+ config: Any,
+ providers: Iterable[CustomToolProvider] | None,
+ ) -> ToolProviderRuntime:
+ """Build a runtime from configuration or explicitly injected
providers."""
+ if providers is None:
+ configured_names = getattr(config, "mcp_tool_providers", ())
+ if not isinstance(configured_names, list | tuple):
+ configured_names = ()
+ loaded = load_tool_providers(configured_names)
+ else:
+ loaded = tuple(prepare_tool_provider(provider) for provider in
providers)
+ return cls(loaded)
+
+ @property
+ def provider_count(self) -> int:
+ return len(self.providers)
+
+ def custom_tools(self) -> Iterable[tuple[str, CustomTool]]:
+ for loaded_provider in self.providers:
+ for custom_tool in loaded_provider.tools:
+ yield loaded_provider.name, custom_tool
+
+ async def start(self) -> None:
+ """Start providers in order and roll back completed starts on
failure."""
+ try:
+ for loaded_provider in self.providers:
+ await call_provider_lifecycle_hook(loaded_provider, "start")
+ self._started.append(loaded_provider)
+ except Exception:
+ await self.close()
+ raise
+
+ async def close(self) -> None:
+ """Close every started provider in reverse order."""
+ for loaded_provider in reversed(self._started):
+ try:
+ await call_provider_lifecycle_hook(loaded_provider, "close")
+ except Exception as exc:
+ logger.error(
+ "Custom tool provider shutdown failed (%s)",
+ type(exc).__name__,
+ )
+ self._started.clear()
+
+ async def retry_after(
+ self,
+ *,
+ tool_name: str,
+ auth_context: Any | None,
+ limit: ToolRateLimit,
+ ) -> float | None:
+ """Apply one tool limit without retaining bearer credentials."""
+ principal = "anonymous"
+ if auth_context is not None:
+ principal = (
+ getattr(auth_context, "user_id", None)
+ or getattr(auth_context, "token_id", None)
+ or getattr(auth_context, "oauth_client_id", None)
+ or getattr(auth_context, "auth_method", None)
+ or principal
+ )
+ return await self._rate_limiter.retry_after(
+ tool_name=tool_name,
+ principal=principal,
+ limit=limit,
+ )
diff --git a/doris_mcp_server/tools/tool_registry.py
b/doris_mcp_server/tools/tool_registry.py
index 2f742ef..a8f460d 100644
--- a/doris_mcp_server/tools/tool_registry.py
+++ b/doris_mcp_server/tools/tool_registry.py
@@ -19,12 +19,14 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable, Iterable
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from types import MappingProxyType
from typing import Any, Literal, Protocol, cast
from mcp.types import Tool
+from .tool_provider import CustomTool, ToolRateLimit
+
ToolPolicyClass = Literal["metadata", "query", "explain", "restricted"]
ToolHandler = Callable[[dict[str, Any]], Awaitable[dict[str, Any]]]
@@ -127,8 +129,17 @@ class ToolDefinition:
audit: ToolAuditDefinition
advertised: bool = True
argument_overrides: tuple[tuple[str, Any], ...] = ()
+ provider_name: str | None = None
+ direct_handler: ToolHandler | None = field(
+ default=None,
+ repr=False,
+ compare=False,
+ )
+ rate_limit: ToolRateLimit | None = None
def bind_handler(self, owner: ToolHandlerOwner) -> ToolHandler:
+ if self.direct_handler is not None:
+ return self.direct_handler
handler = getattr(owner, self.handler_name, None)
if not callable(handler):
raise ToolRegistryError(
@@ -229,6 +240,7 @@ class ToolDefinitionRegistry:
cls,
tools: Iterable[Tool],
owner: ToolHandlerOwner,
+ custom_tools: Iterable[tuple[str, CustomTool]] = (),
) -> ToolDefinitionRegistry:
tools_by_name: dict[str, Tool] = {}
definitions: list[ToolDefinition] = []
@@ -272,6 +284,28 @@ class ToolDefinitionRegistry:
)
)
+ for provider_name, custom_tool in custom_tools:
+ tool = custom_tool.tool
+ policy = ToolPolicyDefinition(
+ "restricted",
+ "custom_provider",
+ custom_tool.risk,
+ "UNSUPPORTED_FOR_OAUTH",
+ )
+ definitions.append(
+ ToolDefinition(
+ name=tool.name,
+ canonical_name=tool.name,
+ tool=tool,
+ handler_name=f"provider:{provider_name}",
+ policy=policy,
+ audit=_audit_for_tool(tool.name, tool, policy),
+ provider_name=provider_name,
+ direct_handler=custom_tool.handler,
+ rate_limit=custom_tool.rate_limit,
+ )
+ )
+
return cls(definitions, owner)
@property
diff --git a/doris_mcp_server/tools/tools_manager.py
b/doris_mcp_server/tools/tools_manager.py
index 0242197..6f054c1 100644
--- a/doris_mcp_server/tools/tools_manager.py
+++ b/doris_mcp_server/tools/tools_manager.py
@@ -20,7 +20,9 @@ Responsible for tool registration, management, scheduling and
routing, does not
"""
import json
+import math
import time
+from collections.abc import Iterable
from datetime import datetime
from typing import Any
@@ -47,6 +49,7 @@ from ..utils.schema_extractor import MetadataExtractor
from ..utils.security import get_current_auth_context
from ..utils.security_analytics_tools import SecurityAnalyticsTools
from .tool_catalog import build_tool_registry
+from .tool_provider import CustomToolProvider, ToolProviderRuntime
from .tool_registry import ToolDefinition, ToolDefinitionRegistry
logger = get_logger(__name__)
@@ -55,10 +58,15 @@ logger = get_logger(__name__)
class DorisToolsManager:
"""Apache Doris Tools Manager"""
- def __init__(self, connection_manager: DorisConnectionManager) -> None:
+ def __init__(
+ self,
+ connection_manager: DorisConnectionManager,
+ *,
+ tool_providers: Iterable[CustomToolProvider] | None = None,
+ ) -> None:
self.connection_manager = connection_manager
-
- # Initialize business logic processors
+ config = getattr(connection_manager, "config", None)
+ self._tool_provider_runtime = ToolProviderRuntime.create(config,
tool_providers)
self.query_executor = DorisQueryExecutor(connection_manager)
self.table_analyzer = TableAnalyzer(connection_manager)
self.sql_analyzer = SQLAnalyzer(connection_manager)
@@ -83,15 +91,23 @@ class DorisToolsManager:
self._tool_registry = self._build_tool_registry()
logger.info(
- "DorisToolsManager initialized with business logic processors,
v0.5.0 analytics tools, and ADBC query tools"
+ "DorisToolsManager initialized with business logic processors,
v0.5.0 "
+ "analytics tools, ADBC query tools, and %d custom tool providers",
+ self._tool_provider_runtime.provider_count,
)
async def start(self) -> None:
"""Start runtime resources owned by the tools manager."""
await self.query_executor.start()
+ try:
+ await self._tool_provider_runtime.start()
+ except Exception:
+ await self.query_executor.close()
+ raise
async def close(self) -> None:
"""Stop runtime resources owned by the tools manager."""
+ await self._tool_provider_runtime.close()
await self.query_executor.close()
@staticmethod
@@ -117,6 +133,8 @@ class DorisToolsManager:
def _build_tool_registry(self) -> ToolDefinitionRegistry:
"""Build the registry from the standalone immutable catalog."""
+ provider_runtime = getattr(self, "_tool_provider_runtime", None)
+ custom_tools = provider_runtime.custom_tools() if provider_runtime
else ()
return build_tool_registry(
self,
getattr(
@@ -124,6 +142,7 @@ class DorisToolsManager:
"config",
None,
),
+ custom_tools=custom_tools,
)
@property
@@ -151,13 +170,40 @@ class DorisToolsManager:
start_time = time.time()
try:
definition = self.tool_registry.resolve(name)
+ if definition.rate_limit is not None:
+ retry_after = await self._tool_provider_runtime.retry_after(
+ tool_name=definition.name,
+ auth_context=get_current_auth_context(),
+ limit=definition.rate_limit,
+ )
+ if retry_after is not None:
+ execution_time = time.time() - start_time
+ self._audit_tool_call(
+ definition,
+ arguments=arguments,
+ status="rate_limited",
+ execution_time=execution_time,
+ )
+ return json.dumps(
+ {
+ "error": "Tool rate limit exceeded",
+ "error_code": "TOOL_RATE_LIMITED",
+ "retry_after_seconds": max(
+ 1,
+ math.ceil(retry_after),
+ ),
+ "timestamp": datetime.now().isoformat(),
+ },
+ ensure_ascii=False,
+ indent=2,
+ )
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):
+ if isinstance(result, dict) and definition.provider_name is None:
result["_execution_info"] = {
"tool_name": name,
"canonical_tool_name": definition.canonical_name,
diff --git a/doris_mcp_server/utils/config.py b/doris_mcp_server/utils/config.py
index ff68dc3..42f237d 100644
--- a/doris_mcp_server/utils/config.py
+++ b/doris_mcp_server/utils/config.py
@@ -43,6 +43,10 @@ from ..result_limits import (
DEFAULT_RESULT_ROWS,
MIN_RESULT_BYTES,
)
+from ..tools.tool_provider import (
+ ToolProviderError,
+ normalize_tool_provider_names,
+)
from ..tools.tool_registry import (
DORIS_OAUTH_EXPLAIN_TOOL_SET,
DORIS_OAUTH_METADATA_TOOL_NAMES,
@@ -841,6 +845,7 @@ class DorisConfig:
mcp_allowed_origins: list[str] = field(default_factory=list)
enable_legacy_http_adapter: bool = False
mcp_list_page_size: int = 100
+ mcp_tool_providers: list[str] = field(default_factory=list)
mcp_state_handle_secret: str = field(
default_factory=lambda: secrets.token_urlsafe(32),
repr=False,
@@ -1461,6 +1466,13 @@ class DorisConfig:
config.mcp_list_page_size,
)
_mark_source(config, "mcp_list_page_size", "env")
+ if "MCP_TOOL_PROVIDERS" in os.environ:
+ config.mcp_tool_providers = [
+ value.strip()
+ for value in os.getenv("MCP_TOOL_PROVIDERS", "").split(",")
+ if value.strip()
+ ]
+ _mark_source(config, "mcp_tool_providers", "env")
if "MCP_STATE_HANDLE_SECRET" in os.environ:
config.mcp_state_handle_secret = os.getenv(
"MCP_STATE_HANDLE_SECRET",
@@ -1491,6 +1503,7 @@ class DorisConfig:
"mcp_allowed_origins",
"enable_legacy_http_adapter",
"mcp_list_page_size",
+ "mcp_tool_providers",
"mcp_state_handle_ttl_seconds",
"temp_files_dir",
"transport",
@@ -1567,6 +1580,7 @@ class DorisConfig:
"mcp_allowed_origins": self.mcp_allowed_origins,
"enable_legacy_http_adapter": self.enable_legacy_http_adapter,
"mcp_list_page_size": self.mcp_list_page_size,
+ "mcp_tool_providers": self.mcp_tool_providers,
"mcp_state_handle_ttl_seconds": self.mcp_state_handle_ttl_seconds,
"temp_files_dir": self.temp_files_dir,
"database": {
@@ -1774,6 +1788,15 @@ class DorisConfig:
if not 1 <= self.mcp_list_page_size <= 1000:
errors.append("MCP list page size must be in the range 1-1000")
+ raw_tool_providers: Any = self.mcp_tool_providers
+ if not isinstance(raw_tool_providers, list):
+ errors.append("MCP tool providers must be a list")
+ else:
+ try:
+ normalize_tool_provider_names(raw_tool_providers)
+ except ToolProviderError as exc:
+ errors.append(str(exc))
+
if len(self.mcp_state_handle_secret.encode("utf-8")) < 32:
errors.append("MCP state handle secret must contain at least 32
bytes")
diff --git a/test/protocol/test_multiworker_config.py
b/test/protocol/test_multiworker_config.py
index 9d05131..25996b2 100644
--- a/test/protocol/test_multiworker_config.py
+++ b/test/protocol/test_multiworker_config.py
@@ -43,6 +43,33 @@ def
test_mcp_list_page_size_is_configurable_and_bounded(monkeypatch):
assert "MCP list page size must be in the range 1-1000" in
configured.validate()
+def test_custom_tool_provider_allowlist_is_explicit_and_validated(monkeypatch):
+ monkeypatch.delenv("MCP_TOOL_PROVIDERS", raising=False)
+ assert DorisConfig.from_env().mcp_tool_providers == []
+
+ monkeypatch.setenv("MCP_TOOL_PROVIDERS", "orders_api, customer-tools")
+ configured = DorisConfig.from_env()
+ assert configured.mcp_tool_providers == ["orders_api", "customer-tools"]
+ assert configured.to_dict()["mcp_tool_providers"] == [
+ "orders_api",
+ "customer-tools",
+ ]
+ assert configured.validate() == []
+
+ configured.mcp_tool_providers = ["orders_api", "orders_api"]
+ assert (
+ "Duplicate custom tool provider in allowlist: orders_api"
+ in configured.validate()
+ )
+ configured.mcp_tool_providers = ["bad/provider"]
+ assert any(
+ error.startswith("Custom tool provider names must contain")
+ for error in configured.validate()
+ )
+ configured.mcp_tool_providers = "orders_api" # type: ignore[assignment]
+ assert "MCP tool providers must be a list" in configured.validate()
+
+
def
test_state_handle_secret_and_ttl_are_configurable_without_serializing_secret(
monkeypatch,
):
@@ -82,6 +109,7 @@ def
test_multiworker_environment_preserves_resolved_parent_config(monkeypatch):
config.mcp_allowed_origins = ["https://client.example.test"]
config.enable_legacy_http_adapter = True
config.mcp_list_page_size = 17
+ config.mcp_tool_providers = ["orders_api", "customer-tools"]
config.mcp_state_handle_secret = "parent-shared-state-handle-secret-value"
config.mcp_state_handle_ttl_seconds = 45
@@ -111,6 +139,7 @@ def
test_multiworker_environment_preserves_resolved_parent_config(monkeypatch):
assert child_config.mcp_allowed_origins == ["https://client.example.test"]
assert child_config.enable_legacy_http_adapter is True
assert child_config.mcp_list_page_size == 17
+ assert child_config.mcp_tool_providers == ["orders_api", "customer-tools"]
assert (
child_config.mcp_state_handle_secret
== "parent-shared-state-handle-secret-value"
diff --git a/test/security/test_operation_policy.py
b/test/security/test_operation_policy.py
index 6e9101d..5d99366 100644
--- a/test/security/test_operation_policy.py
+++ b/test/security/test_operation_policy.py
@@ -305,3 +305,34 @@ def
test_filter_tools_for_doris_oauth_hides_disabled_or_denied_tools():
)
assert [tool.name for tool in filtered] == ["get_db_list", "exec_query"]
+
+
+def test_external_oauth_hides_custom_tools_without_reviewed_policy():
+ context = AuthContext(
+ auth_method="external_oauth",
+ oauth_scopes=[
+ "tool:list",
+ "tool:call:get_db_list",
+ "tool:call:custom_business_api",
+ ],
+ )
+ tools = [
+ SimpleNamespace(name="get_db_list"),
+ SimpleNamespace(name="custom_business_api"),
+ ]
+
+ filtered = filter_tools_for_auth_context(context, tools)
+
+ assert [tool.name for tool in filtered] == ["get_db_list"]
+
+
+def test_external_oauth_rejects_custom_tool_call_without_reviewed_policy():
+ context = AuthContext(
+ auth_method="external_oauth",
+ oauth_scopes=["tool:call:custom_business_api"],
+ )
+
+ with pytest.raises(OperationAuthorizationError) as exc:
+ authorize_operation(context, "tool:custom_business_api")
+
+ assert exc.value.error_code == "UNKNOWN_OPERATION"
diff --git a/test/test_product_identity.py b/test/test_product_identity.py
index e234ca6..4ca5b2f 100644
--- a/test/test_product_identity.py
+++ b/test/test_product_identity.py
@@ -108,3 +108,19 @@ def
test_fine_grained_access_control_guide_is_linked_and_complete():
assert "root` and `admin" in guide
assert "Doris-backed OAuth" in guide
assert "Token-bound database configuration" in guide
+
+
+def test_custom_tool_provider_guide_is_linked_and_complete():
+ guide_path = PROJECT_ROOT / "docs" / "custom-tool-providers.md"
+ guide = guide_path.read_text(encoding="utf-8")
+ readme = (PROJECT_ROOT / "README.md").read_text(encoding="utf-8")
+ chinese_readme = (PROJECT_ROOT /
"README.zh-CN.md").read_text(encoding="utf-8")
+ link = "docs/custom-tool-providers.md"
+
+ assert f"]({link})" in readme
+ assert f"]({link})" in chinese_readme
+ assert "doris_mcp_server.tool_providers" in guide
+ assert "MCP_TOOL_PROVIDERS" in guide
+ assert "ToolRateLimit" in guide
+ assert "fail closed" in guide
+ assert "FastGPT" in guide
diff --git a/test/tools/test_custom_tool_provider.py
b/test/tools/test_custom_tool_provider.py
new file mode 100644
index 0000000..3ffd7c2
--- /dev/null
+++ b/test/tools/test_custom_tool_provider.py
@@ -0,0 +1,456 @@
+# 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.
+"""Tests for the explicit trusted custom-tool provider boundary."""
+
+from __future__ import annotations
+
+import json
+import logging
+from importlib import metadata
+from unittest.mock import AsyncMock, Mock
+
+import httpx2
+import pytest
+from mcp.types import Tool
+
+from doris_mcp_server import __version__
+from doris_mcp_server.protocol import (
+ create_doris_mcp_server,
+ create_transport_security,
+)
+from doris_mcp_server.tools.tool_provider import (
+ CustomTool,
+ LocalToolRateLimiter,
+ ToolProviderError,
+ ToolRateLimit,
+ load_tool_providers,
+)
+from doris_mcp_server.tools.tool_registry import ToolRegistryError
+from doris_mcp_server.tools.tools_manager import DorisToolsManager
+from doris_mcp_server.utils.config import DorisConfig
+from doris_mcp_server.utils.security import (
+ AuthContext,
+ reset_auth_context,
+ set_current_auth_context,
+)
+
+
+def _connection_manager() -> Mock:
+ manager = Mock()
+ manager.config = DorisConfig()
+ manager.get_connection = AsyncMock()
+ return manager
+
+
+class RecordingProvider:
+ name = "orders_api"
+
+ def __init__(
+ self,
+ *,
+ tool_name: str = "lookup_business_order",
+ rate_limit: ToolRateLimit | None = None,
+ ) -> None:
+ self.handler = AsyncMock(
+ return_value={"ok": True, "source": "business-api"}
+ )
+ self.start = AsyncMock()
+ self.close = AsyncMock()
+ self._tool_name = tool_name
+ self._rate_limit = rate_limit
+
+ def tools(self) -> tuple[CustomTool, ...]:
+ return (
+ CustomTool(
+ tool=Tool(
+ name=self._tool_name,
+ description="Look up one order through a trusted business
API",
+ input_schema={
+ "type": "object",
+ "properties": {
+ "order_id": {"type": "string"},
+ },
+ "required": ["order_id"],
+ "additionalProperties": False,
+ },
+ output_schema={
+ "type": "object",
+ "properties": {
+ "ok": {"type": "boolean"},
+ "source": {"type": "string"},
+ },
+ "required": ["ok", "source"],
+ "additionalProperties": False,
+ },
+ ),
+ handler=self.handler,
+ rate_limit=self._rate_limit,
+ ),
+ )
+
+
[email protected]
+async def
test_provider_tool_is_listed_dispatched_audited_and_lifecycle_managed():
+ provider = RecordingProvider()
+ manager = DorisToolsManager(
+ _connection_manager(),
+ tool_providers=[provider],
+ )
+ manager.query_executor.start = AsyncMock()
+ manager.query_executor.close = AsyncMock()
+
+ await manager.start()
+ listed = await manager.list_tools()
+ payload = json.loads(
+ await manager.call_tool(
+ "lookup_business_order",
+ {"order_id": "order-42"},
+ )
+ )
+ await manager.close()
+
+ assert "lookup_business_order" 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"
+ assert "_execution_info" not in payload
+ definition = manager.tool_registry.resolve("lookup_business_order")
+ assert definition.provider_name == "orders_api"
+ assert definition.policy.channel == "custom_provider"
+ provider.start.assert_awaited_once_with()
+ provider.close.assert_awaited_once_with()
+
+
[email protected]
+async def test_provider_start_failure_rolls_back_started_providers():
+ first = RecordingProvider()
+ second = RecordingProvider(tool_name="lookup_customer")
+ second.name = "customer_api"
+ second.start.side_effect = RuntimeError("upstream unavailable")
+ manager = DorisToolsManager(
+ _connection_manager(),
+ tool_providers=[first, second],
+ )
+ manager.query_executor.start = AsyncMock()
+ manager.query_executor.close = AsyncMock()
+
+ with pytest.raises(RuntimeError, match="upstream unavailable"):
+ await manager.start()
+
+ first.start.assert_awaited_once_with()
+ first.close.assert_awaited_once_with()
+ second.start.assert_awaited_once_with()
+ second.close.assert_not_awaited()
+ manager.query_executor.close.assert_awaited_once_with()
+
+
[email protected]
+async def test_custom_tool_rate_limit_is_fail_closed_and_principal_scoped():
+ provider = RecordingProvider(
+ rate_limit=ToolRateLimit(
+ max_calls=1,
+ period_seconds=60,
+ scope="principal",
+ )
+ )
+ manager = DorisToolsManager(
+ _connection_manager(),
+ tool_providers=[provider],
+ )
+
+ first = json.loads(
+ await manager.call_tool(
+ "lookup_business_order",
+ {"order_id": "first"},
+ )
+ )
+ limited = json.loads(
+ await manager.call_tool(
+ "lookup_business_order",
+ {"order_id": "second"},
+ )
+ )
+
+ context_token = set_current_auth_context(
+ AuthContext(
+ auth_method="token",
+ token_id="another-principal",
+ user_id="another-principal",
+ )
+ )
+ try:
+ another_principal = json.loads(
+ await manager.call_tool(
+ "lookup_business_order",
+ {"order_id": "third"},
+ )
+ )
+ finally:
+ reset_auth_context(context_token)
+
+ assert first["ok"] is True
+ assert limited["error_code"] == "TOOL_RATE_LIMITED"
+ assert limited["retry_after_seconds"] >= 1
+ assert another_principal["ok"] is True
+ assert provider.handler.await_count == 2
+
+
+def test_custom_tool_cannot_shadow_a_builtin_tool():
+ provider = RecordingProvider(tool_name="exec_query")
+
+ with pytest.raises(ToolRegistryError, match="Duplicate tool definition"):
+ DorisToolsManager(
+ _connection_manager(),
+ tool_providers=[provider],
+ )
+
+
+def test_custom_tools_cannot_shadow_each_other():
+ first = RecordingProvider()
+ second = RecordingProvider()
+ second.name = "customer_api"
+
+ with pytest.raises(ToolRegistryError, match="Duplicate tool definition"):
+ DorisToolsManager(
+ _connection_manager(),
+ tool_providers=[first, second],
+ )
+
+
+class FakeEntryPoint:
+ def __init__(self, name: str, factory: object) -> None:
+ self.name = name
+ self._factory = factory
+
+ def load(self) -> object:
+ return self._factory
+
+
+def test_loader_loads_only_explicit_installed_entry_points(monkeypatch):
+ provider = RecordingProvider()
+ unselected_factory = Mock(side_effect=AssertionError("must not be loaded"))
+ monkeypatch.setattr(
+ metadata,
+ "entry_points",
+ lambda **kwargs: [
+ FakeEntryPoint("orders_api", lambda: provider),
+ FakeEntryPoint("unselected", unselected_factory),
+ ],
+ )
+
+ loaded = load_tool_providers(["orders_api"])
+
+ assert [item.name for item in loaded] == ["orders_api"]
+ assert loaded[0].provider is provider
+ assert loaded[0].tools[0].tool.name == "lookup_business_order"
+ unselected_factory.assert_not_called()
+
+
+def test_loader_rejects_unknown_or_mismatched_providers(monkeypatch):
+ monkeypatch.setattr(metadata, "entry_points", lambda **kwargs: [])
+ with pytest.raises(ToolProviderError, match="is not installed"):
+ load_tool_providers(["missing"])
+
+ mismatched = RecordingProvider()
+ mismatched.name = "different_name"
+ monkeypatch.setattr(
+ metadata,
+ "entry_points",
+ lambda **kwargs: [
+ FakeEntryPoint("orders_api", lambda: mismatched),
+ ],
+ )
+ with pytest.raises(ToolProviderError, match="mismatched name"):
+ load_tool_providers(["orders_api"])
+
+
[email protected](
+ ("max_calls", "period_seconds", "scope"),
+ [
+ (0, 1, "principal"),
+ (1, 0, "principal"),
+ (1, 1, "unknown"),
+ ],
+)
+def test_custom_tool_rate_limit_rejects_invalid_bounds(
+ max_calls,
+ period_seconds,
+ scope,
+):
+ with pytest.raises(ToolProviderError):
+ ToolRateLimit(
+ max_calls=max_calls,
+ period_seconds=period_seconds,
+ scope=scope,
+ )
+
+
[email protected]
+async def test_rate_limiter_recycles_expired_principal_keys():
+ now = 0.0
+ limiter = LocalToolRateLimiter(clock=lambda: now, max_keys=1)
+ limit = ToolRateLimit(max_calls=1, period_seconds=1)
+
+ assert (
+ await limiter.retry_after(
+ tool_name="lookup",
+ principal="first",
+ limit=limit,
+ )
+ is None
+ )
+ assert (
+ await limiter.retry_after(
+ tool_name="lookup",
+ principal="second",
+ limit=limit,
+ )
+ == 1
+ )
+
+ now = 1.1
+ assert (
+ await limiter.retry_after(
+ tool_name="lookup",
+ principal="second",
+ limit=limit,
+ )
+ is None
+ )
+
+
+def _modern_request(
+ request_id: int,
+ method: str,
+ *,
+ name: str | None = None,
+ arguments: dict | None = None,
+) -> dict:
+ params: dict = {
+ "_meta": {
+ "io.modelcontextprotocol/protocolVersion": "2026-07-28",
+ "io.modelcontextprotocol/clientCapabilities": {},
+ "io.modelcontextprotocol/clientInfo": {
+ "name": "custom-provider-test",
+ "version": "1.0.0",
+ },
+ }
+ }
+ if name is not None:
+ params.update({"name": name, "arguments": arguments or {}})
+ return {
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "method": method,
+ "params": params,
+ }
+
+
+def _modern_headers(method: str, *, name: str | None = None) -> dict[str, str]:
+ headers = {
+ "Accept": "application/json, text/event-stream",
+ "Content-Type": "application/json",
+ "Mcp-Protocol-Version": "2026-07-28",
+ "Mcp-Method": method,
+ }
+ if name is not None:
+ headers["Mcp-Name"] = name
+ return headers
+
+
[email protected]
+async def
test_custom_tool_uses_real_streamable_http_list_validation_and_call():
+ provider = RecordingProvider()
+ manager = DorisToolsManager(
+ _connection_manager(),
+ tool_providers=[provider],
+ )
+ resources_manager = Mock()
+ resources_manager.list_resources = AsyncMock(return_value=[])
+ resources_manager.read_resource = AsyncMock()
+ prompts_manager = Mock()
+ prompts_manager.list_prompts = AsyncMock(return_value=[])
+ prompts_manager.get_prompt = AsyncMock()
+ server = create_doris_mcp_server(
+ resources_manager=resources_manager,
+ tools_manager=manager,
+ prompts_manager=prompts_manager,
+ name="doris-mcp-custom-provider-test",
+ version=__version__,
+ logger=logging.getLogger(__name__),
+ )
+ 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"),
+ )
+ invalid = 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"},
+ ),
+ headers=_modern_headers(
+ "tools/call",
+ name="lookup_business_order",
+ ),
+ )
+
+ assert listed.status_code == 200
+ 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
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]