From d76c16d9a71ca759eac096a5f4469322134ff812 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Wed, 6 May 2026 20:42:51 +0200 Subject: [PATCH] feat(auth): forward SONARQUBE_TOKEN to upstream as Bearer header 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 " 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) --- CHANGELOG.md | 42 +++++++++++++++ CLAUDE.md | 14 +++++ README.md | 33 ++++++++---- pyproject.toml | 2 +- src/mcp_sonarqube_proxy/__init__.py | 2 +- src/mcp_sonarqube_proxy/cli.py | 34 +++++++++++- src/mcp_sonarqube_proxy/proxy.py | 36 +++++++++++-- tests/conftest.py | 15 ++++++ tests/test_cli.py | 63 ++++++++++++++++++++++ tests/test_proxy.py | 82 +++++++++++++++++++++++++++++ 10 files changed, 307 insertions(+), 16 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 tests/conftest.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f2d41e1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +All notable changes to this project are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] — 2026-05-06 + +### Added + +- **`SONARQUBE_TOKEN` (required).** The upstream MCP server expects a SonarQube + user token in the `Authorization` header. The proxy now reads the token from + the environment and forwards it as `Bearer ` on every connection. +- `TokenMissingError` raised by `proxy.sonarqube_token()` when the variable is + unset or blank. +- Early fail-fast token check in `serve` and `check` — exits with code 1 and a + clear stderr message pointing to *My Account → Security* in SonarQube. +- Defensive token redaction: any stderr output that includes exception text + has occurrences of the live token replaced with `[REDACTED]` before display. +- Test coverage for header forwarding, missing-token exit, and token + non-leakage in error paths. + +### Changed + +- `upstream_session()` now opens the streamable-HTTP client with the + `Authorization` header attached. +- README and CLAUDE.md updated with the new env-var, the Claude Desktop config + snippet now includes `SONARQUBE_TOKEN`, and the security guarantees are + documented. + +## [0.1.0] — 2026-05-06 + +### Added + +- Initial transparent stdio MCP proxy implementation. Forwards `tools/list` and + `tools/call` 1:1 to an upstream streamable-HTTP MCP server. +- Schema preservation: `Tool` objects (`inputSchema`, `outputSchema`, + `annotations`, `title`, `_meta`) and `CallToolResult` (`isError`, + `structuredContent`, content blocks) are passed through unchanged. +- Click-based CLI with `serve` (stdio) and `check` (probe-and-list) commands. +- pytest suite (14 tests) covering env resolution, schema passthrough, + forwarding, registration, end-to-end dispatch, and CLI error paths. diff --git a/CLAUDE.md b/CLAUDE.md index 7306cd5..95d3f4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,20 @@ diesen weiterleitet. Claude Desktop / Claude App spawnt diesen Prozess via stdio Fehlermeldungen) muss auf `stderr`. `_configure_logging` setzt das fuer das Logging-Modul, `_stderr()` ist der Helper fuer direkte Meldungen. +### SONARQUBE_TOKEN — Sicherheit + +Der Upstream verlangt einen User-Token im `Authorization`-Header. Wir lesen +den Token in `proxy.sonarqube_token()` aus der Env-Var, leiten ihn als +`Bearer ` an `streamablehttp_client(headers=...)` weiter, und speichern +ihn nirgends. Vor jedem stderr-Output mit potenziell ungeprueftem +Exception-Text laeuft `cli._redact()`, die den live-Token (falls in der Env) +durch `[REDACTED]` ersetzt — defensive Schicht gegen Drittanbieter-Libraries, +die in Fehlermeldungen Header einbetten koennten. + +`TokenMissingError` enthaelt **niemals** Token-Material, weil dieser Fall genau +heisst: es gibt keinen Token. Die Meldung enthaelt nur die operator-gerichtete +Anleitung "My Account -> Security". + ## Lokale Entwicklung ```bash diff --git a/README.md b/README.md index eb698ee..161dceb 100644 --- a/README.md +++ b/README.md @@ -12,16 +12,28 @@ Tools korrekt aufrufen kann. uv tool install git+https://gitea.gecheckt.de/marcus/mcp-sonarqube-proxy.git ``` +## SonarQube-Token + +Der Upstream-Container verlangt einen SonarQube-User-Token. Token erzeugen +in SonarQube unter **My Account → Security → Generate Tokens**, dann in +`SONARQUBE_TOKEN` setzen. Ohne Token brechen `serve` und `check` mit Exit-Code 1 +und einer klaren Meldung auf stderr ab. Der Token wird ausschliesslich als +`Authorization: Bearer ` an den Upstream weitergereicht und nirgends +geloggt; Fehlermeldungen, die den Token versehentlich enthalten, werden vor der +Ausgabe maskiert (`[REDACTED]`). + ## Verwendung ### Verbindung pruefen ```bash -SONARQUBE_MCP_URL=http://192.168.0.2:8765/mcp mcp-sonarqube-proxy check +SONARQUBE_MCP_URL=http://192.168.0.2:8765/mcp \ +SONARQUBE_TOKEN= \ + mcp-sonarqube-proxy check ``` Listet alle Tools auf, die der Upstream bereitstellt. Exit-Code 0 bei Erfolg, -1 wenn der Upstream nicht erreichbar ist. +1 wenn der Token fehlt oder der Upstream nicht erreichbar ist. ### Claude Desktop / Claude App Konfiguration @@ -34,7 +46,8 @@ In `claude_desktop_config.json`: "command": "mcp-sonarqube-proxy", "args": ["serve"], "env": { - "SONARQUBE_MCP_URL": "http://192.168.0.2:8765/mcp" + "SONARQUBE_MCP_URL": "http://192.168.0.2:8765/mcp", + "SONARQUBE_TOKEN": "" } } } @@ -47,9 +60,10 @@ Auf Windows ist `command` typischerweise der absolute Pfad zur per ## Umgebungsvariablen -| Variable | Default | Beschreibung | -|----------------------|----------------------------------|-------------------------------------------| -| `SONARQUBE_MCP_URL` | `http://192.168.0.2:8765/mcp` | Upstream-MCP-Endpoint (Streamable HTTP) | +| Variable | Pflicht | Default | Beschreibung | +|----------------------|---------|----------------------------------|---------------------------------------------| +| `SONARQUBE_TOKEN` | ja | — | SonarQube-User-Token (My Account → Security)| +| `SONARQUBE_MCP_URL` | nein | `http://192.168.0.2:8765/mcp` | Upstream-MCP-Endpoint (Streamable HTTP) | ## Kommandos @@ -61,9 +75,10 @@ Auf Windows ist `command` typischerweise der absolute Pfad zur per ## Funktionsweise -1. Beim Start oeffnet `serve` eine Streamable-HTTP-Session zum Upstream und - ruft `initialize()` auf. Schlaegt das fehl, beendet sich der Prozess mit - Exit-Code 1 und schreibt die Fehlermeldung auf stderr. +1. Beim Start liest `serve` `SONARQUBE_TOKEN` und oeffnet eine Streamable-HTTP- + Session zum Upstream mit `Authorization: Bearer `. Anschliessend wird + `initialize()` ausgefuehrt. Schlaegt eines davon fehl, beendet sich der + Prozess mit Exit-Code 1 und schreibt die Fehlermeldung auf stderr. 2. Diese Session bleibt fuer die Lebensdauer des Proxies offen. 3. `tools/list`-Requests vom Client werden 1:1 an den Upstream weitergeleitet — die `Tool`-Objekte (mit allen Schemata und Annotations) kommen unveraendert diff --git a/pyproject.toml b/pyproject.toml index d6ed2c3..90dd28c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mcp-sonarqube-proxy" -version = "0.1.0" +version = "0.2.0" description = "Stdio MCP server that proxies tools from an upstream SonarQube MCP server over streamable HTTP." readme = "README.md" requires-python = ">=3.12" diff --git a/src/mcp_sonarqube_proxy/__init__.py b/src/mcp_sonarqube_proxy/__init__.py index 3dc1f76..d3ec452 100644 --- a/src/mcp_sonarqube_proxy/__init__.py +++ b/src/mcp_sonarqube_proxy/__init__.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/src/mcp_sonarqube_proxy/cli.py b/src/mcp_sonarqube_proxy/cli.py index 9ea3e9a..24df418 100644 --- a/src/mcp_sonarqube_proxy/cli.py +++ b/src/mcp_sonarqube_proxy/cli.py @@ -4,7 +4,9 @@ from __future__ import annotations import asyncio import logging +import os import sys +from collections.abc import Callable import click from mcp import types @@ -12,8 +14,10 @@ from mcp import types from mcp_sonarqube_proxy import __version__ from mcp_sonarqube_proxy.proxy import ( SERVER_NAME, + TokenMissingError, build_proxy_server, list_tools_via, + sonarqube_token, upstream_session, upstream_url, ) @@ -34,6 +38,27 @@ def _stderr(msg: str) -> None: print(msg, file=sys.stderr, flush=True) +def _redact(text: str) -> str: + """Strip the live token from ``text`` so it cannot be leaked into stderr. + + Defensive: third-party libraries should not embed request headers in their + exception messages, but if any ever does, this guarantees the token does + not reach the user-visible output. + """ + token = os.environ.get("SONARQUBE_TOKEN", "").strip() + if token and token in text: + return text.replace(token, "[REDACTED]") + return text + + +def _require_token_or_exit(emit: Callable[[str], None]) -> None: + try: + sonarqube_token() + except TokenMissingError as e: + emit(str(e)) + sys.exit(1) + + @click.group() @click.version_option(__version__, prog_name=SERVER_NAME) def main() -> None: @@ -44,13 +69,14 @@ def main() -> None: def serve() -> None: """Start the MCP proxy on stdio.""" _configure_logging("warning") + _require_token_or_exit(lambda msg: _stderr(f"{SERVER_NAME}: {msg}")) _stderr(f"{SERVER_NAME} {__version__}: connecting to {upstream_url()}") try: asyncio.run(_run_serve()) except KeyboardInterrupt: sys.exit(130) except Exception as e: - _stderr(f"{SERVER_NAME}: fatal: {e}") + _stderr(f"{SERVER_NAME}: fatal: {_redact(str(e))}") sys.exit(1) @@ -68,10 +94,14 @@ async def _run_serve() -> None: def check() -> None: """Probe the upstream connection and list its tools.""" _configure_logging("warning") + _require_token_or_exit(lambda msg: click.echo(click.style(msg, fg="red"), err=True)) try: tools = asyncio.run(_run_check()) except Exception as e: - click.echo(click.style(f"Upstream nicht erreichbar: {e}", fg="red"), err=True) + click.echo( + click.style(f"Upstream nicht erreichbar: {_redact(str(e))}", fg="red"), + err=True, + ) sys.exit(1) click.echo(click.style(f"Upstream erreichbar — {len(tools)} Tools:", fg="green")) diff --git a/src/mcp_sonarqube_proxy/proxy.py b/src/mcp_sonarqube_proxy/proxy.py index b3bc29c..3385039 100644 --- a/src/mcp_sonarqube_proxy/proxy.py +++ b/src/mcp_sonarqube_proxy/proxy.py @@ -4,6 +4,11 @@ The proxy preserves every Tool field that the upstream advertises (inputSchema, outputSchema, annotations, title, _meta, ...) by passing the Tool objects through unchanged. Tool calls are forwarded as full CallToolResult instances, preserving isError, structuredContent and content blocks. + +Authentication: the upstream MCP server expects a SonarQube user token in the +``Authorization`` header. The token is read from ``SONARQUBE_TOKEN`` and forwarded +as ``Bearer ``. The token is never logged, never stored on any object, and +never embedded in error messages. """ from __future__ import annotations @@ -20,21 +25,46 @@ from mcp.server.lowlevel import Server DEFAULT_UPSTREAM_URL = "http://192.168.0.2:8765/mcp" SERVER_NAME = "mcp-sonarqube-proxy" +_TOKEN_HELP = ( + "SONARQUBE_TOKEN is not set. Generate a user token in SonarQube under " + "My Account -> Security and pass it via the SONARQUBE_TOKEN environment variable." +) + + +class TokenMissingError(RuntimeError): + """Raised when the SONARQUBE_TOKEN environment variable is empty or unset.""" + + def __init__(self) -> None: + super().__init__(_TOKEN_HELP) + def upstream_url() -> str: """Resolve the upstream URL at call time (not at import time).""" return os.environ.get("SONARQUBE_MCP_URL", DEFAULT_UPSTREAM_URL) +def sonarqube_token() -> str: + """Return the SonarQube user token from the environment. + + Raises ``TokenMissingError`` if the variable is unset or empty. The error message + contains *no* token material — only operator-facing guidance on how to set it. + """ + token = os.environ.get("SONARQUBE_TOKEN", "").strip() + if not token: + raise TokenMissingError() + return token + + @asynccontextmanager async def upstream_session(url: str | None = None) -> AsyncIterator[ClientSession]: """Open a streamable-HTTP MCP client session to the upstream and yield it. - The session is initialized before yielding. The HTTP transport and session are - torn down when the context exits. + The token is resolved at call time and attached as ``Authorization: Bearer ...``. + The HTTP transport and session are torn down when the context exits. """ target = url or upstream_url() - async with streamablehttp_client(target) as (read, write, _): + headers = {"Authorization": f"Bearer {sonarqube_token()}"} + async with streamablehttp_client(target, headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() yield session diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d6b8de6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +"""Shared test fixtures.""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True) +def _default_token(monkeypatch: pytest.MonkeyPatch) -> None: + """Provide a non-empty SONARQUBE_TOKEN by default. + + Tests that exercise the missing-token path explicitly call + ``monkeypatch.delenv("SONARQUBE_TOKEN", raising=False)``. + """ + monkeypatch.setenv("SONARQUBE_TOKEN", "test-token-default") diff --git a/tests/test_cli.py b/tests/test_cli.py index 3aab2ee..5ad96f7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -69,3 +69,66 @@ def test_version_flag() -> None: result = CliRunner().invoke(cli.main, ["--version"]) assert result.exit_code == 0 assert __version__ in result.output + + +# --- SONARQUBE_TOKEN handling ------------------------------------------------ + + +def test_check_missing_token_exits_one_with_clean_message( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("SONARQUBE_TOKEN", raising=False) + + result = CliRunner().invoke(cli.main, ["check"]) + + assert result.exit_code == 1 + assert "SONARQUBE_TOKEN" in result.stderr + assert "Security" in result.stderr + + +def test_serve_missing_token_exits_one_with_clean_message( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("SONARQUBE_TOKEN", raising=False) + + result = CliRunner().invoke(cli.main, ["serve"]) + + assert result.exit_code == 1 + assert "SONARQUBE_TOKEN" in result.stderr + + +def test_check_redacts_token_when_error_includes_it( + monkeypatch: pytest.MonkeyPatch, +) -> None: + secret = "secret-token-do-not-leak" + monkeypatch.setenv("SONARQUBE_TOKEN", secret) + + async def fake_run_check() -> list[types.Tool]: + # Simulate a worst-case dependency that leaks the token in its message. + raise RuntimeError(f"401 Unauthorized; sent header Bearer {secret}") + + monkeypatch.setattr(cli, "_run_check", fake_run_check) + + result = CliRunner().invoke(cli.main, ["check"]) + + assert result.exit_code == 1 + assert secret not in result.stderr + assert "[REDACTED]" in result.stderr + + +def test_serve_redacts_token_when_error_includes_it( + monkeypatch: pytest.MonkeyPatch, +) -> None: + secret = "another-secret-token-leak" + monkeypatch.setenv("SONARQUBE_TOKEN", secret) + + async def fake_run_serve() -> None: + raise RuntimeError(f"upstream said Bearer {secret} is invalid") + + monkeypatch.setattr(cli, "_run_serve", fake_run_serve) + + result = CliRunner().invoke(cli.main, ["serve"]) + + assert result.exit_code == 1 + assert secret not in result.stderr + assert "[REDACTED]" in result.stderr diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 289a04d..1215a79 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -2,6 +2,7 @@ from __future__ import annotations +from contextlib import asynccontextmanager from typing import Any from unittest.mock import AsyncMock @@ -10,9 +11,12 @@ 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, ) @@ -148,3 +152,81 @@ async def test_build_proxy_server_handler_forwards_tools_through_dispatch() -> N 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 .""" + 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