feat: add MCP server core, stdio and streamable HTTP transports and serve command
Low-level SDK server built from the catalog plus gitea_api_request, with TOOLSETS/READ_ONLY/DISABLED_TOOLS filters, schema validation without value echo, one audit event per call, static bearer verifier, identity resolver hook, /health, DNS-rebinding protection, rate limiting and fail-closed startup checks.
This commit is contained in:
1 parent
581ca67bf8
commit
c9e2c481e7
11 files changed
+2846
-14
No files matched your search
@@ -0,0 +1,450 @@
|
||||
"""Tests for the streamable HTTP app: health, auth, DNS-rebinding, rate limiting, MCP e2e."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
import pytest
|
||||
from mcp import Client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
from pytest_httpx import HTTPXMock
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from aegis_gitea_mcp import __version__
|
||||
from aegis_gitea_mcp.auth import STATIC_CLIENT_ID
|
||||
from aegis_gitea_mcp.config import Settings
|
||||
from aegis_gitea_mcp.gitea import RAW_TOOL_NAME
|
||||
from aegis_gitea_mcp.identity import Identity
|
||||
from aegis_gitea_mcp.transports import (
|
||||
GiteaProbe,
|
||||
build_http_app,
|
||||
transport_security,
|
||||
)
|
||||
from tests.test_server_helpers import (
|
||||
ANY_HOST,
|
||||
API,
|
||||
BEARER_TOKEN,
|
||||
GITEA_TOKEN,
|
||||
mock_user,
|
||||
server_settings,
|
||||
)
|
||||
|
||||
BASE = "http://127.0.0.1:8080"
|
||||
INITIALIZE = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "test", "version": "0"},
|
||||
},
|
||||
}
|
||||
MCP_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _env(mock_env: None) -> None:
|
||||
"""The audit logger and rate limiter read GITEA_URL from the environment."""
|
||||
|
||||
|
||||
def http_settings(**overrides: Any) -> Settings:
|
||||
"""Settings with a bearer token on port 8080."""
|
||||
values: dict[str, Any] = {"mcp_bearer_token": BEARER_TOKEN, "mcp_port": 8080}
|
||||
values.update(overrides)
|
||||
return server_settings(**values)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(request: pytest.FixtureRequest) -> Iterator[TestClient]:
|
||||
settings = http_settings()
|
||||
with TestClient(build_http_app(settings), base_url=BASE) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def auth(token: str = BEARER_TOKEN) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# Health and index
|
||||
|
||||
|
||||
def test_health_ok_uses_the_admin_token_and_is_cached(
|
||||
client: TestClient, httpx_mock: HTTPXMock
|
||||
) -> None:
|
||||
httpx_mock.add_response(url=f"{API}/version", json={"version": "1.22"})
|
||||
|
||||
first = client.get("/health")
|
||||
second = client.get("/health")
|
||||
|
||||
assert first.status_code == second.status_code == 200
|
||||
assert first.json() == {
|
||||
"status": "ok",
|
||||
"version": __version__,
|
||||
"tools": 538,
|
||||
"gitea": "reachable",
|
||||
}
|
||||
requests = httpx_mock.get_requests()
|
||||
assert len(requests) == 1 # 10 second cache
|
||||
assert requests[0].headers["Authorization"] == f"token {GITEA_TOKEN}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"outcome",
|
||||
[
|
||||
httpx.Response(500, text="gitea exploded at https://gitea.example.com " + GITEA_TOKEN),
|
||||
httpx.Response(302, headers={"Location": "https://elsewhere.example/"}),
|
||||
httpx.ConnectError("connection refused to https://gitea.example.com"),
|
||||
httpx.ReadTimeout("slow"),
|
||||
],
|
||||
)
|
||||
def test_health_degraded_leaks_nothing(
|
||||
client: TestClient, httpx_mock: HTTPXMock, outcome: httpx.Response | Exception
|
||||
) -> None:
|
||||
if isinstance(outcome, Exception):
|
||||
httpx_mock.add_exception(outcome)
|
||||
else:
|
||||
httpx_mock.add_response(
|
||||
status_code=outcome.status_code, headers=outcome.headers, content=outcome.content
|
||||
)
|
||||
|
||||
response = client.get("/health")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"status": "degraded", "gitea": "unreachable"}
|
||||
assert "gitea.example.com" not in response.text and GITEA_TOKEN not in response.text
|
||||
|
||||
|
||||
def test_health_probe_without_a_token_is_anonymous(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(url=f"{API}/version", json={})
|
||||
settings = http_settings(gitea_token=None)
|
||||
|
||||
with TestClient(build_http_app(settings), base_url=BASE) as test_client:
|
||||
assert test_client.get("/health").status_code == 200
|
||||
|
||||
assert "Authorization" not in httpx_mock.get_requests()[0].headers
|
||||
|
||||
|
||||
def test_health_recovers_after_the_cache_expires(
|
||||
client: TestClient, httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from aegis_gitea_mcp import transports
|
||||
|
||||
clock = [100.0]
|
||||
monkeypatch.setattr(transports.time, "monotonic", lambda: clock[0])
|
||||
httpx_mock.add_exception(httpx.ConnectError("down"))
|
||||
httpx_mock.add_response(url=f"{API}/version", json={})
|
||||
|
||||
assert client.get("/health").status_code == 503
|
||||
assert client.get("/health").status_code == 503 # still cached
|
||||
clock[0] += 11
|
||||
assert client.get("/health").status_code == 200
|
||||
|
||||
|
||||
def test_health_and_index_need_no_token(client: TestClient, httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(url=f"{API}/version", json={})
|
||||
|
||||
assert client.get("/").json() == {
|
||||
"name": "aegis-gitea-mcp",
|
||||
"version": __version__,
|
||||
"mcp_endpoint": "/mcp",
|
||||
}
|
||||
assert client.get("/health").status_code == 200
|
||||
|
||||
|
||||
async def test_probe_closes_only_its_own_client() -> None:
|
||||
settings = http_settings()
|
||||
own = GiteaProbe(settings)
|
||||
await own.aclose()
|
||||
assert own._client.is_closed
|
||||
|
||||
shared = httpx.AsyncClient()
|
||||
borrowed = GiteaProbe(settings, shared)
|
||||
await borrowed.aclose()
|
||||
assert not shared.is_closed
|
||||
await shared.aclose()
|
||||
|
||||
|
||||
# Authentication
|
||||
|
||||
|
||||
def test_mcp_without_token_is_401_with_resource_metadata(client: TestClient) -> None:
|
||||
response = client.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
|
||||
|
||||
assert response.status_code == 401
|
||||
challenge = response.headers["WWW-Authenticate"]
|
||||
assert challenge.startswith("Bearer ")
|
||||
assert (
|
||||
'resource_metadata="http://127.0.0.1:8080/.well-known/oauth-protected-resource/mcp"'
|
||||
in challenge
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token", ["wrong", BEARER_TOKEN + "x", ""])
|
||||
def test_mcp_with_wrong_token_is_401(client: TestClient, token: str) -> None:
|
||||
response = client.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth(token)})
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "resource_metadata" in response.headers["WWW-Authenticate"]
|
||||
|
||||
|
||||
def test_mcp_initialize_with_the_right_token(client: TestClient) -> None:
|
||||
response = client.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth()})
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
assert "aegis-gitea-mcp" in body
|
||||
assert BEARER_TOKEN not in body
|
||||
|
||||
|
||||
def test_protected_resource_metadata_names_the_mcp_endpoint(client: TestClient) -> None:
|
||||
response = client.get("/.well-known/oauth-protected-resource/mcp")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["resource"] == "http://127.0.0.1:8080/mcp"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def sdk_client(app: Any, token: str | None = BEARER_TOKEN) -> AsyncIterator[Client]:
|
||||
"""The SDK client talking to ``app`` over an in-process ASGI transport."""
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
||||
async with (
|
||||
app.router.lifespan_context(app),
|
||||
httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app), headers=headers, base_url=BASE
|
||||
) as http_client,
|
||||
Client(streamable_http_client(f"{BASE}/mcp", http_client=http_client)) as c,
|
||||
):
|
||||
yield c
|
||||
|
||||
|
||||
async def test_sdk_client_lists_and_calls_tools_as_the_admin(httpx_mock: HTTPXMock) -> None:
|
||||
mock_user(httpx_mock, "admin")
|
||||
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={"full_name": "octo/demo"})
|
||||
app = build_http_app(http_settings())
|
||||
|
||||
async with sdk_client(app) as c:
|
||||
assert len((await c.list_tools()).tools) == 538
|
||||
result = await c.call_tool("gitea_repo_get", {"owner": "octo", "repo": "demo"})
|
||||
|
||||
assert not result.is_error
|
||||
payload = json.loads(result.content[0].text) # type: ignore[union-attr]
|
||||
assert payload["identity"] == {"login": "admin", "mode": "admin-token", "sudo": None}
|
||||
assert payload["data"] == {"full_name": "octo/demo"}
|
||||
|
||||
|
||||
async def test_access_token_reaches_the_identity_resolver() -> None:
|
||||
seen: list[AccessToken | None] = []
|
||||
|
||||
class Spy:
|
||||
async def resolve(self, access: AccessToken | None) -> Identity:
|
||||
seen.append(access)
|
||||
return Identity(login="spy", mode="admin-token", token=GITEA_TOKEN)
|
||||
|
||||
from aegis_gitea_mcp.server import build_server
|
||||
|
||||
settings = http_settings()
|
||||
app = build_http_app(settings, server=build_server(settings, resolver=Spy()))
|
||||
|
||||
async with sdk_client(app) as c:
|
||||
await c.call_tool("gitea_repo_get", {"owner": "bad owner", "repo": "x"}) # invalid
|
||||
await c.call_tool(RAW_TOOL_NAME, {"method": "GET", "path": "/version"})
|
||||
|
||||
assert len(seen) == 1 and seen[0] is not None
|
||||
assert seen[0].client_id == STATIC_CLIENT_ID
|
||||
assert BEARER_TOKEN not in repr(seen[0])
|
||||
|
||||
|
||||
# DNS rebinding and origin
|
||||
|
||||
|
||||
def test_bad_host_header_is_rejected(httpx_mock: HTTPXMock) -> None:
|
||||
with TestClient(build_http_app(http_settings()), base_url="http://evil.example:8080") as c:
|
||||
response = c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth()})
|
||||
|
||||
assert response.status_code == 421
|
||||
|
||||
|
||||
def test_bad_origin_is_rejected(client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth(), "Origin": "https://evil.example"}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_public_host_is_allowed_when_configured() -> None:
|
||||
settings = http_settings(public_base_url="https://mcp.example.com")
|
||||
with TestClient(build_http_app(settings), base_url="https://mcp.example.com") as c:
|
||||
ok = c.post(
|
||||
"/mcp",
|
||||
json=INITIALIZE,
|
||||
headers={**MCP_HEADERS, **auth(), "Origin": "https://mcp.example.com"},
|
||||
)
|
||||
metadata = c.get("/.well-known/oauth-protected-resource/mcp")
|
||||
challenge = c.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
|
||||
|
||||
assert ok.status_code == 200
|
||||
assert metadata.json()["resource"] == "https://mcp.example.com/mcp"
|
||||
assert (
|
||||
"https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
|
||||
in (challenge.headers["WWW-Authenticate"])
|
||||
)
|
||||
|
||||
|
||||
def test_transport_security_hosts_and_origins() -> None:
|
||||
settings = http_settings(
|
||||
mcp_host="192.168.1.5",
|
||||
public_base_url="https://mcp.example.com:8443/",
|
||||
allowed_hosts=["proxy.internal:9000"],
|
||||
)
|
||||
|
||||
security = transport_security(settings)
|
||||
|
||||
assert security.enable_dns_rebinding_protection is True
|
||||
assert security.allowed_hosts == [
|
||||
"127.0.0.1:8080",
|
||||
"localhost:8080",
|
||||
"[::1]:8080",
|
||||
"192.168.1.5:8080",
|
||||
"mcp.example.com:8443",
|
||||
"mcp.example.com",
|
||||
"proxy.internal:9000",
|
||||
]
|
||||
assert "https://mcp.example.com:8443" in security.allowed_origins
|
||||
assert "https://proxy.internal:9000" in security.allowed_origins
|
||||
assert "http://127.0.0.1:8080" in security.allowed_origins
|
||||
|
||||
|
||||
def test_transport_security_wildcard_bind_adds_no_bind_host() -> None:
|
||||
security = transport_security(http_settings(mcp_host=ANY_HOST))
|
||||
|
||||
assert security.allowed_hosts == ["127.0.0.1:8080", "localhost:8080", "[::1]:8080"]
|
||||
|
||||
|
||||
# Rate limiting
|
||||
|
||||
|
||||
def test_rate_limit_returns_429_on_the_mcp_path_only(
|
||||
httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# The limiter reads the process-wide settings.
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MINUTE", "2")
|
||||
httpx_mock.add_response(url=f"{API}/version", json={}, is_reusable=True)
|
||||
settings = http_settings(rate_limit_per_minute=2)
|
||||
with TestClient(build_http_app(settings), base_url=BASE) as c:
|
||||
statuses = [
|
||||
c.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS).status_code for _ in range(3)
|
||||
]
|
||||
limited = c.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
|
||||
health = c.get("/health")
|
||||
|
||||
assert statuses == [401, 401, 429]
|
||||
assert limited.status_code == 429
|
||||
assert limited.json()["error"]["kind"] == "rate_limited"
|
||||
assert limited.headers["Retry-After"] == "60"
|
||||
assert health.status_code == 200
|
||||
|
||||
|
||||
def test_rate_limit_is_keyed_per_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MINUTE", "1")
|
||||
settings = http_settings(rate_limit_per_minute=1)
|
||||
with TestClient(build_http_app(settings), base_url=BASE) as c:
|
||||
first = c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth()})
|
||||
|
||||
assert first.status_code == 200
|
||||
from aegis_gitea_mcp.rate_limit import get_rate_limiter
|
||||
|
||||
limiter = get_rate_limiter()
|
||||
assert limiter._token_limiter.max_requests == 1
|
||||
# The raw header is never used as a key, only its hash.
|
||||
keys = list(limiter._token_limiter._events)
|
||||
assert keys and all(BEARER_TOKEN not in key for key in keys)
|
||||
|
||||
|
||||
# Auth wiring
|
||||
|
||||
|
||||
def test_no_auth_on_loopback_works_and_warns(caplog: pytest.LogCaptureFixture) -> None:
|
||||
settings = http_settings(mcp_bearer_token=None)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
app = build_http_app(settings)
|
||||
with TestClient(app, base_url=BASE) as c:
|
||||
response = c.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
|
||||
metadata = c.get("/.well-known/oauth-protected-resource/mcp")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert metadata.status_code == 404
|
||||
assert "WITHOUT authentication" in caplog.text
|
||||
|
||||
|
||||
def test_extra_verifier_is_combined_with_the_static_bearer() -> None:
|
||||
class Other:
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
if token == "oauth-token":
|
||||
return AccessToken(token="t", client_id="oauth-client", scopes=[])
|
||||
return None
|
||||
|
||||
app = build_http_app(http_settings(), token_verifier=Other())
|
||||
with TestClient(app, base_url=BASE) as c:
|
||||
static = c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth()})
|
||||
oauth = c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth("oauth-token")})
|
||||
denied = c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth("nope")})
|
||||
|
||||
assert (static.status_code, oauth.status_code, denied.status_code) == (200, 200, 401)
|
||||
|
||||
|
||||
def test_only_an_extra_verifier_protects_the_endpoint() -> None:
|
||||
class Other:
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
return None
|
||||
|
||||
app = build_http_app(http_settings(mcp_bearer_token=None), token_verifier=Other())
|
||||
with TestClient(app, base_url=BASE) as c:
|
||||
assert c.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS).status_code == 401
|
||||
|
||||
|
||||
def test_auth_provider_without_a_verifier_is_refused() -> None:
|
||||
with pytest.raises(ValueError, match="token verifier"):
|
||||
build_http_app(
|
||||
http_settings(mcp_bearer_token=None),
|
||||
auth_server_provider=object(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def test_extra_routes_are_served_without_mcp_auth() -> None:
|
||||
from starlette.responses import PlainTextResponse
|
||||
|
||||
async def callback(request: Any) -> PlainTextResponse:
|
||||
return PlainTextResponse("ok")
|
||||
|
||||
app = build_http_app(http_settings(), extra_routes=[Route("/oauth/callback", callback)])
|
||||
with TestClient(app, base_url=BASE) as c:
|
||||
assert c.get("/oauth/callback").text == "ok"
|
||||
|
||||
|
||||
def test_request_body_cap_admits_a_full_size_upload() -> None:
|
||||
settings = http_settings(max_binary_bytes=5_242_880)
|
||||
app = build_http_app(settings)
|
||||
with TestClient(app, base_url=BASE) as c:
|
||||
padding = "x" * (6 * 1024 * 1024)
|
||||
body = {**INITIALIZE, "params": {**INITIALIZE["params"], "padding": padding}}
|
||||
response = c.post("/mcp", json=body, headers={**MCP_HEADERS, **auth()})
|
||||
|
||||
assert response.status_code != 413
|
||||
|
||||
small = build_http_app(http_settings(max_binary_bytes=1))
|
||||
with TestClient(small, base_url=BASE) as c:
|
||||
rejected = c.post("/mcp", json=body, headers={**MCP_HEADERS, **auth()})
|
||||
assert rejected.status_code == 413
|
||||
Reference in new issue
Block a user