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
+63
View File
@@ -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