blob: bffec07759c56f9f4a4db34544a161f30b161f1f [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.
from __future__ import annotations
import sys
from collections.abc import Iterable
from pathlib import Path
from typing import Any
import httpx2
import pytest
from mcp import Client, StdioServerParameters
from mcp.client.stdio import stdio_client
from doris_mcp_server.auth.mcp_auth_middleware import MCPAuthASGIMiddleware
from doris_mcp_server.auth.operation_policy import (
OperationAuthorizationError,
authorize_operation,
)
from doris_mcp_server.http_transport import DorisMCPHTTPTransport
from doris_mcp_server.protocol import create_transport_security
from doris_mcp_server.tools.doris_feature_matrix import (
EXPECTED_DOMAIN_CHILDREN,
)
from doris_mcp_server.tools.tool_registry import (
DORIS_OAUTH_EXPLAIN_TOOL_SET,
DORIS_OAUTH_METADATA_TOOL_SET,
DORIS_OAUTH_QUERY_TOOL_SET,
RESTRICTED_TOOL_NAMES,
)
from doris_mcp_server.utils.auth_credentials import BearerCredentials
from doris_mcp_server.utils.config import EffectiveAuthConfig
from doris_mcp_server.utils.security import AuthContext
from test.protocol.tool_registry_server import create_registry_test_server
BASE_OPERATION_SCOPES = (
("list_tools", "tool:list"),
("list_resources", "resource:list"),
("read_resource", "resource:read"),
("list_prompts", "prompt:list"),
("get_prompt", "prompt:get"),
)
ALL_TOOL_NAMES = tuple(
sorted(
DORIS_OAUTH_METADATA_TOOL_SET
| DORIS_OAUTH_QUERY_TOOL_SET
| DORIS_OAUTH_EXPLAIN_TOOL_SET
| RESTRICTED_TOOL_NAMES
)
)
TOOL_OPERATION_SCOPES = tuple(
(f"tool:{tool_name}", f"tool:call:{tool_name}")
for tool_name in ALL_TOOL_NAMES
)
DOMAIN_OPERATION_SCOPES = tuple(
(f"tool:{domain_name}", "tool:list")
for domain_name in EXPECTED_DOMAIN_CHILDREN
)
ALL_OPERATION_SCOPES = (
BASE_OPERATION_SCOPES
+ TOOL_OPERATION_SCOPES
+ DOMAIN_OPERATION_SCOPES
)
def _context(auth_method: str, scopes: Iterable[str] = ()) -> AuthContext:
context = AuthContext(
user_id=f"{auth_method}-user",
auth_method=auth_method,
oauth_scopes=list(scopes),
)
if auth_method == "doris_oauth":
context.doris_oauth_db_tools_enabled = True
context.doris_oauth_db_tool_allowlist = tuple(
sorted(DORIS_OAUTH_METADATA_TOOL_SET)
)
context.doris_oauth_query_tools_enabled = True
context.doris_oauth_query_tool_allowlist = tuple(
sorted(DORIS_OAUTH_QUERY_TOOL_SET)
)
context.doris_oauth_explain_tools_enabled = True
context.doris_oauth_explain_tool_allowlist = tuple(
sorted(DORIS_OAUTH_EXPLAIN_TOOL_SET)
)
return context
def _external_oauth_config() -> EffectiveAuthConfig:
return EffectiveAuthConfig(
enable_token_auth=False,
enable_jwt_auth=False,
enable_external_oauth_auth=True,
enable_doris_oauth_auth=False,
auth_methods=("external_oauth",),
oauth_discovery_mode="external_oauth",
transport="http",
requested_workers=1,
effective_workers=1,
legacy_auth_type="",
external_oauth_issuer="https://issuer.example.test",
external_oauth_resource="https://mcp.example.test/mcp",
external_oauth_scopes=(
"tool:list",
),
external_oauth_required_scopes=("tool:list",),
)
def _modern_request(
request_id: int,
method: str,
*,
name: str | None = None,
arguments: dict[str, Any] | None = None,
) -> dict[str, Any]:
params: dict[str, Any] = {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": {
"name": "auth-cross-matrix-test",
"version": "1.0.0",
},
}
}
if name is not None:
params["name"] = name
params["arguments"] = arguments or {}
return {
"jsonrpc": "2.0",
"id": request_id,
"method": method,
"params": params,
}
def _modern_headers(
method: str,
*,
name: str | None = None,
token: str,
) -> dict[str, str]:
headers = {
"Accept": "application/json, text/event-stream",
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Mcp-Protocol-Version": "2026-07-28",
"Mcp-Method": method,
}
if name is not None:
headers["Mcp-Name"] = name
return headers
@pytest.mark.parametrize(
"auth_context",
[
None,
_context("anonymous"),
_context("token"),
_context("jwt"),
],
ids=["stdio-no-context", "anonymous", "token", "jwt"],
)
@pytest.mark.parametrize(
"operation",
[operation for operation, _ in ALL_OPERATION_SCOPES],
)
def test_non_oauth_auth_methods_do_not_apply_oauth_scope_policy(
auth_context: AuthContext | None,
operation: str,
) -> None:
authorize_operation(auth_context, operation)
@pytest.mark.parametrize(("operation", "required_scope"), ALL_OPERATION_SCOPES)
def test_external_oauth_accepts_each_exact_operation_scope(
operation: str,
required_scope: str,
) -> None:
authorize_operation(
_context("external_oauth", [required_scope]),
operation,
)
@pytest.mark.parametrize(("operation", "required_scope"), ALL_OPERATION_SCOPES)
@pytest.mark.parametrize(
"granted_scopes",
[
(),
("*",),
("wrong:scope",),
],
ids=["missing", "wildcard", "wrong"],
)
def test_external_oauth_rejects_missing_or_inexact_operation_scope(
operation: str,
required_scope: str,
granted_scopes: tuple[str, ...],
) -> None:
with pytest.raises(OperationAuthorizationError) as exc:
authorize_operation(
_context("external_oauth", granted_scopes),
operation,
)
assert exc.value.error_code == "PERMISSION_DENIED"
assert exc.value.required_scope == required_scope
assert exc.value.operation == operation
@pytest.mark.parametrize(
("operation", "required_scope"),
(
BASE_OPERATION_SCOPES[:3]
+ tuple(
(f"tool:{tool_name}", f"tool:call:{tool_name}")
for tool_name in sorted(
DORIS_OAUTH_METADATA_TOOL_SET
| DORIS_OAUTH_QUERY_TOOL_SET
| DORIS_OAUTH_EXPLAIN_TOOL_SET
)
)
),
)
def test_doris_oauth_accepts_enabled_operations_with_exact_scope(
operation: str,
required_scope: str,
) -> None:
authorize_operation(
_context("doris_oauth", [required_scope]),
operation,
)
@pytest.mark.parametrize(
"operation",
["list_prompts", "get_prompt"]
+ [f"tool:{tool_name}" for tool_name in sorted(RESTRICTED_TOOL_NAMES)],
)
def test_doris_oauth_keeps_unsupported_operations_denied(
operation: str,
) -> None:
required_scope = (
f"tool:call:{operation.split(':', 1)[1]}"
if operation.startswith("tool:")
else "prompt:list"
if operation == "list_prompts"
else "prompt:get"
)
with pytest.raises(OperationAuthorizationError) as exc:
authorize_operation(
_context("doris_oauth", [required_scope]),
operation,
)
assert exc.value.error_code == "UNSUPPORTED_FOR_DORIS_OAUTH"
def test_external_oauth_rejects_unknown_tool_before_dispatch() -> None:
with pytest.raises(OperationAuthorizationError) as exc:
authorize_operation(
_context("external_oauth", ["tool:call:not_real"]),
"tool:not_real",
)
assert exc.value.error_code == "UNKNOWN_OPERATION"
assert exc.value.operation == "tool:not_real"
@pytest.mark.asyncio
async def test_streamable_http_enforces_external_oauth_tool_scope_before_dispatch() -> None:
contexts = {
"no-scope": _context("external_oauth"),
"list-only": _context("external_oauth", ["tool:list"]),
}
class SecurityManager:
async def authenticate_request(
self,
credentials: BearerCredentials,
) -> AuthContext:
return contexts[credentials.token]
server = create_registry_test_server()
transport = DorisMCPHTTPTransport(
app=server,
security_settings=create_transport_security("127.0.0.1"),
)
app = MCPAuthASGIMiddleware(
SecurityManager(),
transport.handle_request,
_external_oauth_config(),
)
async with (
transport.run(),
httpx2.ASGITransport(app) as asgi_transport,
httpx2.AsyncClient(
transport=asgi_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", token="list-only"),
)
assert listed.status_code == 200
assert {
tool["name"] for tool in listed.json()["result"]["tools"]
} == set(EXPECTED_DOMAIN_CHILDREN)
denied = await client.post(
"/mcp",
json=_modern_request(
2,
"tools/call",
name="doris_catalog",
arguments={},
),
headers=_modern_headers(
"tools/call",
name="doris_catalog",
token="no-scope",
),
)
assert denied.status_code == 403
assert denied.json()["error"] == "PERMISSION_DENIED"
assert denied.json()["required_scope"] == "tool:list"
assert 'error="insufficient_scope"' in denied.headers[
"www-authenticate"
]
assert 'scope="tool:list"' in denied.headers[
"www-authenticate"
]
allowed = await client.post(
"/mcp",
json=_modern_request(
3,
"tools/call",
name="doris_catalog",
arguments={},
),
headers=_modern_headers(
"tools/call",
name="doris_catalog",
token="list-only",
),
)
assert allowed.status_code == 200
assert allowed.json()["result"]["structuredContent"]["mode"] == (
"manifest"
)
assert allowed.json()["result"]["structuredContent"]["domain"] == (
"doris_catalog"
)
@pytest.mark.asyncio
async def test_true_subprocess_stdio_keeps_local_tool_and_resource_paths_scope_free() -> None:
server_script = (
Path(__file__).resolve().parents[1]
/ "protocol"
/ "tool_registry_server.py"
)
server_params = StdioServerParameters(
command=sys.executable,
args=[str(server_script)],
)
async with Client(stdio_client(server_params)) as client:
tools = await client.list_tools(cache_mode="bypass")
assert {tool.name for tool in tools.tools} == set(
EXPECTED_DOMAIN_CHILDREN
)
called = await client.call_tool("doris_catalog", {})
assert called.structured_content["mode"] == "manifest"
assert called.structured_content["domain"] == "doris_catalog"
resources = await client.list_resources(cache_mode="bypass")
assert resources.resources == []
read = await client.read_resource(
"doris://table/orders",
cache_mode="bypass",
)
assert read.contents[0].uri == "doris://table/orders"