Skip to content

Commit cc4549c

Browse files
authored
Merge pull request #101 from universal-tool-calling-protocol/fix/mcp-quiet-child-stderr
mcp: quiet stdio child stderr by default, tighten structuredContent unwrap
2 parents 228d0a3 + b824c07 commit cc4549c

3 files changed

Lines changed: 138 additions & 10 deletions

File tree

‎plugins/communication_protocols/mcp/README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,16 @@ except TimeoutError:
187187
print("MCP server connection timed out")
188188
```
189189

190+
### Child Process stderr
191+
192+
Stdio MCP servers often write banners, telemetry notices and auth chatter to stderr, multiplied by every server you federate. `utcp-mcp` therefore discards the child's stderr by default. To see it while debugging a server that fails to start, opt back in for the host process:
193+
194+
```bash
195+
UTCP_MCP_CHILD_STDERR=inherit python your_app.py
196+
```
197+
198+
Any other value, or leaving the variable unset, keeps stderr suppressed. When a stdio server fails to connect, the error log reminds you of this switch.
199+
190200
### List Available Tools
191201
```python
192202
# Discover tools from MCP server

‎plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py‎

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import os
12
import sys
2-
from typing import Any, Dict, Optional, AsyncGenerator, TYPE_CHECKING, Tuple
3+
from typing import Any, Dict, Optional, AsyncGenerator, TYPE_CHECKING, Tuple, TextIO
34
import json
45

56
from mcp_use import MCPClient
@@ -23,6 +24,56 @@
2324

2425
logger = logging.getLogger(__name__)
2526

27+
# Environment variable that opts stdio MCP children back into writing to the
28+
# host's stderr. Same name and semantics as the TypeScript SDK.
29+
CHILD_STDERR_ENV_VAR = "UTCP_MCP_CHILD_STDERR"
30+
31+
_devnull: Optional[TextIO] = None
32+
33+
34+
def _child_stderr_target() -> TextIO:
35+
"""Return the stream stdio MCP children should write their stderr to.
36+
37+
Defaults to ``os.devnull`` so a chatty server (banners, telemetry notices,
38+
auth chatter, multiplied by every federated server) does not flood the host
39+
terminal during discovery. Set ``UTCP_MCP_CHILD_STDERR=inherit`` to see it
40+
while debugging. A file object rather than ``subprocess.DEVNULL`` because
41+
the connector's contract is a text stream.
42+
"""
43+
if os.environ.get(CHILD_STDERR_ENV_VAR) == "inherit":
44+
return sys.stderr
45+
global _devnull
46+
if _devnull is None or _devnull.closed:
47+
_devnull = open(os.devnull, "w")
48+
return _devnull
49+
50+
51+
class _QuietStdioMCPClient(MCPClient):
52+
"""``MCPClient`` that routes stdio children's stderr per ``UTCP_MCP_CHILD_STDERR``.
53+
54+
``MCPClient.from_dict`` offers no way to set the ``errlog`` that
55+
``StdioConnector`` hands to the MCP SDK's ``stdio_client``, so every child
56+
would inherit the host's stderr. The connector only reads ``errlog`` when it
57+
connects, so it is enough to set it between construction and initialization.
58+
"""
59+
60+
async def create_session(self, server_name: str, auto_initialize: bool = True):
61+
session = await super().create_session(server_name, auto_initialize=False)
62+
if session is None:
63+
return None
64+
if hasattr(session.connector, "errlog"):
65+
session.connector.errlog = _child_stderr_target()
66+
if auto_initialize:
67+
try:
68+
await session.initialize()
69+
except Exception:
70+
# Mirror the base class: a session that failed to initialize must
71+
# not stay cached, or the next lookup would hand back a dead one.
72+
self.sessions.pop(server_name, None)
73+
raise
74+
return session
75+
76+
2677
class McpCommunicationProtocol(CommunicationProtocol):
2778
"""REQUIRED
2879
MCP transport implementation that connects to MCP servers via stdio or HTTP.
@@ -52,7 +103,7 @@ async def _ensure_mcp_client(self, manual_call_template: 'McpCallTemplate'):
52103
if self._mcp_client is None or self._mcp_client.config != manual_call_template.config.mcpServers:
53104
# Create a new MCPClient with the server configuration
54105
config = {"mcpServers": manual_call_template.config.mcpServers}
55-
self._mcp_client = MCPClient.from_dict(config)
106+
self._mcp_client = _QuietStdioMCPClient.from_dict(config)
56107

57108
async def _get_or_create_session(self, server_name: str, manual_call_template: 'McpCallTemplate'):
58109
"""Get an existing session or create a new one using MCPClient."""
@@ -66,7 +117,17 @@ async def _get_or_create_session(self, server_name: str, manual_call_template: '
66117
except ValueError:
67118
# Session doesn't exist, create a new one
68119
self._log_info(f"Creating new session for server: {server_name}")
69-
session = await self._mcp_client.create_session(server_name, auto_initialize=True)
120+
try:
121+
session = await self._mcp_client.create_session(server_name, auto_initialize=True)
122+
except Exception as e:
123+
server_config = manual_call_template.config.mcpServers.get(server_name)
124+
is_stdio = isinstance(server_config, dict) and "command" in server_config
125+
if is_stdio and os.environ.get(CHILD_STDERR_ENV_VAR) != "inherit":
126+
self._log_error(
127+
f"Failed to start stdio MCP server '{server_name}': {e}. The child's stderr was "
128+
f"suppressed; re-run with {CHILD_STDERR_ENV_VAR}=inherit to see what it printed while starting."
129+
)
130+
raise
70131
return session
71132

72133
async def _cleanup_session(self, server_name: str):
@@ -385,13 +446,25 @@ async def call_tool_streaming(self, caller: 'UtcpClient', tool_name: str, tool_a
385446
def _process_tool_result(self, result, tool_name: str) -> Any:
386447
self._log_info(f"Processing tool result for '{tool_name}', type: {type(result)}")
387448

388-
# Check for structured output first - this is the expected behavior
389-
if hasattr(result, 'structuredContent'):
390-
self._log_info(f"Found structuredContent: {result.structuredContent}")
391-
# If structuredContent has a 'result' key, unwrap it
392-
if isinstance(result.structuredContent, dict) and 'result' in result.structuredContent:
393-
return result.structuredContent['result']
394-
return result.structuredContent
449+
# Prefer structuredContent (MCP spec field) whenever the server sent it.
450+
structured = getattr(result, 'structuredContent', None)
451+
if structured is not None:
452+
self._log_info(f"Found structuredContent: {structured}")
453+
# FastMCP wraps NON-OBJECT returns (primitives, lists, None) as
454+
# {"result": value}; object returns are sent as-is. Unwrap exactly that
455+
# shape: a single "result" key whose value is not a dict. A single-key
456+
# {"result": {...}} is therefore a genuine object return and passes
457+
# through untouched, as does any dict with other keys. A genuine
458+
# {"result": <primitive or list>} return is indistinguishable from the
459+
# wrapper on the wire and is unwrapped too; that ambiguity is inherent
460+
# to the FastMCP convention.
461+
if (
462+
isinstance(structured, dict)
463+
and set(structured.keys()) == {"result"}
464+
and not isinstance(structured["result"], dict)
465+
):
466+
return structured["result"]
467+
return structured
395468

396469
# Process content if available (fallback)
397470
if hasattr(result, 'content'):

‎plugins/communication_protocols/mcp/tests/test_mcp_transport.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,3 +251,48 @@ async def test_call_tool_streaming_yields_single_chunk(transport: McpCommunicati
251251
"""Streaming mode should emit the awaited result as one chunk, not a coroutine."""
252252
chunks = [chunk async for chunk in transport.call_tool_streaming(None, f"{SERVER_NAME}.echo", {"message": "test"}, mcp_manual)]
253253
assert chunks == [{"reply": "you said: test"}]
254+
255+
256+
# --- Child stderr routing and structuredContent unwrapping ---
257+
258+
@pytest.mark.asyncio
259+
async def test_stdio_child_stderr_suppressed_by_default(transport: McpCommunicationProtocol, mcp_manual: McpCallTemplate, monkeypatch):
260+
"""Without the opt-in, stdio children write stderr to os.devnull, not the host's stderr."""
261+
monkeypatch.delenv("UTCP_MCP_CHILD_STDERR", raising=False)
262+
session = await transport._get_or_create_session(SERVER_NAME, mcp_manual)
263+
try:
264+
assert session.connector.errlog is not sys.stderr
265+
assert session.connector.errlog.name == os.devnull
266+
finally:
267+
await transport._cleanup_session(SERVER_NAME)
268+
269+
270+
@pytest.mark.asyncio
271+
async def test_stdio_child_stderr_inherit_opt_in(transport: McpCommunicationProtocol, mcp_manual: McpCallTemplate, monkeypatch):
272+
"""UTCP_MCP_CHILD_STDERR=inherit restores the host's stderr for debugging."""
273+
monkeypatch.setenv("UTCP_MCP_CHILD_STDERR", "inherit")
274+
session = await transport._get_or_create_session(SERVER_NAME, mcp_manual)
275+
try:
276+
assert session.connector.errlog is sys.stderr
277+
finally:
278+
await transport._cleanup_session(SERVER_NAME)
279+
280+
281+
@pytest.mark.asyncio
282+
async def test_process_tool_result_unwraps_only_single_key_result_wrapper(transport: McpCommunicationProtocol):
283+
"""A FastMCP {"result": x} wrapper is unwrapped; a real object with a result key is not."""
284+
from types import SimpleNamespace
285+
assert transport._process_tool_result(SimpleNamespace(structuredContent={"result": 42}, content=[]), "t") == 42
286+
assert transport._process_tool_result(
287+
SimpleNamespace(structuredContent={"result": 1, "extra": 2}, content=[]), "t"
288+
) == {"result": 1, "extra": 2}
289+
assert transport._process_tool_result(SimpleNamespace(structuredContent={"answer": 42}, content=[]), "t") == {"answer": 42}
290+
assert transport._process_tool_result(SimpleNamespace(structuredContent={"result": ["a", "b"]}, content=[]), "t") == ["a", "b"]
291+
# FastMCP only wraps non-object returns, so {"result": {...}} is a genuine
292+
# object return from the tool and must keep its shape.
293+
assert transport._process_tool_result(
294+
SimpleNamespace(structuredContent={"result": {"nested": True}}, content=[]), "t"
295+
) == {"result": {"nested": True}}
296+
# No structuredContent: fall back to text content.
297+
text_only = SimpleNamespace(structuredContent=None, content=[SimpleNamespace(text="7")])
298+
assert transport._process_tool_result(text_only, "t") == 7

0 commit comments

Comments
 (0)