McpServer stdio: can an MCP client reach the `@effect/rpc/*` control methods? `@effect/rpc/Eof` stops the server silently
メンテナーはふだん 1 日以内に返信
評価
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 初心者へのやさしさ
- 25/100
- issue の種類
- バグ
- 明瞭さ
- 説明が足りない
- 活発さ
- 停滞
- 技術スタック
- typescript
調査の方向性
Review linked pull request #8509 first, then read the control-message handling in packages/effect/src/unstable/rpc/RpcSerialization.ts and packages/effect/src/unstable/ai/McpServer.ts, along with the cited RpcServer.ts paths. Compare the observed stdio behavior with the MCP stdio specification and establish the intended handling of @effect/rpc/* messages; done means the behavior and wire-protocol expectations are resolved.
索引モデルが issue の本文から書いたものです。
説明
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: [email protected] (tag [email protected], 14a3f14) and @effect/[email protected], 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/Eofis 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
stdoutthat is not a valid MCP message." We're unsure whether@effect/rpc/Pongcounts. It is a well-formed JSON-RPC notification, just not an MCP method.
Where it happens
RpcSerialization.ts:304-313:decodeJsonRpcMessageturns any message with noidwhose method starts with@effect/rpc/into the control message named by the suffix (Eof,Ping,Ack,Interrupt, ...).McpServer.ts:1149-1157: the MCP server passesPing,AckandInterruptthrough. ForEofon a non-HTTP transport, it callsdisconnectClient(clientId)first.RpcServer.ts:213-217andRpcServer.ts:802-806:Pingis answered withconstPong, andEofmarks 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
- #7682 (closed): control-message ids in JSON-RPC decoding. It shows the control messages are a deliberate part of
RpcSerialization.jsonRpc.
- 主要言語
- TypeScript
- スター
- 16.7k
- フォーク
- 808
- 平均マージ
- 10時間 33分
- マージ済み PR(30日)
- 830
環境構築
このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
Effect-TS/effect のほかの issue
-
RpcTest.makeClient: accept disableFatalDefects対応中かも @effect-bot が 1 日前に担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 75/100
Effect-TS/effect#9893 · コメント 3 件 ·
メンテナーはふだん 1 日以内に返信
-
`HttpApiSchema.Upgrade`: allow a regular success for servers that don't upgrade対応中かも @effect-bot が今日担当しました。 オープンdocker-made-me-do-it enhancement
Effect-TS/effect#9912 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
docker-made-me-do-it enhancement
難易度 5/5 1週間以上 初心者へのやさしさ 20/100
メンテナーはふだん 1 日以内に返信
-
`HttpApiSchema`: stream bodies in a custom framing対応中かも @effect-bot が今日担当しました。 オープンdocker-made-me-do-it enhancement
Effect-TS/effect#9910 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
`HttpApiSchema` streams: declare every media type a stream can be sent as対応中かも @effect-bot が今日担当しました。 オープンdocker-made-me-do-it enhancement
Effect-TS/effect#9909 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
Effect-TS/effect の issue をすべて見る
似ている issue
-
freshness freshness:observed-only
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
thoroc/context-radar#282 ·
-
bug P2 rust
難易度 2/5 1〜3時間 初心者へのやさしさ 82/100
メンテナーはふだん 1 日以内に返信
-
area: web
難易度 2/5 1〜3時間 初心者へのやさしさ 85/100
gabe-santana/arkdraw#25 ·
-
bug require-triage
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
expressjs/expressjs.com#2549 · リアクション 1 件 ·
メンテナーはふだん 1 日以内に返信
-
bug good first issue hacktoberfest
難易度 2/5 1〜3時間 初心者へのやさしさ 88/100
codercops/discord-bot#101 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信