Skip to content

Commit b226251

Browse files
authored
fix(mcp): redirect stdout to stderr during import to protect JSON-RPC channel (#225) (#864)
* fix(mcp): redirect stdout to stderr during import to protect JSON-RPC channel (#225) Fixes #225. Several transitive dependencies (chromadb, onnxruntime, posthog) print banners and warnings to stdout — sometimes at the C level — during the mcp_server import chain. Because the MCP protocol multiplexes JSON-RPC over stdio, any non-JSON output on stdout corrupted the message stream and broke Claude Desktop's parser with errors like: MCP mempalace: Unexpected token '*', "**********"... is not valid JSON MCP mempalace: Unexpected token 'E', "EP Error D"... is not valid JSON MCP mempalace: Unexpected token 'F', "Falling ba"... is not valid JSON Reproduced on Windows 11 with mempalace 3.0.0 / Python 3.10 / Claude Desktop 1.1062.0. Fix: at module load, redirect stdout to stderr at both the Python level (sys.stdout = sys.stderr) and the file-descriptor level (os.dup2(2, 1)) to catch C-level prints, while preserving the real stdout for later restore. main() calls _restore_stdout() right before entering the protocol loop so JSON-RPC responses still go to the real stdout. Adds tests/test_mcp_stdio_protection.py with three regression tests: - module-level redirect is in place after import - _restore_stdout() restores the original stdout (idempotent) - 'python -m mempalace.mcp_server' with empty stdin emits no stdout * style: reformat with ruff 0.4 (CI version) for #225
1 parent 0aee6f3 commit b226251

2 files changed

Lines changed: 137 additions & 15 deletions

File tree

mempalace/mcp_server.py

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,47 @@
2020
mempalace_reconnect — force cache invalidation and reconnect after external writes
2121
"""
2222

23-
import argparse
2423
import os
2524
import sys
26-
import json
27-
import logging
28-
import hashlib
29-
import time
30-
from datetime import datetime
31-
from pathlib import Path
32-
33-
from .config import MempalaceConfig, sanitize_kg_value, sanitize_name, sanitize_content
34-
from .version import __version__
35-
from .backends.chroma import ChromaBackend, ChromaCollection
36-
from .query_sanitizer import sanitize_query
37-
from .searcher import search_memories
38-
from .palace_graph import (
25+
26+
# --- MCP stdio protection (issue #225) -----------------------------------
27+
# The MCP protocol multiplexes JSON-RPC over stdio: stdout MUST carry only
28+
# valid JSON-RPC messages, stderr is for human-readable logs. Some
29+
# transitive dependencies (chromadb → onnxruntime, posthog telemetry) print
30+
# banners and error messages directly to stdout — sometimes at C level —
31+
# which breaks Claude Desktop's JSON parser. Redirect stdout → stderr at
32+
# both the Python and file-descriptor level before heavy imports, then
33+
# restore the real stdout in main() before entering the protocol loop.
34+
_REAL_STDOUT = sys.stdout
35+
_REAL_STDOUT_FD = None
36+
try:
37+
_REAL_STDOUT_FD = os.dup(1)
38+
os.dup2(2, 1)
39+
except (OSError, AttributeError):
40+
# Environments without fd-level stdio (embedded interpreters, some test
41+
# harnesses). The Python-level redirect below still applies.
42+
pass
43+
sys.stdout = sys.stderr
44+
45+
import argparse # noqa: E402 (deferred until after stdio protection above)
46+
import json # noqa: E402
47+
import logging # noqa: E402
48+
import hashlib # noqa: E402
49+
import time # noqa: E402
50+
from datetime import datetime # noqa: E402
51+
from pathlib import Path # noqa: E402
52+
53+
from .config import ( # noqa: E402
54+
MempalaceConfig,
55+
sanitize_kg_value,
56+
sanitize_name,
57+
sanitize_content,
58+
)
59+
from .version import __version__ # noqa: E402
60+
from .backends.chroma import ChromaBackend, ChromaCollection # noqa: E402
61+
from .query_sanitizer import sanitize_query # noqa: E402
62+
from .searcher import search_memories # noqa: E402
63+
from .palace_graph import ( # noqa: E402
3964
traverse,
4065
find_tunnels,
4166
graph_stats,
@@ -45,7 +70,7 @@
4570
follow_tunnels,
4671
)
4772

48-
from .knowledge_graph import KnowledgeGraph
73+
from .knowledge_graph import KnowledgeGraph # noqa: E402
4974

5075
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stderr)
5176
logger = logging.getLogger("mempalace_mcp")
@@ -1645,7 +1670,21 @@ def handle_request(request):
16451670
}
16461671

16471672

1673+
def _restore_stdout():
1674+
"""Restore real stdout for MCP JSON-RPC output (see issue #225)."""
1675+
global _REAL_STDOUT, _REAL_STDOUT_FD
1676+
if _REAL_STDOUT_FD is not None:
1677+
try:
1678+
os.dup2(_REAL_STDOUT_FD, 1)
1679+
os.close(_REAL_STDOUT_FD)
1680+
except OSError:
1681+
pass
1682+
_REAL_STDOUT_FD = None
1683+
sys.stdout = _REAL_STDOUT
1684+
1685+
16481686
def main():
1687+
_restore_stdout()
16491688
logger.info("MemPalace MCP Server starting...")
16501689
while True:
16511690
try:

tests/test_mcp_stdio_protection.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Regression tests for issue #225 — MCP stdio protection.
2+
3+
The MCP protocol multiplexes JSON-RPC over stdio. Stdout MUST carry only
4+
valid JSON-RPC messages. Several transitive deps (chromadb → onnxruntime,
5+
posthog telemetry) print banners and warnings to stdout — sometimes at
6+
the C level — which broke Claude Desktop's JSON parser on Windows.
7+
8+
The fix in mcp_server.py redirects stdout → stderr at both the Python
9+
and file-descriptor level during module import, then restores the real
10+
stdout in main() before entering the protocol loop.
11+
"""
12+
13+
import subprocess
14+
import sys
15+
import textwrap
16+
17+
18+
def test_module_import_redirects_stdout_to_stderr():
19+
"""At import time, sys.stdout must point at sys.stderr so any stray
20+
print() from a transitive dependency is sent to stderr."""
21+
code = textwrap.dedent(
22+
"""
23+
import sys
24+
original_stdout = sys.stdout
25+
from mempalace import mcp_server
26+
assert sys.stdout is sys.stderr, (
27+
f"Expected sys.stdout to be redirected to sys.stderr, "
28+
f"got: {sys.stdout!r}"
29+
)
30+
assert mcp_server._REAL_STDOUT is original_stdout, (
31+
"mcp_server._REAL_STDOUT must hold the original stdout"
32+
)
33+
print("OK", file=sys.stderr)
34+
"""
35+
)
36+
result = subprocess.run(
37+
[sys.executable, "-c", code],
38+
capture_output=True,
39+
timeout=60,
40+
)
41+
assert result.returncode == 0, f"stdout: {result.stdout!r}\nstderr: {result.stderr!r}"
42+
43+
44+
def test_restore_stdout_returns_real_stdout():
45+
"""_restore_stdout() must reassign sys.stdout to the original handle
46+
so main() can write JSON-RPC responses to the real stdout."""
47+
code = textwrap.dedent(
48+
"""
49+
import sys
50+
original_stdout = sys.stdout
51+
from mempalace import mcp_server
52+
assert sys.stdout is sys.stderr
53+
mcp_server._restore_stdout()
54+
assert sys.stdout is original_stdout, (
55+
f"After _restore_stdout(), sys.stdout must be the original; "
56+
f"got: {sys.stdout!r}"
57+
)
58+
mcp_server._restore_stdout() # idempotent
59+
print("OK", file=sys.stderr)
60+
"""
61+
)
62+
result = subprocess.run(
63+
[sys.executable, "-c", code],
64+
capture_output=True,
65+
timeout=60,
66+
)
67+
assert result.returncode == 0, f"stdout: {result.stdout!r}\nstderr: {result.stderr!r}"
68+
69+
70+
def test_mcp_server_no_stdout_noise_on_clean_exit():
71+
"""`python -m mempalace.mcp_server` with empty stdin must produce
72+
nothing on stdout. Empty input → readline() returns '' → main()
73+
breaks out cleanly. Any stdout content here would corrupt the
74+
JSON-RPC stream in real use."""
75+
proc = subprocess.run(
76+
[sys.executable, "-m", "mempalace.mcp_server"],
77+
input=b"",
78+
capture_output=True,
79+
timeout=60,
80+
)
81+
assert (
82+
proc.stdout == b""
83+
), f"stdout must be empty before the first JSON-RPC response, but got: {proc.stdout!r}"

0 commit comments

Comments
 (0)