1+ import os
12import sys
2- from typing import Any , Dict , Optional , AsyncGenerator , TYPE_CHECKING , Tuple
3+ from typing import Any , Dict , Optional , AsyncGenerator , TYPE_CHECKING , Tuple , TextIO
34import json
45
56from mcp_use import MCPClient
2324
2425logger = 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+
2677class 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' ):
0 commit comments