d76c16d9a7
The upstream MCP container requires a SonarQube user token in the Authorization header. Without one, every call returns 401. - proxy: read SONARQUBE_TOKEN via sonarqube_token() at session-open time; raise TokenMissingError when unset/blank. upstream_session() attaches the token as "Authorization: Bearer <token>" via streamablehttp_client(headers=...). - cli: fail fast in serve and check with a clear stderr message and exit 1 when the token is missing, before any network attempt. All exception text written to stderr passes through _redact() so an accidentally-leaked token from a third-party exception is replaced with [REDACTED] before display. - The token is never stored on any object, never logged, and the TokenMissingError message contains no token material (it only describes how to generate one in SonarQube). - Tests: header forwarding via mocked streamablehttp_client, missing- token exit code, redaction in CLI error paths, whitespace stripping on the token. Total: 25 tests. - Docs: README/CLAUDE updated with the new env-var, Claude Desktop config snippet, and the security guarantees. CHANGELOG added. Bumps version to 0.2.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
233 lines
7.7 KiB
Python
233 lines
7.7 KiB
Python
"""Tests for the proxy core: discovery, schema preservation, call forwarding."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import asynccontextmanager
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from mcp import types
|
|
|
|
from mcp_sonarqube_proxy.proxy import (
|
|
DEFAULT_UPSTREAM_URL,
|
|
TokenMissingError,
|
|
build_proxy_server,
|
|
call_tool_via,
|
|
list_tools_via,
|
|
sonarqube_token,
|
|
upstream_session,
|
|
upstream_url,
|
|
)
|
|
|
|
|
|
def _full_tool() -> types.Tool:
|
|
"""A Tool with every passthrough field populated."""
|
|
return types.Tool(
|
|
name="rich_tool",
|
|
title="Rich Tool",
|
|
description="A tool with full metadata.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string", "description": "search query"},
|
|
"limit": {"type": "integer", "minimum": 1, "default": 10},
|
|
},
|
|
"required": ["query"],
|
|
"additionalProperties": False,
|
|
},
|
|
outputSchema={
|
|
"type": "object",
|
|
"properties": {"hits": {"type": "array", "items": {"type": "string"}}},
|
|
},
|
|
annotations=types.ToolAnnotations(
|
|
title="Rich Tool",
|
|
readOnlyHint=True,
|
|
idempotentHint=True,
|
|
openWorldHint=False,
|
|
),
|
|
)
|
|
|
|
|
|
def _fake_upstream(*, list_tools_return: Any = None, call_tool_return: Any = None) -> AsyncMock:
|
|
upstream = AsyncMock()
|
|
if list_tools_return is not None:
|
|
upstream.list_tools.return_value = list_tools_return
|
|
if call_tool_return is not None:
|
|
upstream.call_tool.return_value = call_tool_return
|
|
return upstream
|
|
|
|
|
|
def test_upstream_url_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("SONARQUBE_MCP_URL", raising=False)
|
|
assert upstream_url() == DEFAULT_UPSTREAM_URL
|
|
|
|
|
|
def test_upstream_url_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("SONARQUBE_MCP_URL", "http://example.test:9000/mcp")
|
|
assert upstream_url() == "http://example.test:9000/mcp"
|
|
|
|
|
|
async def test_list_tools_via_returns_full_tool_objects() -> None:
|
|
tool = _full_tool()
|
|
upstream = _fake_upstream(list_tools_return=types.ListToolsResult(tools=[tool]))
|
|
|
|
tools = await list_tools_via(upstream)
|
|
|
|
assert len(tools) == 1
|
|
# Round-trip dump must match exactly: schemas, annotations, title all survive.
|
|
assert tools[0].model_dump(by_alias=True) == tool.model_dump(by_alias=True)
|
|
upstream.list_tools.assert_awaited_once()
|
|
|
|
|
|
async def test_list_tools_via_returns_empty_list() -> None:
|
|
upstream = _fake_upstream(list_tools_return=types.ListToolsResult(tools=[]))
|
|
|
|
tools = await list_tools_via(upstream)
|
|
|
|
assert tools == []
|
|
|
|
|
|
async def test_list_tools_via_propagates_errors() -> None:
|
|
upstream = AsyncMock()
|
|
upstream.list_tools.side_effect = RuntimeError("upstream gone")
|
|
|
|
with pytest.raises(RuntimeError, match="upstream gone"):
|
|
await list_tools_via(upstream)
|
|
|
|
|
|
async def test_call_tool_via_forwards_full_result() -> None:
|
|
expected = types.CallToolResult(
|
|
content=[types.TextContent(type="text", text="ok")],
|
|
structuredContent={"score": 42},
|
|
isError=False,
|
|
)
|
|
upstream = _fake_upstream(call_tool_return=expected)
|
|
|
|
result = await call_tool_via(upstream, "do_thing", {"x": 1})
|
|
|
|
assert result is expected
|
|
upstream.call_tool.assert_awaited_once_with("do_thing", {"x": 1})
|
|
|
|
|
|
async def test_call_tool_via_preserves_is_error_results() -> None:
|
|
expected = types.CallToolResult(
|
|
content=[types.TextContent(type="text", text="boom")],
|
|
isError=True,
|
|
)
|
|
upstream = _fake_upstream(call_tool_return=expected)
|
|
|
|
result = await call_tool_via(upstream, "broken", {})
|
|
|
|
assert result.isError is True
|
|
assert result.content[0].text == "boom"
|
|
|
|
|
|
async def test_call_tool_via_propagates_errors() -> None:
|
|
upstream = AsyncMock()
|
|
upstream.call_tool.side_effect = ConnectionError("link down")
|
|
|
|
with pytest.raises(ConnectionError, match="link down"):
|
|
await call_tool_via(upstream, "anything", {})
|
|
|
|
|
|
def test_build_proxy_server_registers_handlers() -> None:
|
|
upstream = AsyncMock()
|
|
server = build_proxy_server(upstream)
|
|
|
|
assert types.ListToolsRequest in server.request_handlers
|
|
assert types.CallToolRequest in server.request_handlers
|
|
|
|
|
|
async def test_build_proxy_server_handler_forwards_tools_through_dispatch() -> None:
|
|
"""End-to-end: dispatch a ListToolsRequest through the registered handler."""
|
|
tool = _full_tool()
|
|
upstream = _fake_upstream(list_tools_return=types.ListToolsResult(tools=[tool]))
|
|
server = build_proxy_server(upstream)
|
|
|
|
handler = server.request_handlers[types.ListToolsRequest]
|
|
server_result = await handler(types.ListToolsRequest(method="tools/list"))
|
|
|
|
inner = server_result.root # ServerResult wraps a ListToolsResult
|
|
assert isinstance(inner, types.ListToolsResult)
|
|
assert len(inner.tools) == 1
|
|
assert inner.tools[0].model_dump(by_alias=True) == tool.model_dump(by_alias=True)
|
|
|
|
|
|
# --- SONARQUBE_TOKEN handling ------------------------------------------------
|
|
|
|
|
|
def test_sonarqube_token_returns_value(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("SONARQUBE_TOKEN", "my-token-xyz")
|
|
assert sonarqube_token() == "my-token-xyz"
|
|
|
|
|
|
def test_sonarqube_token_strips_whitespace(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("SONARQUBE_TOKEN", " trimmed-token ")
|
|
assert sonarqube_token() == "trimmed-token"
|
|
|
|
|
|
def test_sonarqube_token_raises_when_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("SONARQUBE_TOKEN", raising=False)
|
|
with pytest.raises(TokenMissingError):
|
|
sonarqube_token()
|
|
|
|
|
|
def test_sonarqube_token_raises_when_blank(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("SONARQUBE_TOKEN", " ")
|
|
with pytest.raises(TokenMissingError):
|
|
sonarqube_token()
|
|
|
|
|
|
def test_token_missing_error_message_carries_no_token() -> None:
|
|
msg = str(TokenMissingError())
|
|
assert "SONARQUBE_TOKEN" in msg
|
|
assert "Security" in msg
|
|
# No token material is ever in this message — there is no token to leak.
|
|
|
|
|
|
async def test_upstream_session_attaches_bearer_header(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The token must be forwarded as Authorization: Bearer <token>."""
|
|
monkeypatch.setenv("SONARQUBE_TOKEN", "header-test-token")
|
|
captured: dict[str, Any] = {}
|
|
fake_session = AsyncMock()
|
|
|
|
@asynccontextmanager
|
|
async def fake_streamable(url: str, headers: dict[str, str] | None = None, **_: Any):
|
|
captured["url"] = url
|
|
captured["headers"] = headers
|
|
yield ("READ", "WRITE", lambda: None)
|
|
|
|
class FakeClientSession:
|
|
def __init__(self, read: Any, write: Any) -> None:
|
|
self.read = read
|
|
self.write = write
|
|
|
|
async def __aenter__(self) -> AsyncMock:
|
|
return fake_session
|
|
|
|
async def __aexit__(self, *exc: Any) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr("mcp_sonarqube_proxy.proxy.streamablehttp_client", fake_streamable)
|
|
monkeypatch.setattr("mcp_sonarqube_proxy.proxy.ClientSession", FakeClientSession)
|
|
|
|
async with upstream_session(url="http://x.example/mcp") as session:
|
|
assert session is fake_session
|
|
|
|
assert captured["url"] == "http://x.example/mcp"
|
|
assert captured["headers"] == {"Authorization": "Bearer header-test-token"}
|
|
fake_session.initialize.assert_awaited_once()
|
|
|
|
|
|
async def test_upstream_session_aborts_when_token_missing(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("SONARQUBE_TOKEN", raising=False)
|
|
|
|
with pytest.raises(TokenMissingError):
|
|
async with upstream_session(url="http://x.example/mcp"):
|
|
pass
|