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
|
||||
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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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 <token>` 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=<your-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": "<your-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 <token>`. 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
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.0"
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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 <token>``. 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
|
||||
|
||||
@@ -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"])
|
||||
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
|
||||
|
||||
@@ -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 <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