Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions keycloak_mcp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
59 changes: 59 additions & 0 deletions tests/test_stdio_smoke.py
Original file line number Diff line number Diff line change
@@ -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