mcp: quiet stdio child stderr by default, tighten structuredContent unwrap - #101
Merged
Conversation
…nwrap Python counterpart of typescript-utcp PR #33 plus its follow-ups, so both SDKs behave the same in the next release. - Stdio MCP children no longer inherit the host's stderr. mcp-use's MCPClient.from_dict offers no way to set the errlog that StdioConnector hands to the SDK's stdio_client, so a thin MCPClient subclass sets the connector's errlog to os.devnull between construction and initialization. UTCP_MCP_CHILD_STDERR=inherit restores the old behavior for debugging (same switch as the TypeScript SDK), and a stdio server that fails to start logs a hint pointing at it. - structuredContent is used when it is not None (the previous hasattr check was always true on CallToolResult). A FastMCP-style single-key {"result": value} wrapper is unwrapped; an object that merely has a "result" key among others is a genuine object return and now passes through untouched instead of losing its sibling keys. - Tests for both, plus a README section on child process stderr. Circular $ref handling from #33 needs no port: this plugin passes MCP schemas through without dereferencing them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
mcp 2.x removed `mcp.server.fastmcp.FastMCP` and `mcp.shared.exceptions.McpError`, which the MCP test mocks import. CI installs the newest mcp, so every job failed at collection with mcp 2.1.1 (the last green run on dev predates the mcp 2 release). The plugin targets the 1.x API; a fresh install with the pin resolves to mcp 1.29.1 and the MCP suite passes. Migrating to mcp 2 is a separate task. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 tasks
FastMCP wraps only non-object returns as {"result": value}, so a
single-key {"result": {...}} is a genuine object return and must keep its
shape. Unwrap only when the inner value is not a dict. Tests added for the
list wrapper and the genuine single-key object return. Mirrors the cubic
review fix on typescript-utcp#42.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
1 issue found and verified against the latest diff
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py">
<violation number="1" location="plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py:72">
P2: When session.initialize() fails, this override pops the dead session from `self.sessions` but not from `self.active_sessions`, which `super().create_session` already appended `server_name` to before initialize ran. Later `close_all_sessions()` iterates `active_sessions` and will try to close a session that is no longer cached. Pop `server_name` from `self.active_sessions` as well (e.g. `self.active_sessions.remove(server_name)`) in the except block.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| except Exception: | ||
| # Mirror the base class: a session that failed to initialize must | ||
| # not stay cached, or the next lookup would hand back a dead one. | ||
| self.sessions.pop(server_name, None) |
Contributor
There was a problem hiding this comment.
P2: When session.initialize() fails, this override pops the dead session from self.sessions but not from self.active_sessions, which super().create_session already appended server_name to before initialize ran. Later close_all_sessions() iterates active_sessions and will try to close a session that is no longer cached. Pop server_name from self.active_sessions as well (e.g. self.active_sessions.remove(server_name)) in the except block.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py, line 72:
<comment>When session.initialize() fails, this override pops the dead session from `self.sessions` but not from `self.active_sessions`, which `super().create_session` already appended `server_name` to before initialize ran. Later `close_all_sessions()` iterates `active_sessions` and will try to close a session that is no longer cached. Pop `server_name` from `self.active_sessions` as well (e.g. `self.active_sessions.remove(server_name)`) in the except block.</comment>
<file context>
@@ -23,6 +24,56 @@
+ except Exception:
+ # Mirror the base class: a session that failed to initialize must
+ # not stay cached, or the next lookup would hand back a dead one.
+ self.sessions.pop(server_name, None)
+ raise
+ return session
</file context>
Both #100 and this branch appended tests to test_mcp_transport.py; keep both.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Python counterpart of universal-tool-calling-protocol/typescript-utcp#42 (which lands typescript-utcp#33 plus follow-ups), so both SDKs ship the same MCP behavior in the next release.
Also carries the
mcp<2pin commit from #100 so CI can collect the MCP tests; if #100 merges first this hunk merges cleanly as an identical change.Stdio child stderr is discarded by default. Stdio MCP servers write banners, telemetry notices and auth chatter to stderr, multiplied by every federated server. mcp-use's
MCPClient.from_dictgives no way to set theerrlogthatStdioConnectorhands to the SDK'sstdio_client, so a thinMCPClientsubclass sets the connector'serrlogtoos.devnullbetween construction and initialization (the connector only reads it on connect).UTCP_MCP_CHILD_STDERR=inheritrestores the host's stderr for debugging, the same switch as the TypeScript SDK, and a stdio server that fails to start logs a hint pointing at it.structuredContent handling tightened. The previous
hasattr(result, 'structuredContent')check is always true onCallToolResult, so it is nowis not None. FastMCP wraps non-object returns as{"result": value}; that single-key wrapper is still unwrapped, but an object that merely has aresultkey among others now passes through untouched instead of losing its sibling keys. TypeScript gets the same rule in the companion PR.Not ported: the circular
$reffix from #33. This plugin passes MCP schemas through without dereferencing them, so it cannot hit that bug.Test plan
errlogisos.devnull;UTCP_MCP_CHILD_STDERR=inherityieldssys.stderr; single-key unwrap rule and text fallbackpytest plugins/communication_protocols/mcp/tests: 23 passed, three consecutive runs🤖 Generated with Claude Code
Summary by cubic
Stdio MCP children now suppress stderr by default instead of inheriting the host's stderr, and
structuredContenthandling preserves object-shaped tool results. This aligns the Python plugin with the TypeScript SDK while adding a debug path for failed stdio servers.Behavior changes
UTCP_MCP_CHILD_STDERR=inheritto restore child stderr; startup errors mention this switch when stderr is suppressed.{"result": value}wrappers whenvalueis not a dict; objects with sibling keys or a nested object keep their full shape.structuredContent=Nonefalls back to text content as before.Dependencies
mcpto<2because version 2 removed APIs used by the plugin's test mocks.$reffix is not included because this plugin passes MCP schemas through without dereferencing them.Written for commit b824c07. Summary will update on new commits.