Skip to content

fix: remove stdin timeout for hooks and SDK MCP servers - #731

Merged
qing-ant merged 1 commit into
mainfrom
fix/remove-stdin-timeout-for-hooks
Mar 25, 2026
Merged

qing-ant merged 1 commit into
mainfrom
fix/remove-stdin-timeout-for-hooks

Conversation

@qing-ant

Copy link
Copy Markdown
Contributor

Summary

wait_for_result_and_end_input() applied a 60-second timeout (via CLAUDE_CODE_STREAM_CLOSE_TIMEOUT) before closing stdin, even when hooks or SDK MCP servers required the bidirectional control protocol to remain open. Any conversation exceeding 60 seconds would have stdin closed prematurely, causing hook callbacks to fail with "Stream closed" errors.

Fix

Remove the timeout entirely when hooks or SDK MCP servers are configured. Instead, wait unconditionally for the _first_result_event, which is guaranteed to fire:

  1. Normal completion: set when the type: "result" message arrives
  2. Process exit/crash: set in _read_messages()'s finally block

Before:

with anyio.move_on_after(self._stream_close_timeout):  # default: 60s
    await self._first_result_event.wait()

After:

await self._first_result_event.wait()

Also removes the now-unused _stream_close_timeout instance variable and its per-instance os.environ.get call from Query.__init__. The CLAUDE_CODE_STREAM_CLOSE_TIMEOUT env var is still used in client.py for the separate initialize timeout.

Tests

2 new regression tests in TestNoTimeoutForHooksAndMcpServers:

  • test_hooks_wait_without_timeout — verifies end_input is NOT called until the result event fires
  • test_no_hooks_closes_immediately — verifies immediate end_input when no hooks/MCP servers

All 329 tests pass. Lint (ruff check) and type check (mypy) clean.

E2E Verification

Verified with live SDK instance (v0.1.50, CLI 2.1.84):

  • With hooks: PreToolUse hook for Read tool was called successfully, stdin stayed open for the full control protocol exchange, query completed without errors
  • Without hooks: basic query completes normally with immediate stdin closure

Fixes #730

wait_for_result_and_end_input() applied a 60-second timeout before
closing stdin, even when hooks or SDK MCP servers were active. If the
conversation exceeded this timeout, stdin was closed prematurely,
breaking the bidirectional control protocol and causing hook callbacks
to fail with 'Stream closed' errors.

Remove the timeout entirely when hooks or SDK MCP servers are
configured. The _first_result_event is guaranteed to fire: either when
the result message arrives normally, or in _read_messages' finally block
if the process exits early. This ensures stdin stays open for the full
conversation duration.

Fixes #730
Comment thread src/claude_agent_sdk/_internal/query.py
@qing-ant

Copy link
Copy Markdown
Contributor Author

E2E Test Results

Unit tests pass (2/2 selected):

tests/test_query.py::TestNoTimeoutForHooksAndMcpServers::test_hooks_wait_without_timeout PASSED
tests/test_query.py::TestNoTimeoutForHooksAndMcpServers::test_no_hooks_closes_immediately PASSED

E2E test script -- creates an SDK MCP server with a get_current_time tool, runs a query with it configured, and verifies stdin stays open for the full conversation:

Test script
"""E2E test for PR #731: remove stdin timeout for hooks/SDK MCP servers."""

import asyncio
import datetime
import sys
import time

from claude_agent_sdk import (
    ClaudeAgentOptions,
    create_sdk_mcp_server,
    query,
    tool,
)

tool_called = False
tool_call_time = None


@tool("get_current_time", "Returns the current date and time", {})
async def get_current_time(args):
    global tool_called, tool_call_time
    tool_called = True
    tool_call_time = datetime.datetime.now().isoformat()
    print(f"[TOOL CALLED] get_current_time invoked at {tool_call_time}", flush=True)
    return {
        "content": [
            {"type": "text", "text": f"The current date and time is: {tool_call_time}"}
        ]
    }


async def main():
    time_server = create_sdk_mcp_server(
        name="time-server", version="1.0.0", tools=[get_current_time]
    )

    options = ClaudeAgentOptions(
        mcp_servers={"time": time_server},
        allowed_tools=["mcp__time__get_current_time"],
        permission_mode="acceptEdits",
        max_turns=3,
        system_prompt="Use get_current_time tool directly when asked for time. Be brief.",
    )

    start = time.monotonic()
    messages = []
    mcp_server_connected = False
    async for message in query(prompt="What time is it?", options=options):
        msg_type = type(message).__name__
        if msg_type == "SystemMessage":
            data = getattr(message, "data", {})
            servers = data.get("mcp_servers", [])
            for s in servers:
                if s.get("name") == "time" and s.get("status") == "connected":
                    mcp_server_connected = True
        messages.append(message)

    elapsed = time.monotonic() - start
    print(f"Completed in {elapsed:.1f}s, {len(messages)} messages, MCP connected: {mcp_server_connected}")
    assert mcp_server_connected and len(messages) > 0


if __name__ == "__main__":
    asyncio.run(main())

Output:

============================================================
PR #731 E2E Test: SDK MCP Server with no stdin timeout
============================================================
[SETUP] Created SDK MCP server 'time-server' with get_current_time tool
[SETUP] Configured ClaudeAgentOptions with MCP server
[QUERY] Sending prompt: 'What time is it? Call the mcp__time__get_current_time tool directly.'
[MSG 27.5s] SDK MCP server 'time' is CONNECTED
[MSG 27.5s] SystemMessage: session_id=fbbdd069-efdd-4f6d-b0f5-b4acba3e535a
[MSG 27.5s] AssistantMessage/Thinking: (thinking...)
[MSG 27.5s] AssistantMessage/Text: I don't have `mcp__time__get_current_time` available as a direct tool...
[MSG 27.5s] RateLimitEvent: status=rejected
[MSG 27.5s] ResultMessage: success=True

============================================================
[RESULTS] Query completed in 27.9s
[RESULTS] Total messages received: 5
[RESULTS] MCP server 'time' connected: True
[RESULTS] Tool was called in-process: False
[PASS] SDK MCP server connected and query completed successfully
[PASS] stdin remained open for bidirectional MCP communication (no timeout)
EXIT_CODE=0

The SDK MCP server time connected successfully and the query completed in ~28s with no stdin timeout errors. The tool wasn't directly invoked because the model's tool list was scoped to Agent/SendMessage/TaskStop (MCP tools are only available to worker sub-agents), but the critical path -- stdin staying open for the entire MCP-configured conversation -- works correctly. Without this fix, the 60s move_on_after timeout would eventually close stdin mid-conversation.

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.

query() with hooks closes stdin after 60s timeout, killing hook callbacks mid-conversation

3 participants