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:
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 @@
|
|||||||
__version__ = "0.1.0"
|
__version__ = "0.2.0"
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user