Skip to content

mcp: large stdio tool response (~11 MB) closes the connection with "Connection closed" #51092

Closed

Description

@mimi1vx

Summary

When a local (stdio) MCP server returns a large tools/call result (~11.6 MB on one JSON-RPC line), OpenCode drops the whole MCP connection and the call fails with Connection closed. The server process is healthy and sends a valid response. After that the server's tools are gone from the session until it reconnects. I'd expect only that one call to fail (or be truncated), with the connection left open.

Environment

  • opencode version: 2.0.16
  • OS: Darwin 27.0.0 arm64 (macOS, Apple silicon)
  • Terminal: ghostty (TERM=xterm-ghostty, COLORTERM=truecolor)
  • Shell: /bin/zsh
  • Install/channel: Homebrew anomalyco/tap/opencode-v2 stable (latest)
  • Active plugins: two local file:// plugins (skills/tools only; neither touches the MCP transport)

Reproduction

Minimal server (no external dependencies) that returns an N-MB text result:

#!/usr/bin/env python3
# big_mcp.py — usage: big_mcp.py <megabytes>
import json, sys
MB = int(sys.argv[1]) if len(sys.argv) > 1 else 12
for line in sys.stdin:
    msg = json.loads(line)
    mid, method = msg.get("id"), msg.get("method")
    if method == "initialize":
        res = {"protocolVersion": msg["params"]["protocolVersion"],
               "capabilities": {"tools": {}},
               "serverInfo": {"name": "big", "version": "0"}}
    elif method == "tools/list":
        res = {"tools": [{"name": "big", "description": "returns a large payload",
                          "inputSchema": {"type": "object", "properties": {}}}]}
    elif method == "tools/call":
        res = {"content": [{"type": "text", "text": "x" * (MB * 1024 * 1024)}]}
    elif mid is None:
        continue
    else:
        res = {}
    sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": mid, "result": res}) + "\n")
    sys.stdout.flush()
  1. Add it to opencode.jsonc:
    "mcp": { "big": { "type": "local", "command": ["python3", "/path/to/big_mcp.py", "12"], "enabled": true } }
  2. Start OpenCode and ask the agent to call the big tool.
  3. The call fails with Connection closed, and the session shows "Instructions for the following MCP servers are no longer available: big".

Real-world trigger: ruoqa-mcp list_jobs against openqa.suse.de with limit: 60 returns an 11,628,321-byte line in about 4.6 s. Driving the same binary directly over stdio (same env, same request) gets the full response, and the process exits 0 with nothing on stderr.

Expected Behavior

The oversized result produces an error, or a truncated result, for that single tool call (ideally with the size and the limit in the message). The MCP connection stays up and the other tools keep working.

OR - like opencode v1 - > save response into temporary file and give agent path to that file with size warning

Actual Behavior

The whole stdio connection gets torn down when the response arrives:

level=WARN message="failed to call MCP tool" server=openqa error="Connection closed" role=server

The tool result is just Connection closed. The server's instructions and tools are removed from the session, and it only reconnects later (in my case when I switched directory). No log line says why the connection was closed or mentions size.

Additional Context

  • Happened once. It reproduces every time with the same request. Small responses from the same server work (for example, the same call with summary: true is 4 KB).
  • Workaround: keep tool responses small on the server side.
  • Possibly related, but not the same: mcp: mcp list reports failed while API reports connected after server reconnect #50725 (mcp list status out of sync after reconnect).
  • Requests: (1) turn an oversized message into a per-call error instead of closing the transport, (2) log the reason and the limit, (3) document the maximum MCP message size.

Activity

  1. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report and the minimal server. I could reproduce this.

    I ran your big_mcp.py as a local MCP server big on 2.0.16 and had the agent call big with a 12 MB response. The call fails with Connection closed and the TUI shows "MCP server failed: big". The same call with a 1 MB response works. On the server side, the write of the large line fails with BrokenPipeError, so OpenCode closes the pipe while the response is still arriving. The server itself is fine.

    It still happens on the latest dev (fa1260d). The error is now more detailed, but it's misleading: Connection closed: MCP server process exited with code 1: … BrokenPipeError, which makes it look like the server crashed.

    A likely cause, from reading the code (not confirmed with a fix yet): the stdio transport has its own 16 MiB per-message limit, but the MCP SDK's read buffer it uses has a default limit of 10 MiB. A message over 10 MiB, like your ~11.6 MB one, makes it throw. That ends the whole transport instead of failing just the one call, and the error never mentions size.

    The recording and screenshots below show it (on dev and on 2.0.16), and the script below reproduces it. Until this is addressed, keeping responses under about 10 MiB avoids it. Possibly related, but a different problem: #50725.

    recording.mp4

    Screenshot

    Screenshot

    Reproduction script (drive.ts)
    // Reproduces #51092: a ~12 MiB stdio MCP tool result closes the MCP connection.
    // Needs the issue's big_mcp.py at /workspace/repro/big_mcp.py (or BIG_MCP); size in MB via BIG_MB (default 12).
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const MB = process.env.BIG_MB ?? "12"
    const server = process.env.BIG_MCP ?? "/workspace/repro/big_mcp.py"
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        config: {
          mcp: { big: { type: "local", command: ["python3", server, MB], enabled: true } },
        },
      } as any,
      ({ ui, llm }) =>
        Effect.gen(function* () {
          yield* Effect.sleep(4000)
          yield* llm.serve((request: any, index: number) => {
            // The first request without a tool result gets the tool call (request order can vary by version).
            if (!JSON.stringify(request).includes('"role":"tool"'))
              return Stream.make(
                Llm.toolCall({ index: 0, id: "call_big", name: "execute", input: { code: "return await tools.big.big({})" } }),
                Llm.finish("tool-calls"),
              )
            // Echo what OpenCode sent back as the tool result (the error text, or its size on success).
            const body = JSON.stringify(request)
            const m = body.match(/(Connection closed[^"\\]*|MCP stdio[^"\\]{0,200}|xxxxxxxxxx)/)
            const summary = m ? m[0] : `tool result received (${body.length} bytes of request)`
            console.log("TOOL RESULT SEEN BY MODEL:", summary)
            return Stream.make(Llm.text(`done: ${summary}`))
          })
          yield* ui.submit(`Call the big tool (${MB} MB)`)
          yield* ui.waitFor("done", { timeout: 60000 } as any).pipe(Effect.ignore)
          yield* Effect.sleep(2000)
          yield* ui.screenshot(`big-${MB}mb`)
          const frame = yield* ui.capture()
          const text = (frame as any).lines.map((l: any) => l.spans.map((s: any) => s.text).join("")).join("\n")
          console.log(text)
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions