feat(stdio): harden local MCP transport and add end-to-end tests

Reserve stdout for the JSON-RPC stream: _configure_stderr_logging() pins all
logging to stderr (and rewrites any stray stdout handler) so a log line can
never corrupt the stdio protocol. Extract a pure, testable build_server() from
_serve(). Add end-to-end tests over the mcp in-memory transport (initialize +
tools/list + tools/call), covering a successful round trip and a policy denial
surfaced as an MCP error.
This commit is contained in:
2026-06-27 15:19:42 +02:00
parent f660fa32c1
commit 5d4a98d06e
2 changed files with 117 additions and 13 deletions
+47 -13
View File
@@ -20,6 +20,7 @@ log all run exactly as they do on the server. The same tools (including
from __future__ import annotations
import asyncio
import logging
import os
import sys
from pathlib import Path
@@ -165,24 +166,36 @@ async def _dispatch(tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]
return result
async def _serve() -> None:
"""Build the stdio MCP server from the shared registry and serve it."""
def _configure_stderr_logging() -> None:
"""Pin all logging to stderr so the stdout JSON-RPC channel stays clean.
The stdio MCP transport speaks JSON-RPC over stdout; a single stray log line
on stdout corrupts the stream and breaks the client. ``configure_logging``
already targets stderr, but we additionally rewrite any handler that points
at stdout (e.g. a library that called ``basicConfig``) so nothing can leak.
"""
from aegis_gitea_mcp.config import get_settings
from aegis_gitea_mcp.logging_utils import configure_logging
configure_logging(get_settings().log_level)
root = logging.getLogger()
for handler in root.handlers:
if isinstance(handler, logging.StreamHandler) and handler.stream is sys.stdout:
handler.setStream(sys.stderr)
def build_server() -> Any:
"""Build (but do not run) the stdio MCP ``Server`` from the shared registry.
Kept separate from :func:`_serve` so it can be driven in-process by tests
over an in-memory transport without opening real stdio streams.
"""
import mcp.types as mcp_types
from mcp.server import Server
from mcp.server.stdio import stdio_server
from aegis_gitea_mcp.config import get_settings
from aegis_gitea_mcp.policy import get_policy_engine
from aegis_gitea_mcp.registry import list_tool_definitions
# Fail fast on bad settings/policy before opening the transport.
get_settings()
get_policy_engine()
global _owner_login
_owner_login = await _resolve_owner_login()
server: Server = Server("aegis-gitea-mcp")
server: Any = Server("aegis-gitea-mcp")
@server.list_tools()
async def list_tools() -> list[mcp_types.Tool]:
@@ -200,6 +213,24 @@ async def _serve() -> None:
# Returning a dict yields structured content plus a JSON text block.
return await _dispatch(name, arguments)
return server
async def _serve() -> None:
"""Resolve identity and serve the stdio MCP server over real stdin/stdout."""
from mcp.server.stdio import stdio_server
from aegis_gitea_mcp.config import get_settings
from aegis_gitea_mcp.policy import get_policy_engine
# Fail fast on bad settings/policy before opening the transport.
get_settings()
get_policy_engine()
global _owner_login
_owner_login = await _resolve_owner_login()
server = build_server()
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, server.create_initialization_options())
@@ -221,6 +252,9 @@ def main() -> None:
print(f"aegis-gitea-mcp: invalid configuration: {exc}", file=sys.stderr)
raise SystemExit(2) from exc
# Keep stdout reserved for the JSON-RPC stream; all logs go to stderr.
_configure_stderr_logging()
try:
asyncio.run(_serve())
except StdioConfigError as exc: