Skip to content

McpServer stdio: can an MCP client reach the @effect/rpc/* control methods? @effect/rpc/Eof stops the server silently #8499

Description

@spencerbeggs

This is a question more than a report. We found it by accident while probing malformed input, and we don't know whether it's meant to be reachable.

What we observed

On an MCP stdio server, a client notification {"jsonrpc":"2.0","method":"@effect/rpc/Eof"} makes the server stop answering. The process stays alive, logs nothing, and later requests get no reply. {"jsonrpc":"2.0","method":"@effect/rpc/Ping"} is answered with {"jsonrpc":"2.0","method":"@effect/rpc/Pong"} on stdout, which is not an MCP method. When the same method is sent with an id, it is treated as an ordinary request and gets -32601.

Environment: effect@4.0.0-rc.117 (tag effect@4.0.0-rc.117, 14a3f14) and @effect/platform-node@4.0.0-rc.117, on Node 26.10.0 and macOS. From reading the source, both code paths are unchanged on main at 3788b63 (packages/effect/src/rpc/RpcSerialization.ts:315, packages/effect/src/ai/McpServer.ts:1158).

Reproduction

We used a minimal McpServer.layerStdio server over NodeStdio.layer, with References.LogToStderr and one tool. After the handshake, we send the case's line, then {"jsonrpc":"2.0","id":3,"method":"tools/list"} one second later. Captured live and trimmed:

##### case: eof-ctl     printf '{"jsonrpc":"2.0","method":"@effect/rpc/Eof"}\n'
(no reply to id 3; nothing on stderr)
##### case: eof-ctl-id  printf '{"jsonrpc":"2.0","id":5,"method":"@effect/rpc/Eof"}\n'
{"jsonrpc":"2.0","id":5,"error":{"code":-32601,"message":"Method not found: @effect/rpc/Eof","_tag":"MethodNot...
{"jsonrpc":"2.0","id":3,"result":{"tools":[...]}}
##### case: ping-ctl    printf '{"jsonrpc":"2.0","method":"@effect/rpc/Ping"}\n'
{"jsonrpc":"2.0","method":"@effect/rpc/Pong"}
{"jsonrpc":"2.0","id":3,"result":{"tools":[...]}}

In a second run, we sent @effect/rpc/Eof, then ping requests 0.5 s and 3.5 s later. We checked the server process at 2 s and it was still running. Neither ping was answered, and the process exited only when stdin closed.

The server used for these runs (server.ts)
// Minimal MCP stdio server over the installed effect + @effect/platform-node.
// Variant selected by argv[2]: "echo" (default), "union", "empty".
// PROTOCOLS=both serves [v2026_07_28, v2025_11_25]; default is [v2025_11_25].
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import * as NodeStdio from "@effect/platform-node/NodeStdio"
import { Effect, Layer, References, Schema } from "effect"
import { McpProtocol, McpServer, Tool, Toolkit } from "effect/unstable/ai"

const variant = process.argv[2] ?? "echo"

const Echo = Tool.make("echo", {
  parameters: Schema.Struct({ text: Schema.String }),
  success: Schema.Struct({ text: Schema.String })
}).annotate(Tool.Strict, true)

const Act = Tool.make("act", {
  parameters: Schema.Union([
    Schema.Struct({ action: Schema.Literal("start"), id: Schema.String }),
    Schema.Struct({ action: Schema.Literal("stop"), reason: Schema.String })
  ]),
  success: Schema.String
})

const Ping = Tool.make("noargs", {
  parameters: Schema.Struct({}),
  success: Schema.String
})

const EchoKit = Toolkit.make(Echo)
const UnionKit = Toolkit.make(Echo, Act)
const EmptyKit = Toolkit.make(Echo, Ping)

const tools =
  variant === "union"
    ? McpServer.toolkit(UnionKit).pipe(
      Layer.provide(UnionKit.toLayer({ echo: ({ text }) => Effect.succeed({ text }), act: () => Effect.succeed("ok") }))
    )
    : variant === "empty"
    ? McpServer.toolkit(EmptyKit).pipe(
      Layer.provide(EmptyKit.toLayer({ echo: ({ text }) => Effect.succeed({ text }), noargs: () => Effect.succeed("ok") }))
    )
    : McpServer.toolkit(EchoKit).pipe(
      Layer.provide(EchoKit.toLayer({ echo: ({ text }) => Effect.succeed({ text }) }))
    )

const Main = tools.pipe(
  Layer.provideMerge(
    McpServer.layerStdio({ name: "repro", version: "0.0.0", protocols: process.env.PROTOCOLS === "both"
      ? [McpProtocol.v2026_07_28, McpProtocol.v2025_11_25]
      : [McpProtocol.v2025_11_25]
  })
  ),
  Layer.provide(NodeStdio.layer)
)

// Logs go to stderr so stdout carries only the JSON-RPC wire.
NodeRuntime.runMain(Layer.launch(Main).pipe(Effect.provideService(References.LogToStderr, true)))
The driver used for these runs (drive.sh)
#!/bin/bash
# drive.sh <case>: handshake, then the case's frames, then a probe request
# (id 3) one second later. Each `printf` is one write to the server's stdin.
cd "$(dirname "$0")"
INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"repro","version":"0"}}}'
INITED='{"jsonrpc":"2.0","method":"notifications/initialized"}'
LIST2='{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
LIST3='{"jsonrpc":"2.0","id":3,"method":"tools/list"}'
{
  printf '%s\n%s\n' "$INIT" "$INITED"; sleep 0.5
  case "$1" in
    not-json)   printf 'not json\n' ;;
    blank)      printf '\n' ;;
    bom)        printf '\xef\xbb\xbf%s\n' "$LIST2" ;;
    null-batch) printf 'null\n%s\n' "$LIST2" ;;
    method-batch) printf '{"jsonrpc":"2.0","method":1}\n%s\n' "$LIST2" ;;
    scalar)     printf '7\n' ;;
    empty-obj)  printf '{}\n' ;;
    eof-ctl)    printf '{"jsonrpc":"2.0","method":"@effect/rpc/Eof"}\n' ;;
    control) : ;;
    array)      printf '[1]\n' ;;
    method-id)  printf '{"jsonrpc":"2.0","id":4,"method":5}\n' ;;
    eof-ctl-id) printf '{"jsonrpc":"2.0","id":5,"method":"@effect/rpc/Eof"}\n' ;;
    ping-ctl)   printf '{"jsonrpc":"2.0","method":"@effect/rpc/Ping"}\n' ;;
    overcap)    python3 -c 'import sys; sys.stdout.write("{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{\"text\":\"" + "a" * (17 * 1024 * 1024) + "\"}}}\n")' ;;
  esac
  sleep 1
  printf '%s\n' "$LIST3"; sleep 1
} | node server.ts echo 2>stderr.txt | cut -c1-110
echo "exit=${PIPESTATUS[1]}"
echo "stderr (ERROR lines):"; grep ERROR stderr.txt | cut -c1-160

Our reading of the spec

  • MCP 2025-11-25, Transports, "stdio": "Messages are individual JSON-RPC requests, notifications, or responses." We didn't find anything in MCP that reserves a method namespace for a transport's own control messages. To an MCP client, @effect/rpc/Eof is an unknown notification. Our understanding is that a server usually ignores those, although we couldn't find wording that requires it.
  • The same page says "The server MUST NOT write anything to its stdout that is not a valid MCP message." We're unsure whether @effect/rpc/Pong counts. It is a well-formed JSON-RPC notification, just not an MCP method.

Where it happens

  • RpcSerialization.ts:304-313: decodeJsonRpcMessage turns any message with no id whose method starts with @effect/rpc/ into the control message named by the suffix (Eof, Ping, Ack, Interrupt, ...).
  • McpServer.ts:1149-1157: the MCP server passes Ping, Ack and Interrupt through. For Eof on a non-HTTP transport, it calls disconnectClient(clientId) first.
  • RpcServer.ts:213-217 and RpcServer.ts:802-806: Ping is answered with constPong, and Eof marks the client ended.

We read the stdio Eof case as an internal signal: stdio has only one client, so ending that client leaves nothing to serve. That matches what we see.

How we work around it

We don't work around it yet. The @effected/mcp stdin guard forwards any object with a string method, so these frames reach the server unchanged. We held off because we weren't sure whether the namespace is part of the intended wire contract.

Is this intended?

Is the @effect/rpc/* namespace meant to be reachable by an MCP client on the stdio transport? If it isn't, one possible direction is for the MCP serialization to treat those methods as ordinary, unknown methods: an unknown notification would be ignored, and a request answered -32601 as it is today. We may be missing a reason the control path needs to stay open here, for example tooling that speaks both protocols.

Related

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions