Skip to content

Commit ba81e7f

Browse files
saqadriandrew-lastmile
authored andcommitted
create a filtered stdio transport that skips json validation errors for non-json text on stdio (lastmile-ai#600)
1 parent 4acd8a1 commit ba81e7f

File tree

3 files changed

+145
-14
lines changed

3 files changed

+145
-14
lines changed

src/mcp_agent/mcp/mcp_connection_manager.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,8 @@
1919
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
2020

2121
from mcp import ClientSession
22-
from mcp.client.stdio import (
23-
StdioServerParameters,
24-
get_default_environment,
25-
)
22+
from mcp.client.stdio import StdioServerParameters, get_default_environment
2623
from mcp.client.sse import sse_client
27-
from mcp.client.stdio import stdio_client
2824
from mcp.client.streamable_http import streamablehttp_client, MCP_SESSION_ID
2925
from mcp.client.websocket import websocket_client
3026
from mcp.types import JSONRPCMessage, ServerCapabilities
@@ -35,6 +31,7 @@
3531
from mcp_agent.logging.event_progress import ProgressAction
3632
from mcp_agent.logging.logger import get_logger
3733
from mcp_agent.mcp.mcp_agent_client_session import MCPAgentClientSession
34+
from mcp_agent.mcp.stdio_transport import filtered_stdio_client
3835
from mcp_agent.oauth.http import OAuthHttpxAuth
3936

4037
if TYPE_CHECKING:
@@ -455,8 +452,10 @@ def transport_context_factory():
455452
env={**get_default_environment(), **(config.env or {})},
456453
cwd=config.cwd or None,
457454
)
458-
# Create stdio client config with redirected stderr
459-
return stdio_client(server=server_params)
455+
# Create stdio client config with filtered stdout
456+
return filtered_stdio_client(
457+
server_name=server_name, server=server_params
458+
)
460459
elif config.transport in ["streamable_http", "streamable-http", "http"]:
461460
if session_id:
462461
headers = config.headers.copy() if config.headers else {}

src/mcp_agent/mcp/mcp_server_registry.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,7 @@
1313

1414
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
1515
from mcp import ClientSession
16-
from mcp.client.stdio import (
17-
StdioServerParameters,
18-
stdio_client,
19-
get_default_environment,
20-
)
16+
from mcp.client.stdio import StdioServerParameters, get_default_environment
2117
from mcp.client.sse import sse_client
2218
from mcp.client.streamable_http import streamablehttp_client, MCP_SESSION_ID
2319
from mcp.client.websocket import websocket_client
@@ -31,8 +27,9 @@
3127

3228
from mcp_agent.logging.logger import get_logger
3329
from mcp_agent.mcp.mcp_agent_client_session import MCPAgentClientSession
34-
from mcp_agent.oauth.http import OAuthHttpxAuth
3530
from mcp_agent.mcp.mcp_connection_manager import MCPConnectionManager
31+
from mcp_agent.mcp.stdio_transport import filtered_stdio_client
32+
from mcp_agent.oauth.http import OAuthHttpxAuth
3633

3734
if TYPE_CHECKING:
3835
from mcp_agent.core.context import Context
@@ -169,7 +166,9 @@ async def start_server(
169166
cwd=config.cwd or None,
170167
)
171168

172-
async with stdio_client(server_params) as (read_stream, write_stream):
169+
async with filtered_stdio_client(
170+
server_name=server_name, server=server_params
171+
) as (read_stream, write_stream):
173172
# Construct session; tolerate factories that don't accept 'context'
174173
try:
175174
session = client_session_factory(
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""
2+
Utilities for working with stdio-based MCP transports.
3+
4+
In MCP 1.19 the stdio client started forwarding JSON parsing errors from the
5+
server's stdout stream as exceptions on the transport. Many MCP servers still
6+
emit setup logs on stdout (e.g. package managers), which now surface as noisy
7+
tracebacks for every log line. This module wraps the upstream stdio transport
8+
and filters out clearly non-JSON stdout lines so that normal logging output
9+
does not bubble up as transport errors.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from contextlib import asynccontextmanager
15+
from typing import AsyncGenerator, Iterable
16+
17+
import anyio
18+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
19+
from pydantic import ValidationError
20+
21+
from mcp.client.stdio import StdioServerParameters, stdio_client
22+
from mcp.shared.message import SessionMessage
23+
24+
from mcp_agent.logging.logger import get_logger
25+
26+
logger = get_logger(__name__)
27+
28+
# JSON-RPC messages should always be JSON objects, but we keep literal checks
29+
# to stay conservative if upstream ever sends arrays or literals.
30+
_LITERAL_PREFIXES: tuple[str, ...] = ("true", "false", "null")
31+
_MESSAGE_START_CHARS = {"{", "["}
32+
33+
34+
def _should_ignore_exception(exc: Exception) -> bool:
35+
"""
36+
Returns True when the exception represents a non-JSON stdout line that we can
37+
safely drop.
38+
"""
39+
if not isinstance(exc, ValidationError):
40+
return False
41+
42+
errors: Iterable[dict] = exc.errors()
43+
first = next(iter(errors), None)
44+
if not first or first.get("type") != "json_invalid":
45+
return False
46+
47+
input_value = first.get("input")
48+
if not isinstance(input_value, str):
49+
return False
50+
51+
stripped = input_value.strip()
52+
if not stripped:
53+
return True
54+
55+
first_char = stripped[0]
56+
lowered = stripped.lower()
57+
if first_char in _MESSAGE_START_CHARS or any(
58+
lowered.startswith(prefix) for prefix in _LITERAL_PREFIXES
59+
):
60+
# Likely a legitimate JSON payload; don't swallow
61+
return False
62+
63+
return True
64+
65+
66+
def _truncate(value: str, length: int = 120) -> str:
67+
"""
68+
Truncate long log lines so debug output remains readable.
69+
"""
70+
if len(value) <= length:
71+
return value
72+
return value[: length - 3] + "..."
73+
74+
75+
@asynccontextmanager
76+
async def filtered_stdio_client(
77+
server_name: str, server: StdioServerParameters
78+
) -> AsyncGenerator[
79+
tuple[
80+
MemoryObjectReceiveStream[SessionMessage | Exception],
81+
MemoryObjectSendStream[SessionMessage],
82+
],
83+
None,
84+
]:
85+
"""
86+
Wrap the upstream stdio_client so obviously non-JSON stdout lines are filtered.
87+
"""
88+
async with stdio_client(server=server) as (read_stream, write_stream):
89+
filtered_send, filtered_recv = anyio.create_memory_object_stream[
90+
SessionMessage | Exception
91+
](0)
92+
93+
async def _forward_stdout() -> None:
94+
try:
95+
async with read_stream:
96+
async for item in read_stream:
97+
if isinstance(item, Exception) and _should_ignore_exception(
98+
item
99+
):
100+
try:
101+
errors = item.errors() # type: ignore[attr-defined]
102+
offending = errors[0].get("input", "") if errors else ""
103+
except Exception:
104+
offending = ""
105+
if offending:
106+
logger.debug(
107+
"%s: ignoring non-JSON stdout: %s",
108+
server_name,
109+
_truncate(str(offending)),
110+
)
111+
else:
112+
logger.debug(
113+
"%s: ignoring non-JSON stdout (unable to capture)",
114+
server_name,
115+
)
116+
continue
117+
118+
try:
119+
await filtered_send.send(item)
120+
except anyio.ClosedResourceError:
121+
break
122+
except anyio.ClosedResourceError:
123+
# Consumer closed; nothing else to forward
124+
pass
125+
finally:
126+
await filtered_send.aclose()
127+
128+
async with anyio.create_task_group() as tg:
129+
tg.start_soon(_forward_stdout)
130+
try:
131+
yield filtered_recv, write_stream
132+
finally:
133+
tg.cancel_scope.cancel()

0 commit comments

Comments
 (0)