feat: initial v0.1.0 of mcp-sonarqube-proxy
Stdio MCP server that proxies tools from an upstream SonarQube MCP server over streamable HTTP. Tools are forwarded 1:1 with full schema preservation (inputSchema, outputSchema, annotations, title, _meta); CallToolResult is forwarded including isError and structuredContent. - proxy.py: persistent upstream ClientSession, low-level Server with @list_tools and @call_tool(validate_input=False) handlers — the upstream is the sole schema authority. - cli.py: Click-based 'serve' (stdio) and 'check' (probe) commands; logging strictly on stderr (stdout reserved for JSON-RPC). - Targets mcp 1.27.x decorator API (pinned <2 to guard against the unreleased constructor-API rewrite on main). - pytest suite (14 tests) covering env-var resolution, schema passthrough, CallToolResult forwarding, registration, dispatch end-to-end, and CLI success/error paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+49
@@ -0,0 +1,49 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
wheels/
|
||||||
|
.eggs/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# uv
|
||||||
|
.uv/
|
||||||
|
uv.lock
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
|
||||||
|
# Type checkers / linters
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.pyright/
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Entwicklungshinweise fuer `mcp-sonarqube-proxy`.
|
||||||
|
|
||||||
|
## Projekt
|
||||||
|
|
||||||
|
Stdio-MCP-Server, der beim Start eine Streamable-HTTP-Verbindung zum Upstream
|
||||||
|
SonarQube-MCP-Server oeffnet, dessen Tools 1:1 weiterreicht und Aufrufe an
|
||||||
|
diesen weiterleitet. Claude Desktop / Claude App spawnt diesen Prozess via stdio.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
- `src/mcp_sonarqube_proxy/proxy.py` — eine `upstream_session()` (async context
|
||||||
|
manager) haelt die persistente `ClientSession` zum Upstream. `build_proxy_server()`
|
||||||
|
registriert zwei Handler auf einem `mcp.server.lowlevel.Server`, die Aufrufe
|
||||||
|
transparent weiterleiten.
|
||||||
|
- `src/mcp_sonarqube_proxy/cli.py` — Click-CLI mit `serve` (stdio) und `check`
|
||||||
|
(einmaliger Verbindungstest).
|
||||||
|
|
||||||
|
### Zentrale Architekturentscheidungen
|
||||||
|
|
||||||
|
1. **Low-Level `Server`, nicht `FastMCP`.** FastMCP introspiziert die
|
||||||
|
Handler-Signatur, um ein `inputSchema` zu generieren. Bei einem generischen
|
||||||
|
`**kwargs`-Handler ist das Schema leer und der Client weiss nicht, wie er
|
||||||
|
das Tool aufrufen soll. Der Low-Level-Server uebernimmt dagegen das
|
||||||
|
`Tool`-Objekt mit allen Feldern (`inputSchema`, `outputSchema`,
|
||||||
|
`annotations`, `title`, `_meta`) unveraendert vom Upstream.
|
||||||
|
|
||||||
|
2. **Eine persistente Upstream-Session, kein Per-Call-Reconnect.** Die fruehere
|
||||||
|
Variante hat fuer jeden Tool-Call eine neue HTTP-Session geoeffnet. Das
|
||||||
|
bricht die Streamable-HTTP-Session-Id, multipliziert die Latenz und macht
|
||||||
|
`initialize()` zur teuersten Operation im Hot Path. Jetzt: einmal beim Start
|
||||||
|
verbinden, bei `serve`-Lifetime offenhalten, beim Beenden sauber schliessen.
|
||||||
|
|
||||||
|
3. **`validate_input=False` auf dem `@call_tool`-Decorator.** Der Upstream ist
|
||||||
|
die einzige Wahrheit ueber Schemata. Lokale Validierung wuerde nur
|
||||||
|
duplizieren und koennte bei Schema-Drift zu falschen Ablehnungen fuehren.
|
||||||
|
|
||||||
|
4. **`server.create_initialization_options()` statt manueller Capabilities.**
|
||||||
|
Die Helper-Methode liefert die korrekten `NotificationOptions()` und
|
||||||
|
`experimental_capabilities={}` und ueberlebt SDK-Updates besser als ein
|
||||||
|
handgeschriebener Aufruf.
|
||||||
|
|
||||||
|
5. **Stateless Tool-Liste — kein Caching.** Jeder `tools/list`-Request
|
||||||
|
re-fetcht den Upstream. Weniger Code, immer aktuell, kein Cache-Coherency-
|
||||||
|
Problem.
|
||||||
|
|
||||||
|
### Logging und stdout/stderr
|
||||||
|
|
||||||
|
`stdout` ist fuer JSON-RPC reserviert. Jegliche Ausgabe (Logging, Startmeldungen,
|
||||||
|
Fehlermeldungen) muss auf `stderr`. `_configure_logging` setzt das fuer das
|
||||||
|
Logging-Modul, `_stderr()` ist der Helper fuer direkte Meldungen.
|
||||||
|
|
||||||
|
## Lokale Entwicklung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Editable install + Dev-Group
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Upstream-Verbindung testen
|
||||||
|
SONARQUBE_MCP_URL=http://192.168.0.2:8765/mcp uv run mcp-sonarqube-proxy check
|
||||||
|
|
||||||
|
# Server lokal starten (stdio — z.B. via MCP Inspector)
|
||||||
|
SONARQUBE_MCP_URL=http://192.168.0.2:8765/mcp uv run mcp-sonarqube-proxy serve
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
uv run pytest
|
||||||
|
|
||||||
|
# Lint / Format
|
||||||
|
uv run ruff check .
|
||||||
|
uv run ruff format .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation als Tool
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv tool install git+https://gitea.gecheckt.de/marcus/mcp-sonarqube-proxy.git
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bekannte offene Punkte
|
||||||
|
|
||||||
|
- **Reconnect bei Verbindungsabbruch:** Wenn der Upstream waehrend `serve`
|
||||||
|
weggeht, scheitern nachfolgende Tool-Calls bis zum Neustart des Proxies.
|
||||||
|
Eine Reconnect-Logik mit Retry on `anyio.ClosedResourceError`/`httpx.ReadError`
|
||||||
|
waere ein sinnvoller naechster Schritt, ist fuer v0.1 aber bewusst weggelassen.
|
||||||
|
- **Concurrent Tool-Calls:** Die `ClientSession` korreliert Antworten ueber
|
||||||
|
Request-IDs und sollte parallele Calls verkraften. Nicht explizit getestet.
|
||||||
|
- **SDK-Versionssprung:** Auf `main` der python-sdk wurden die Decorators
|
||||||
|
durch Konstruktor-Kwargs ersetzt. Bei Migration auf eine neue Major-Version
|
||||||
|
muss `build_proxy_server()` angepasst werden.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# mcp-sonarqube-proxy
|
||||||
|
|
||||||
|
Stdio-MCP-Server, der einen Upstream-SonarQube-MCP-Server (Streamable HTTP)
|
||||||
|
in Claude Desktop / Claude App einbindet. Der Proxy wird via stdio gespawnt,
|
||||||
|
verbindet sich beim Start mit dem Upstream und reicht alle Tools 1:1 weiter —
|
||||||
|
inklusive `inputSchema`, `outputSchema` und `annotations`, sodass Claude die
|
||||||
|
Tools korrekt aufrufen kann.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv tool install git+https://gitea.gecheckt.de/marcus/mcp-sonarqube-proxy.git
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verwendung
|
||||||
|
|
||||||
|
### Verbindung pruefen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SONARQUBE_MCP_URL=http://192.168.0.2:8765/mcp mcp-sonarqube-proxy check
|
||||||
|
```
|
||||||
|
|
||||||
|
Listet alle Tools auf, die der Upstream bereitstellt. Exit-Code 0 bei Erfolg,
|
||||||
|
1 wenn der Upstream nicht erreichbar ist.
|
||||||
|
|
||||||
|
### Claude Desktop / Claude App Konfiguration
|
||||||
|
|
||||||
|
In `claude_desktop_config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"sonarqube": {
|
||||||
|
"command": "mcp-sonarqube-proxy",
|
||||||
|
"args": ["serve"],
|
||||||
|
"env": {
|
||||||
|
"SONARQUBE_MCP_URL": "http://192.168.0.2:8765/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Auf Windows ist `command` typischerweise der absolute Pfad zur per
|
||||||
|
`uv tool install` installierten EXE, z.B.
|
||||||
|
`%USERPROFILE%\.local\bin\mcp-sonarqube-proxy.exe`.
|
||||||
|
|
||||||
|
## Umgebungsvariablen
|
||||||
|
|
||||||
|
| Variable | Default | Beschreibung |
|
||||||
|
|----------------------|----------------------------------|-------------------------------------------|
|
||||||
|
| `SONARQUBE_MCP_URL` | `http://192.168.0.2:8765/mcp` | Upstream-MCP-Endpoint (Streamable HTTP) |
|
||||||
|
|
||||||
|
## Kommandos
|
||||||
|
|
||||||
|
| Kommando | Beschreibung |
|
||||||
|
|--------------------------------|----------------------------------------------------|
|
||||||
|
| `mcp-sonarqube-proxy serve` | Startet den MCP-Server auf stdio. |
|
||||||
|
| `mcp-sonarqube-proxy check` | Testet Upstream-Verbindung und listet Tools. |
|
||||||
|
| `mcp-sonarqube-proxy --version`| Gibt die Proxy-Version aus. |
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
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
|
||||||
|
beim Client an.
|
||||||
|
4. `tools/call`-Requests werden ebenfalls weitergeleitet. Das vollstaendige
|
||||||
|
`CallToolResult` (inklusive `isError`, `structuredContent` und allen
|
||||||
|
Content-Bloecken) wird an den Client zurueckgegeben.
|
||||||
|
5. `stdout` ist ausschliesslich fuer JSON-RPC reserviert. Logging und
|
||||||
|
Statusmeldungen gehen auf `stderr`.
|
||||||
|
|
||||||
|
Der Proxy validiert Tool-Argumente bewusst nicht lokal — der Upstream ist die
|
||||||
|
einzige Schema-Autoritaet und uebernimmt die Validierung.
|
||||||
|
|
||||||
|
## Anforderungen
|
||||||
|
|
||||||
|
- Python `>= 3.12`
|
||||||
|
- `mcp >= 1.27, < 2`
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "mcp-sonarqube-proxy"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Stdio MCP server that proxies tools from an upstream SonarQube MCP server over streamable HTTP."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
license = { text = "MIT" }
|
||||||
|
authors = [
|
||||||
|
{ name = "Marcus" },
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"mcp>=1.27.0,<2",
|
||||||
|
"click>=8.1",
|
||||||
|
"anyio>=4.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
mcp-sonarqube-proxy = "mcp_sonarqube_proxy.cli:main"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"ruff>=0.1",
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-asyncio>=0.23",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/mcp_sonarqube_proxy"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""CLI entry point: ``serve`` (run the stdio proxy) and ``check`` (probe upstream)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import click
|
||||||
|
from mcp import types
|
||||||
|
|
||||||
|
from mcp_sonarqube_proxy import __version__
|
||||||
|
from mcp_sonarqube_proxy.proxy import (
|
||||||
|
SERVER_NAME,
|
||||||
|
build_proxy_server,
|
||||||
|
list_tools_via,
|
||||||
|
upstream_session,
|
||||||
|
upstream_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_logging(level: str = "warning") -> None:
|
||||||
|
"""Send all logging to stderr — stdout is reserved for JSON-RPC."""
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, level.upper(), logging.WARNING),
|
||||||
|
format="%(levelname)s %(name)s: %(message)s",
|
||||||
|
stream=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stderr(msg: str) -> None:
|
||||||
|
print(msg, file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
@click.version_option(__version__, prog_name=SERVER_NAME)
|
||||||
|
def main() -> None:
|
||||||
|
"""MCP-Proxy fuer SonarQube."""
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
def serve() -> None:
|
||||||
|
"""Start the MCP proxy on stdio."""
|
||||||
|
_configure_logging("warning")
|
||||||
|
_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}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_serve() -> None:
|
||||||
|
from mcp.server.stdio import stdio_server
|
||||||
|
|
||||||
|
async with upstream_session() as upstream:
|
||||||
|
server = build_proxy_server(upstream)
|
||||||
|
_stderr(f"{SERVER_NAME}: upstream connected, serving on stdio")
|
||||||
|
async with stdio_server() as (read, write):
|
||||||
|
await server.run(read, write, server.create_initialization_options())
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
def check() -> None:
|
||||||
|
"""Probe the upstream connection and list its tools."""
|
||||||
|
_configure_logging("warning")
|
||||||
|
try:
|
||||||
|
tools = asyncio.run(_run_check())
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(click.style(f"Upstream nicht erreichbar: {e}", fg="red"), err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
click.echo(click.style(f"Upstream erreichbar — {len(tools)} Tools:", fg="green"))
|
||||||
|
for t in tools:
|
||||||
|
title = getattr(t, "title", None) or t.name
|
||||||
|
suffix = f" ({t.name})" if title != t.name else ""
|
||||||
|
click.echo(f" - {title}{suffix}: {t.description or '-'}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_check() -> list[types.Tool]:
|
||||||
|
async with upstream_session() as upstream:
|
||||||
|
return await list_tools_via(upstream)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Transparent stdio MCP proxy: forwards list_tools and call_tool to an upstream server.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from mcp import ClientSession, types
|
||||||
|
from mcp.client.streamable_http import streamablehttp_client
|
||||||
|
from mcp.server.lowlevel import Server
|
||||||
|
|
||||||
|
DEFAULT_UPSTREAM_URL = "http://192.168.0.2:8765/mcp"
|
||||||
|
SERVER_NAME = "mcp-sonarqube-proxy"
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
target = url or upstream_url()
|
||||||
|
async with streamablehttp_client(target) as (read, write, _):
|
||||||
|
async with ClientSession(read, write) as session:
|
||||||
|
await session.initialize()
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
async def list_tools_via(upstream: ClientSession) -> list[types.Tool]:
|
||||||
|
"""Fetch the upstream tool list and return it 1:1."""
|
||||||
|
result = await upstream.list_tools()
|
||||||
|
return list(result.tools)
|
||||||
|
|
||||||
|
|
||||||
|
async def call_tool_via(
|
||||||
|
upstream: ClientSession, name: str, arguments: dict[str, Any]
|
||||||
|
) -> types.CallToolResult:
|
||||||
|
"""Forward a tool call to the upstream and return the full CallToolResult."""
|
||||||
|
return await upstream.call_tool(name, arguments)
|
||||||
|
|
||||||
|
|
||||||
|
def build_proxy_server(upstream: ClientSession) -> Server:
|
||||||
|
"""Build a low-level MCP Server whose tool handlers forward to ``upstream``.
|
||||||
|
|
||||||
|
Input validation is disabled (validate_input=False): the upstream is the
|
||||||
|
authoritative schema owner, so we let it reject malformed calls rather than
|
||||||
|
duplicate validation locally.
|
||||||
|
"""
|
||||||
|
server: Server = Server(SERVER_NAME)
|
||||||
|
|
||||||
|
@server.list_tools()
|
||||||
|
async def _list_tools() -> list[types.Tool]:
|
||||||
|
return await list_tools_via(upstream)
|
||||||
|
|
||||||
|
@server.call_tool(validate_input=False)
|
||||||
|
async def _call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult:
|
||||||
|
return await call_tool_via(upstream, name, arguments)
|
||||||
|
|
||||||
|
return server
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Tests for the Click CLI: serve startup errors, check success/failure."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
from mcp import types
|
||||||
|
|
||||||
|
from mcp_sonarqube_proxy import cli
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_success_lists_tools(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
fake_tools = [
|
||||||
|
types.Tool(
|
||||||
|
name="search",
|
||||||
|
description="Search issues.",
|
||||||
|
inputSchema={"type": "object"},
|
||||||
|
),
|
||||||
|
types.Tool(
|
||||||
|
name="get_metric",
|
||||||
|
title="Project Metric",
|
||||||
|
description=None,
|
||||||
|
inputSchema={"type": "object"},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
async def fake_run_check() -> list[types.Tool]:
|
||||||
|
return fake_tools
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "_run_check", fake_run_check)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(cli.main, ["check"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "2 Tools" in result.output
|
||||||
|
assert "search" in result.output
|
||||||
|
assert "Project Metric" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_failure_exits_one_with_stderr(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
async def fake_run_check() -> list[types.Tool]:
|
||||||
|
raise ConnectionError("connection refused")
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "_run_check", fake_run_check)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(cli.main, ["check"])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Upstream nicht erreichbar" in result.stderr
|
||||||
|
assert "connection refused" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_startup_error_exits_one(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
async def fake_run_serve() -> None:
|
||||||
|
raise RuntimeError("upstream initialize failed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "_run_serve", fake_run_serve)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(cli.main, ["serve"])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "fatal" in result.stderr
|
||||||
|
assert "upstream initialize failed" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_flag() -> None:
|
||||||
|
from mcp_sonarqube_proxy import __version__
|
||||||
|
|
||||||
|
result = CliRunner().invoke(cli.main, ["--version"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert __version__ in result.output
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Tests for the proxy core: discovery, schema preservation, call forwarding."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from mcp import types
|
||||||
|
|
||||||
|
from mcp_sonarqube_proxy.proxy import (
|
||||||
|
DEFAULT_UPSTREAM_URL,
|
||||||
|
build_proxy_server,
|
||||||
|
call_tool_via,
|
||||||
|
list_tools_via,
|
||||||
|
upstream_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _full_tool() -> types.Tool:
|
||||||
|
"""A Tool with every passthrough field populated."""
|
||||||
|
return types.Tool(
|
||||||
|
name="rich_tool",
|
||||||
|
title="Rich Tool",
|
||||||
|
description="A tool with full metadata.",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string", "description": "search query"},
|
||||||
|
"limit": {"type": "integer", "minimum": 1, "default": 10},
|
||||||
|
},
|
||||||
|
"required": ["query"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
outputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"hits": {"type": "array", "items": {"type": "string"}}},
|
||||||
|
},
|
||||||
|
annotations=types.ToolAnnotations(
|
||||||
|
title="Rich Tool",
|
||||||
|
readOnlyHint=True,
|
||||||
|
idempotentHint=True,
|
||||||
|
openWorldHint=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_upstream(*, list_tools_return: Any = None, call_tool_return: Any = None) -> AsyncMock:
|
||||||
|
upstream = AsyncMock()
|
||||||
|
if list_tools_return is not None:
|
||||||
|
upstream.list_tools.return_value = list_tools_return
|
||||||
|
if call_tool_return is not None:
|
||||||
|
upstream.call_tool.return_value = call_tool_return
|
||||||
|
return upstream
|
||||||
|
|
||||||
|
|
||||||
|
def test_upstream_url_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.delenv("SONARQUBE_MCP_URL", raising=False)
|
||||||
|
assert upstream_url() == DEFAULT_UPSTREAM_URL
|
||||||
|
|
||||||
|
|
||||||
|
def test_upstream_url_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("SONARQUBE_MCP_URL", "http://example.test:9000/mcp")
|
||||||
|
assert upstream_url() == "http://example.test:9000/mcp"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_tools_via_returns_full_tool_objects() -> None:
|
||||||
|
tool = _full_tool()
|
||||||
|
upstream = _fake_upstream(list_tools_return=types.ListToolsResult(tools=[tool]))
|
||||||
|
|
||||||
|
tools = await list_tools_via(upstream)
|
||||||
|
|
||||||
|
assert len(tools) == 1
|
||||||
|
# Round-trip dump must match exactly: schemas, annotations, title all survive.
|
||||||
|
assert tools[0].model_dump(by_alias=True) == tool.model_dump(by_alias=True)
|
||||||
|
upstream.list_tools.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_tools_via_returns_empty_list() -> None:
|
||||||
|
upstream = _fake_upstream(list_tools_return=types.ListToolsResult(tools=[]))
|
||||||
|
|
||||||
|
tools = await list_tools_via(upstream)
|
||||||
|
|
||||||
|
assert tools == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_tools_via_propagates_errors() -> None:
|
||||||
|
upstream = AsyncMock()
|
||||||
|
upstream.list_tools.side_effect = RuntimeError("upstream gone")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="upstream gone"):
|
||||||
|
await list_tools_via(upstream)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_call_tool_via_forwards_full_result() -> None:
|
||||||
|
expected = types.CallToolResult(
|
||||||
|
content=[types.TextContent(type="text", text="ok")],
|
||||||
|
structuredContent={"score": 42},
|
||||||
|
isError=False,
|
||||||
|
)
|
||||||
|
upstream = _fake_upstream(call_tool_return=expected)
|
||||||
|
|
||||||
|
result = await call_tool_via(upstream, "do_thing", {"x": 1})
|
||||||
|
|
||||||
|
assert result is expected
|
||||||
|
upstream.call_tool.assert_awaited_once_with("do_thing", {"x": 1})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_call_tool_via_preserves_is_error_results() -> None:
|
||||||
|
expected = types.CallToolResult(
|
||||||
|
content=[types.TextContent(type="text", text="boom")],
|
||||||
|
isError=True,
|
||||||
|
)
|
||||||
|
upstream = _fake_upstream(call_tool_return=expected)
|
||||||
|
|
||||||
|
result = await call_tool_via(upstream, "broken", {})
|
||||||
|
|
||||||
|
assert result.isError is True
|
||||||
|
assert result.content[0].text == "boom"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_call_tool_via_propagates_errors() -> None:
|
||||||
|
upstream = AsyncMock()
|
||||||
|
upstream.call_tool.side_effect = ConnectionError("link down")
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionError, match="link down"):
|
||||||
|
await call_tool_via(upstream, "anything", {})
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_proxy_server_registers_handlers() -> None:
|
||||||
|
upstream = AsyncMock()
|
||||||
|
server = build_proxy_server(upstream)
|
||||||
|
|
||||||
|
assert types.ListToolsRequest in server.request_handlers
|
||||||
|
assert types.CallToolRequest in server.request_handlers
|
||||||
|
|
||||||
|
|
||||||
|
async def test_build_proxy_server_handler_forwards_tools_through_dispatch() -> None:
|
||||||
|
"""End-to-end: dispatch a ListToolsRequest through the registered handler."""
|
||||||
|
tool = _full_tool()
|
||||||
|
upstream = _fake_upstream(list_tools_return=types.ListToolsResult(tools=[tool]))
|
||||||
|
server = build_proxy_server(upstream)
|
||||||
|
|
||||||
|
handler = server.request_handlers[types.ListToolsRequest]
|
||||||
|
server_result = await handler(types.ListToolsRequest(method="tools/list"))
|
||||||
|
|
||||||
|
inner = server_result.root # ServerResult wraps a ListToolsResult
|
||||||
|
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)
|
||||||
Reference in New Issue
Block a user