blob: 5e79bc3ca83817ca6947b473ac95f6ec0ff4f66f [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.
"""MCP 2026-07-28 protocol boundary for Doris managers."""
from __future__ import annotations
import json
import logging
import secrets
from collections.abc import Callable, Iterable, Mapping, Sequence
from typing import Any, Protocol, TypeVar
from mcp.server import Server, ServerRequestContext
from mcp.server.caching import CacheHint
from mcp.server.context import CallNext
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.exceptions import MCPError
from mcp.types import (
INTERNAL_ERROR,
INVALID_PARAMS,
LATEST_PROTOCOL_VERSION,
MISSING_REQUIRED_CLIENT_CAPABILITY,
CallToolRequestParams,
CallToolResult,
ClientCapabilities,
GetPromptRequestParams,
GetPromptResult,
ListPromptsResult,
ListResourcesResult,
ListToolsResult,
MissingRequiredClientCapabilityErrorData,
PaginatedRequestParams,
Prompt,
ReadResourceRequestParams,
ReadResourceResult,
Resource,
TextContent,
TextResourceContents,
Tool,
)
from .auth.operation_policy import OperationAuthorizationError, authorize_operation
from .pagination import (
DEFAULT_LIST_PAGE_SIZE,
MAX_LIST_PAGE_SIZE,
CursorCollection,
PaginationCursorError,
PaginationPage,
paginate,
)
from .schema_validation import (
DEFAULT_SCHEMA_LIMITS,
SchemaLimits,
ToolArgumentsValidationError,
ToolOutputValidationError,
ToolSchemaGuard,
)
from .state_handles import (
DEFAULT_STATE_HANDLE_TTL_SECONDS,
StateHandleCodec,
)
from .tools.domain_dispatcher import ToolNotFoundError
from .trace_context import TraceContextSanitizingMiddleware
from .utils.redaction import (
redact_error_payload,
redact_sensitive_text,
redact_uri,
)
from .utils.security import get_current_auth_context
_ListItemT = TypeVar("_ListItemT")
class ResourcesManager(Protocol):
async def list_resources(self) -> list[Resource]: ...
async def read_resource(self, uri: str) -> str: ...
class ToolsManager(Protocol):
async def list_tools(self) -> list[Tool]: ...
async def call_tool(self, name: str, arguments: dict[str, Any]) -> str: ...
class PromptsManager(Protocol):
async def list_prompts(self) -> list[Prompt]: ...
async def get_prompt(
self, name: str, arguments: dict[str, Any]
) -> GetPromptResult: ...
def _explicit_transport_allowlist(
values: Iterable[str] | None,
*,
setting: str,
) -> list[str]:
"""Normalize an explicit transport allowlist without accepting allow-all."""
normalized = list(dict.fromkeys(str(value).strip() for value in values or ()))
normalized = [value for value in normalized if value]
if "*" in normalized:
raise ValueError(
f"{setting} must list deployment hosts explicitly; '*' is not allowed"
)
return normalized
def create_transport_security(
host: str,
*,
allowed_hosts: Iterable[str] | None = None,
allowed_origins: Iterable[str] | None = None,
) -> TransportSecuritySettings:
"""Create a fail-closed Host and Origin policy for a bind host."""
configured_hosts = _explicit_transport_allowlist(
allowed_hosts,
setting="MCP_ALLOWED_HOSTS",
)
configured_origins = _explicit_transport_allowlist(
allowed_origins,
setting="MCP_ALLOWED_ORIGINS",
)
if host in {"127.0.0.1", "localhost", "::1"}:
default_hosts = ["127.0.0.1:*", "localhost:*", "[::1]:*"]
default_origins = [
"http://127.0.0.1:*",
"http://localhost:*",
"http://[::1]:*",
]
else:
# A bind address is not a public deployment hostname. Explicit
# deployment allowlists replace this conservative fallback.
default_hosts = [host, f"{host}:*"]
default_origins = []
return TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=configured_hosts or default_hosts,
allowed_origins=configured_origins or default_origins,
)
def _decode_structured_tool_result(payload: str) -> tuple[Any | None, bool]:
"""Extract safe structured content and the legacy manager's error marker."""
try:
decoded = json.loads(payload)
except (TypeError, json.JSONDecodeError):
return None, False
return decoded, isinstance(decoded, dict) and "error" in decoded
def _sanitize_manager_error_payload(
payload: str,
) -> tuple[str, Any | None, bool]:
decoded, is_error = _decode_structured_tool_result(payload)
if not is_error:
return payload, decoded, False
decoded = redact_error_payload(decoded)
return (
json.dumps(decoded, ensure_ascii=False, indent=2),
decoded,
True,
)
_RESOURCE_INVALID_PARAMS_MESSAGES = {
"INVALID_RESOURCE_URI": "Invalid resource URI",
"RESOURCE_NOT_FOUND": "Resource not found",
}
_PROMPT_INVALID_PARAMS_MESSAGES = {
"UNKNOWN_PROMPT": "Prompt not found",
"MISSING_REQUIRED_ARGUMENT": "Missing required prompt argument",
}
_PROMPT_DATABASE_CONTEXT_ERROR = "DATABASE_CONTEXT_UNAVAILABLE"
_LIST_ERROR_CATEGORIES = {
"backend_unavailable",
"internal_error",
"permission_denied",
}
_LIST_ERROR_MESSAGES = {
"backend_unavailable": "List backend unavailable",
"internal_error": "Internal server error",
"permission_denied": "List operation permission denied",
}
def _decode_resource_request_error(payload: str) -> tuple[str, str] | None:
"""Decode only manager errors that are safe to classify as client input."""
try:
decoded = json.loads(payload)
except (TypeError, json.JSONDecodeError):
return None
if not isinstance(decoded, dict):
return None
error_code = decoded.get("error_code")
if not isinstance(error_code, str):
return None
message = _RESOURCE_INVALID_PARAMS_MESSAGES.get(error_code)
if message is None:
return None
return error_code, message
def _list_operation_error(operation: str, exc: Exception) -> MCPError:
"""Build a value-safe MCP failure for a list operation."""
category = getattr(exc, "list_error_category", "internal_error")
if not isinstance(category, str) or category not in _LIST_ERROR_CATEGORIES:
category = "internal_error"
data = {
"operation": operation,
"listErrorCategory": category,
}
error_code = getattr(exc, "error_code", None)
if (
isinstance(error_code, str)
and len(error_code) <= 96
and error_code.replace("_", "").isalnum()
):
data["listErrorCode"] = error_code
return MCPError(
code=INTERNAL_ERROR,
message=_LIST_ERROR_MESSAGES[category],
data=data,
)
def _paginate_list_or_raise(
items: Sequence[_ListItemT],
*,
collection: CursorCollection,
cursor: str | None,
page_size: int,
identifier: Callable[[_ListItemT], str],
handle_codec: StateHandleCodec,
) -> PaginationPage[_ListItemT]:
try:
return paginate(
items,
collection=collection,
cursor=cursor,
page_size=page_size,
identifier=identifier,
auth_context=get_current_auth_context(),
handle_codec=handle_codec,
)
except PaginationCursorError as exc:
raise MCPError(
code=INVALID_PARAMS,
message="Invalid pagination cursor",
data={"cursorError": exc.reason},
) from exc
def _tools_for_protocol(
tools: Sequence[Tool],
protocol_version: str,
) -> list[Tool]:
"""Hide non-object output schemas from protocol eras that cannot encode them."""
if protocol_version == LATEST_PROTOCOL_VERSION:
return list(tools)
return [
(
tool.model_copy(update={"output_schema": None})
if tool.output_schema is not None
and tool.output_schema.get("type") != "object"
else tool
)
for tool in tools
]
def create_doris_mcp_server(
*,
resources_manager: ResourcesManager,
tools_manager: ToolsManager,
prompts_manager: PromptsManager,
name: str,
version: str,
logger: logging.Logger,
list_page_size: int = DEFAULT_LIST_PAGE_SIZE,
state_handle_secret: str | bytes | None = None,
state_handle_ttl_seconds: int = DEFAULT_STATE_HANDLE_TTL_SECONDS,
schema_limits: SchemaLimits = DEFAULT_SCHEMA_LIMITS,
required_client_capabilities: Mapping[str, ClientCapabilities] | None = None,
required_tool_capabilities: Mapping[str, ClientCapabilities] | None = None,
) -> Server:
"""Create the one low-level SDK v2 server used by every transport."""
if not 1 <= list_page_size <= MAX_LIST_PAGE_SIZE:
raise ValueError(f"list_page_size must be in the range 1-{MAX_LIST_PAGE_SIZE}")
handle_codec = StateHandleCodec(
(
state_handle_secret
if state_handle_secret is not None
else secrets.token_urlsafe(32)
),
default_ttl_seconds=state_handle_ttl_seconds,
)
schema_guard = ToolSchemaGuard(schema_limits)
async def list_resources(
ctx: ServerRequestContext,
params: PaginatedRequestParams | None,
) -> ListResourcesResult:
del ctx
authorize_operation(get_current_auth_context(), "list_resources")
try:
resources = await resources_manager.list_resources()
page = _paginate_list_or_raise(
resources,
collection="resources",
cursor=params.cursor if params else None,
page_size=list_page_size,
identifier=lambda resource: str(resource.uri),
handle_codec=handle_codec,
)
except (MCPError, OperationAuthorizationError):
raise
except Exception as exc:
logger.exception("resources/list failed")
raise _list_operation_error("resources/list", exc) from exc
logger.info(
"Returning %d of %d resources",
len(page.items),
len(resources),
)
return ListResourcesResult(
resources=page.items,
next_cursor=page.next_cursor,
)
async def read_resource(
ctx: ServerRequestContext,
params: ReadResourceRequestParams,
) -> ReadResourceResult:
authorize_operation(get_current_auth_context(), "read_resource")
content = await resources_manager.read_resource(params.uri)
content, _, _ = _sanitize_manager_error_payload(content)
if ctx.protocol_version == LATEST_PROTOCOL_VERSION:
request_error = _decode_resource_request_error(content)
if request_error is not None:
error_code, message = request_error
raise MCPError(
code=INVALID_PARAMS,
message=message,
data={
"uri": redact_uri(str(params.uri)),
"resourceErrorCode": error_code,
},
)
return ReadResourceResult(
contents=[
TextResourceContents(
uri=params.uri,
mime_type="application/json",
text=content,
)
]
)
async def list_tools(
ctx: ServerRequestContext,
params: PaginatedRequestParams | None,
) -> ListToolsResult:
authorize_operation(get_current_auth_context(), "list_tools")
try:
tools = await tools_manager.list_tools()
schema_guard.compile_catalog(tools)
tools = _tools_for_protocol(tools, ctx.protocol_version)
page = _paginate_list_or_raise(
tools,
collection="tools",
cursor=params.cursor if params else None,
page_size=list_page_size,
identifier=lambda tool: tool.name,
handle_codec=handle_codec,
)
except (MCPError, OperationAuthorizationError):
raise
except Exception as exc:
logger.exception("tools/list failed")
raise _list_operation_error("tools/list", exc) from exc
logger.info("Returning %d of %d tools", len(page.items), len(tools))
return ListToolsResult(
tools=page.items,
next_cursor=page.next_cursor,
)
async def call_tool(
ctx: ServerRequestContext,
params: CallToolRequestParams,
) -> CallToolResult:
arguments = params.arguments or {}
tools = await tools_manager.list_tools()
compiled_schema = schema_guard.compile_catalog(tools).get(params.name)
if compiled_schema is not None:
try:
compiled_schema.validate_arguments(arguments)
except ToolArgumentsValidationError as exc:
data: dict[str, Any] = {
"name": redact_sensitive_text(params.name),
"violations": [
violation.as_dict()
for violation in exc.violations
],
}
if exc.truncated:
data["truncated"] = True
raise MCPError(
code=INVALID_PARAMS,
message="Tool arguments do not match input schema",
data=data,
) from exc
try:
payload = await tools_manager.call_tool(params.name, arguments)
except OperationAuthorizationError:
raise
except ToolNotFoundError as exc:
raise MCPError(
code=INVALID_PARAMS,
message="Tool not found",
data={
"name": redact_sensitive_text(exc.name),
"toolErrorCode": "TOOL_NOT_FOUND",
},
) from exc
except Exception:
logger.exception("Tool execution failed")
payload = json.dumps(
{
"error": "Tool execution failed",
"error_code": "TOOL_EXECUTION_FAILED",
}
)
payload, structured_content, is_error = _sanitize_manager_error_payload(
payload
)
if compiled_schema is not None and not is_error:
try:
compiled_schema.validate_output(structured_content)
except ToolOutputValidationError:
logger.exception(
"Tool %s returned structured content that violates outputSchema",
params.name,
)
raise
if (
ctx.protocol_version != LATEST_PROTOCOL_VERSION
and not isinstance(structured_content, dict)
):
structured_content = None
return CallToolResult(
content=[TextContent(type="text", text=payload)],
structured_content=structured_content,
is_error=is_error,
)
async def list_prompts(
ctx: ServerRequestContext,
params: PaginatedRequestParams | None,
) -> ListPromptsResult:
del ctx
authorize_operation(get_current_auth_context(), "list_prompts")
try:
prompts = await prompts_manager.list_prompts()
page = _paginate_list_or_raise(
prompts,
collection="prompts",
cursor=params.cursor if params else None,
page_size=list_page_size,
identifier=lambda prompt: prompt.name,
handle_codec=handle_codec,
)
except (MCPError, OperationAuthorizationError):
raise
except Exception as exc:
logger.exception("prompts/list failed")
raise _list_operation_error("prompts/list", exc) from exc
logger.info(
"Returning %d of %d prompts",
len(page.items),
len(prompts),
)
return ListPromptsResult(
prompts=page.items,
next_cursor=page.next_cursor,
)
async def get_prompt(
ctx: ServerRequestContext,
params: GetPromptRequestParams,
) -> GetPromptResult:
del ctx
authorize_operation(get_current_auth_context(), "get_prompt")
try:
return await prompts_manager.get_prompt(
params.name,
dict(params.arguments or {}),
)
except Exception as exc:
prompt_error_code = getattr(exc, "error_code", None)
message = (
_PROMPT_INVALID_PARAMS_MESSAGES.get(prompt_error_code)
if isinstance(prompt_error_code, str)
else None
)
if message is not None:
data = {
"name": redact_sensitive_text(params.name),
"promptErrorCode": prompt_error_code,
}
argument = getattr(exc, "argument", None)
if isinstance(argument, str):
data["argument"] = argument
raise MCPError(
code=INVALID_PARAMS,
message=message,
data=data,
) from exc
if prompt_error_code == _PROMPT_DATABASE_CONTEXT_ERROR:
logger.exception(
"Database context failed while rendering prompt %s",
params.name,
)
raise MCPError(
code=INTERNAL_ERROR,
message="Database context unavailable",
data={
"name": params.name,
"promptErrorCode": prompt_error_code,
},
) from exc
raise
private_no_cache = CacheHint(ttl_ms=0, scope="private")
server = Server(
name,
version=version,
description="Model Context Protocol server for Apache Doris",
cache_hints={
"server/discover": CacheHint(ttl_ms=300_000, scope="public"),
"tools/list": private_no_cache,
"resources/list": private_no_cache,
"resources/read": private_no_cache,
"prompts/list": private_no_cache,
},
on_list_resources=list_resources,
on_read_resource=read_resource,
on_list_tools=list_tools,
on_call_tool=call_tool,
on_list_prompts=list_prompts,
on_get_prompt=get_prompt,
)
# The SDK's OpenTelemetry middleware extracts W3C carrier values. Run the
# value-safe sanitizer before it so malformed tracestate/baggage cannot be
# echoed by dependency warning logs and never reaches the handler layer.
server.middleware.insert(0, TraceContextSanitizingMiddleware(logger))
async def hide_unhandled_errors(
ctx: ServerRequestContext,
call_next: CallNext,
) -> Any:
try:
return await call_next(ctx)
except (MCPError, OperationAuthorizationError):
raise
except Exception as exc:
logger.exception("Unhandled MCP request failure for %s", ctx.method)
raise MCPError(
code=INTERNAL_ERROR,
message="Internal server error",
) from exc
server.middleware.append(hide_unhandled_errors)
if required_client_capabilities or required_tool_capabilities:
requirements = dict(required_client_capabilities or {})
tool_requirements = dict(required_tool_capabilities or {})
async def enforce_required_client_capabilities(
ctx: ServerRequestContext,
call_next: CallNext,
) -> Any:
required = requirements.get(ctx.method)
if ctx.method == "tools/call" and ctx.params is not None:
tool_name = ctx.params.get("name")
if isinstance(tool_name, str):
required = tool_requirements.get(tool_name, required)
if (
required is not None
and ctx.protocol_version == LATEST_PROTOCOL_VERSION
and not ctx.session.check_client_capability(required)
):
data = MissingRequiredClientCapabilityErrorData(
required_capabilities=required,
).model_dump(by_alias=True, mode="json", exclude_none=True)
raise MCPError(
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
message="Missing required client capability",
data=data,
)
return await call_next(ctx)
server.middleware.append(enforce_required_client_capabilities)
return server