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 <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>
This commit is contained in:
2026-05-06 20:42:51 +02:00
parent a6fd188c14
commit d76c16d9a7
10 changed files with 307 additions and 16 deletions
+42
View File
@@ -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 <token>` 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.
+14
View File
@@ -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 Fehlermeldungen) muss auf `stderr`. `_configure_logging` setzt das fuer das
Logging-Modul, `_stderr()` ist der Helper fuer direkte Meldungen. 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 <token>` 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 ## Lokale Entwicklung
```bash ```bash
+24 -9
View File
@@ -12,16 +12,28 @@ Tools korrekt aufrufen kann.
uv tool install git+https://gitea.gecheckt.de/marcus/mcp-sonarqube-proxy.git 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 <token>` an den Upstream weitergereicht und nirgends
geloggt; Fehlermeldungen, die den Token versehentlich enthalten, werden vor der
Ausgabe maskiert (`[REDACTED]`).
## Verwendung ## Verwendung
### Verbindung pruefen ### Verbindung pruefen
```bash ```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=<your-sonarqube-token> \
mcp-sonarqube-proxy check
``` ```
Listet alle Tools auf, die der Upstream bereitstellt. Exit-Code 0 bei Erfolg, 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 ### Claude Desktop / Claude App Konfiguration
@@ -34,7 +46,8 @@ In `claude_desktop_config.json`:
"command": "mcp-sonarqube-proxy", "command": "mcp-sonarqube-proxy",
"args": ["serve"], "args": ["serve"],
"env": { "env": {
"SONARQUBE_MCP_URL": "http://192.168.0.2:8765/mcp" "SONARQUBE_MCP_URL": "http://192.168.0.2:8765/mcp",
"SONARQUBE_TOKEN": "<your-sonarqube-token>"
} }
} }
} }
@@ -47,9 +60,10 @@ Auf Windows ist `command` typischerweise der absolute Pfad zur per
## Umgebungsvariablen ## Umgebungsvariablen
| Variable | Default | Beschreibung | | Variable | Pflicht | Default | Beschreibung |
|----------------------|----------------------------------|-------------------------------------------| |----------------------|---------|----------------------------------|---------------------------------------------|
| `SONARQUBE_MCP_URL` | `http://192.168.0.2:8765/mcp` | Upstream-MCP-Endpoint (Streamable HTTP) | | `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 ## Kommandos
@@ -61,9 +75,10 @@ Auf Windows ist `command` typischerweise der absolute Pfad zur per
## Funktionsweise ## Funktionsweise
1. Beim Start oeffnet `serve` eine Streamable-HTTP-Session zum Upstream und 1. Beim Start liest `serve` `SONARQUBE_TOKEN` und oeffnet eine Streamable-HTTP-
ruft `initialize()` auf. Schlaegt das fehl, beendet sich der Prozess mit Session zum Upstream mit `Authorization: Bearer <token>`. Anschliessend wird
Exit-Code 1 und schreibt die Fehlermeldung auf stderr. `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. 2. Diese Session bleibt fuer die Lebensdauer des Proxies offen.
3. `tools/list`-Requests vom Client werden 1:1 an den Upstream weitergeleitet — 3. `tools/list`-Requests vom Client werden 1:1 an den Upstream weitergeleitet —
die `Tool`-Objekte (mit allen Schemata und Annotations) kommen unveraendert die `Tool`-Objekte (mit allen Schemata und Annotations) kommen unveraendert
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "mcp-sonarqube-proxy" 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." description = "Stdio MCP server that proxies tools from an upstream SonarQube MCP server over streamable HTTP."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.0" __version__ = "0.2.0"
+32 -2
View File
@@ -4,7 +4,9 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os
import sys import sys
from collections.abc import Callable
import click import click
from mcp import types from mcp import types
@@ -12,8 +14,10 @@ from mcp import types
from mcp_sonarqube_proxy import __version__ from mcp_sonarqube_proxy import __version__
from mcp_sonarqube_proxy.proxy import ( from mcp_sonarqube_proxy.proxy import (
SERVER_NAME, SERVER_NAME,
TokenMissingError,
build_proxy_server, build_proxy_server,
list_tools_via, list_tools_via,
sonarqube_token,
upstream_session, upstream_session,
upstream_url, upstream_url,
) )
@@ -34,6 +38,27 @@ def _stderr(msg: str) -> None:
print(msg, file=sys.stderr, flush=True) 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.group()
@click.version_option(__version__, prog_name=SERVER_NAME) @click.version_option(__version__, prog_name=SERVER_NAME)
def main() -> None: def main() -> None:
@@ -44,13 +69,14 @@ def main() -> None:
def serve() -> None: def serve() -> None:
"""Start the MCP proxy on stdio.""" """Start the MCP proxy on stdio."""
_configure_logging("warning") _configure_logging("warning")
_require_token_or_exit(lambda msg: _stderr(f"{SERVER_NAME}: {msg}"))
_stderr(f"{SERVER_NAME} {__version__}: connecting to {upstream_url()}") _stderr(f"{SERVER_NAME} {__version__}: connecting to {upstream_url()}")
try: try:
asyncio.run(_run_serve()) asyncio.run(_run_serve())
except KeyboardInterrupt: except KeyboardInterrupt:
sys.exit(130) sys.exit(130)
except Exception as e: except Exception as e:
_stderr(f"{SERVER_NAME}: fatal: {e}") _stderr(f"{SERVER_NAME}: fatal: {_redact(str(e))}")
sys.exit(1) sys.exit(1)
@@ -68,10 +94,14 @@ async def _run_serve() -> None:
def check() -> None: def check() -> None:
"""Probe the upstream connection and list its tools.""" """Probe the upstream connection and list its tools."""
_configure_logging("warning") _configure_logging("warning")
_require_token_or_exit(lambda msg: click.echo(click.style(msg, fg="red"), err=True))
try: try:
tools = asyncio.run(_run_check()) tools = asyncio.run(_run_check())
except Exception as e: 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) sys.exit(1)
click.echo(click.style(f"Upstream erreichbar — {len(tools)} Tools:", fg="green")) click.echo(click.style(f"Upstream erreichbar — {len(tools)} Tools:", fg="green"))
+33 -3
View File
@@ -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 outputSchema, annotations, title, _meta, ...) by passing the Tool objects through
unchanged. Tool calls are forwarded as full CallToolResult instances, preserving unchanged. Tool calls are forwarded as full CallToolResult instances, preserving
isError, structuredContent and content blocks. 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 <token>``. The token is never logged, never stored on any object, and
never embedded in error messages.
""" """
from __future__ import annotations from __future__ import annotations
@@ -20,21 +25,46 @@ from mcp.server.lowlevel import Server
DEFAULT_UPSTREAM_URL = "http://192.168.0.2:8765/mcp" DEFAULT_UPSTREAM_URL = "http://192.168.0.2:8765/mcp"
SERVER_NAME = "mcp-sonarqube-proxy" 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: def upstream_url() -> str:
"""Resolve the upstream URL at call time (not at import time).""" """Resolve the upstream URL at call time (not at import time)."""
return os.environ.get("SONARQUBE_MCP_URL", DEFAULT_UPSTREAM_URL) 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 @asynccontextmanager
async def upstream_session(url: str | None = None) -> AsyncIterator[ClientSession]: async def upstream_session(url: str | None = None) -> AsyncIterator[ClientSession]:
"""Open a streamable-HTTP MCP client session to the upstream and yield it. """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 The token is resolved at call time and attached as ``Authorization: Bearer ...``.
torn down when the context exits. The HTTP transport and session are torn down when the context exits.
""" """
target = url or upstream_url() 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: async with ClientSession(read, write) as session:
await session.initialize() await session.initialize()
yield session yield session
+15
View File
@@ -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")
+63
View File
@@ -69,3 +69,66 @@ def test_version_flag() -> None:
result = CliRunner().invoke(cli.main, ["--version"]) result = CliRunner().invoke(cli.main, ["--version"])
assert result.exit_code == 0 assert result.exit_code == 0
assert __version__ in result.output 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
+82
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Any from typing import Any
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
@@ -10,9 +11,12 @@ from mcp import types
from mcp_sonarqube_proxy.proxy import ( from mcp_sonarqube_proxy.proxy import (
DEFAULT_UPSTREAM_URL, DEFAULT_UPSTREAM_URL,
TokenMissingError,
build_proxy_server, build_proxy_server,
call_tool_via, call_tool_via,
list_tools_via, list_tools_via,
sonarqube_token,
upstream_session,
upstream_url, upstream_url,
) )
@@ -148,3 +152,81 @@ async def test_build_proxy_server_handler_forwards_tools_through_dispatch() -> N
assert isinstance(inner, types.ListToolsResult) assert isinstance(inner, types.ListToolsResult)
assert len(inner.tools) == 1 assert len(inner.tools) == 1
assert inner.tools[0].model_dump(by_alias=True) == tool.model_dump(by_alias=True) 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