diff --git a/keycloak_mcp/__main__.py b/keycloak_mcp/__main__.py index acb5172..255aaf4 100644 --- a/keycloak_mcp/__main__.py +++ b/keycloak_mcp/__main__.py @@ -57,6 +57,35 @@ def main() -> None: if args.check: sys.exit(_check_config()) + # On Windows, the MCP SDK creates TextIOWrapper(sys.stdout.buffer) with the + # default newline=None, which translates \n → \r\n and corrupts the NDJSON + # wire format (modelcontextprotocol/python-sdk#2433). + # Intercept writes at the RawIOBase level to strip \r\n → \n, then rebuild + # sys.stdout so that the SDK's sys.stdout.buffer access gets our wrapper. + if sys.platform == "win32": + import io + + class _CRStripper(io.RawIOBase): + """Strip \\r\\n → \\n inserted by TextIOWrapper on Windows.""" + + def __init__(self, fd: int) -> None: + self._fd = fd + + def writable(self) -> bool: + return True + + def write(self, b: bytes | bytearray | memoryview) -> int: # type: ignore[override] + data = bytes(b).replace(b"\r\n", b"\n") + os.write(self._fd, data) + return len(b) + + def fileno(self) -> int: + return self._fd + + sys.stdout.flush() + _buf = io.BufferedWriter(_CRStripper(sys.stdout.fileno())) + sys.stdout = io.TextIOWrapper(_buf, encoding="utf-8", errors="replace", newline="\n") + try: mcp.run(transport="stdio") except KeyboardInterrupt: diff --git a/tests/test_stdio_smoke.py b/tests/test_stdio_smoke.py new file mode 100644 index 0000000..5f666b2 --- /dev/null +++ b/tests/test_stdio_smoke.py @@ -0,0 +1,59 @@ +"""Stdio wire-format smoke test. + +Spawns keycloak-mcp as a subprocess, sends a JSON-RPC ``initialize`` +request, and verifies that the response line is terminated with LF (\\n) +not CRLF (\\r\\n). Guards against modelcontextprotocol/python-sdk#2433 +where ``stdio_server()`` emitted CRLF on Windows and corrupted the +NDJSON wire format. + +No Keycloak connection is required — ``initialize`` is handled entirely +by the MCP framework before any tool dispatch. +""" + +import json +import subprocess +import sys + +_INITIALIZE = ( + json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "smoke-test", "version": "0"}, + }, + } + ) + + "\n" +) + + +def test_stdio_lf_not_crlf(): + """Response lines must be LF-terminated, not CRLF (guards against sdk#2433).""" + proc = subprocess.Popen( + [sys.executable, "-m", "keycloak_mcp"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + stdout, _ = proc.communicate(input=_INITIALIZE.encode(), timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + raise AssertionError("keycloak-mcp did not respond within 10 seconds") + + assert stdout, "keycloak-mcp produced no stdout output" + + first_nl = stdout.find(b"\n") + assert first_nl != -1, "No newline found in response" + + line = stdout[:first_nl] + assert not line.endswith(b"\r"), f"Response line ends with CR (CRLF): {line!r}" + + response = json.loads(line) + assert response.get("jsonrpc") == "2.0" + assert "id" in response