Skip to content

mcp: quiet stdio child stderr by default, tighten structuredContent unwrap - #101

Merged
h3xxit merged 4 commits into
devfrom
fix/mcp-quiet-child-stderr
Sep 4, 2026
Merged

mcp: quiet stdio child stderr by default, tighten structuredContent unwrap#101
h3xxit merged 4 commits into
devfrom
fix/mcp-quiet-child-stderr

Conversation

@h3xxit

@h3xxit h3xxit commented Sep 4, 2026

Copy link
Copy Markdown
Member

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<2 pin 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_dict gives 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 (the connector only reads it on connect). UTCP_MCP_CHILD_STDERR=inherit restores 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 on CallToolResult, so it is now is not None. FastMCP wraps non-object returns as {"result": value}; that single-key wrapper is still unwrapped, but an object that merely has a result key 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 $ref fix from #33. This plugin passes MCP schemas through without dereferencing them, so it cannot hit that bug.

Test plan

  • New tests: default errlog is os.devnull; UTCP_MCP_CHILD_STDERR=inherit yields sys.stderr; single-key unwrap rule and text fallback
  • pytest plugins/communication_protocols/mcp/tests: 23 passed, three consecutive runs
  • README section on child process stderr

🤖 Generated with Claude Code


Summary by cubic

Stdio MCP children now suppress stderr by default instead of inheriting the host's stderr, and structuredContent handling 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

  • Set UTCP_MCP_CHILD_STDERR=inherit to restore child stderr; startup errors mention this switch when stderr is suppressed.
  • Only unwrap single-key {"result": value} wrappers when value is not a dict; objects with sibling keys or a nested object keep their full shape.
  • structuredContent=None falls back to text content as before.

Dependencies

  • Pin mcp to <2 because version 2 removed APIs used by the plugin's test mocks.
  • The circular $ref fix is not included because this plugin passes MCP schemas through without dereferencing them.

Written for commit b824c07. Summary will update on new commits.

Review in cubic

h3xxit and others added 2 commits September 4, 2026 11:21
…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>
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>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@h3xxit
h3xxit merged commit cc4549c into dev Sep 4, 2026
10 checks passed
@h3xxit
h3xxit deleted the fix/mcp-quiet-child-stderr branch September 4, 2026 13:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant