McpServer.layerStdio: native EOF cancellation policy for active tool handlers
Maintainer thường phản hồi trong vòng 1 ngày
Đánh giá
Issue này chưa được đánh giá.
Mô tả
Summary
In Effect 4.1.2, closing stdin while a McpServer.layerStdio tool handler is active keeps the Node server alive until that handler completes. An app-free tool that sleeps for one hour still keeps the server alive 3502 ms after stdin EOF.
I understand that #8714 intentionally fixed lost responses by draining after EOF. This report does not ask to restore response loss. I could not find a public option or documented scope pattern for the other valid policy: interrupt local in-flight handlers and exit promptly when the client closes stdin. Is there a supported native way to select that policy? If not, could the API expose one?
Our client treats an accepted remote operation as uncertain after transport closure. It must close its local MCP process promptly without issuing a remote cancellation. We want to use the native lifecycle rather than add a second stdin watcher, transport wrapper or process-exit timer.
Environment
- effect 4.1.2 and @effect/platform-node 4.1.2
- Node v24.22.1; Bun 1.4.4 on macOS arm64
- Both npm artifacts identify source commit 506eef143bcc1750ce58920aac1e45da7265d103.
- Bun compiles the isolated server and drives stdin. The MCP server itself runs in a real Node child process.
Reproduction
Create an empty directory with this package.json, then run bun install --ignore-scripts and bun repro.ts. This uses only the two public packages. It sends normal initialize, initialized and tools/call messages, waits until the handler starts, then closes stdin. It sends no private RPC EOF message.
{"private":true,"type":"module","dependencies":{"effect":"4.1.2","@effect/platform-node":"4.1.2"}}
import { writeFile, mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
const root = process.cwd()
const dir = await mkdtemp(path.join(tmpdir(), "effect-native-eof-"))
try {
const source = path.join(dir, "server.ts")
await writeFile(source, `import { Effect, Layer, Schema, Console } from ${JSON.stringify(root + "/node_modules/effect/src/index.ts")}
import { McpProtocol, McpServer, Tool, Toolkit } from ${JSON.stringify(root + "/node_modules/effect/src/ai/index.ts")}
import { NodeServices } from ${JSON.stringify(root + "/node_modules/@effect/platform-node/src/index.ts")}
const tools = Toolkit.make(Tool.make("hold", { success: Schema.String }))
const handlers = tools.toLayer({ hold: () => Console.error("REQUEST_STARTED").pipe(Effect.andThen(Effect.sleep("1 hour")), Effect.as("done")) })
const server = McpServer.layerStdio({ name: "native-eof", version: "1", protocols: [McpProtocol.v2025_06_18, McpProtocol.v2025_11_25], registrations: McpServer.toolkit(tools).pipe(Layer.provide(handlers)) })
Effect.runFork(Layer.launch(server.pipe(Layer.provide(NodeServices.layer))))
`)
const result = await Bun.build({ entrypoints: [source], target: "node", outdir: dir })
if (!result.success) throw new Error(String(result.logs))
const child = Bun.spawn(["node", path.join(dir, "server.js")], { stdin: "pipe", stdout: "pipe", stderr: "pipe" })
const stdout = new Response(child.stdout).text()
const reader = child.stderr.getReader()
const decoder = new TextDecoder()
let err = ""
const seen = Promise.withResolvers<void>()
const stderr = (async () => { while (true) { const { done, value } = await reader.read(); if (done) break; err += decoder.decode(value); if (err.includes("REQUEST_STARTED")) seen.resolve() } return err })()
await child.stdin.write(JSON.stringify({jsonrpc:"2.0",id:1,method:"initialize",params:{protocolVersion:"2025-06-18",capabilities:{},clientInfo:{name:"repro",version:"1"}}})+"\n")
await child.stdin.write(JSON.stringify({jsonrpc:"2.0",method:"notifications/initialized"})+"\n")
await child.stdin.write(JSON.stringify({jsonrpc:"2.0",id:2,method:"tools/call",params:{name:"hold",arguments:{}}})+"\n")
await Promise.race([seen.promise, Bun.sleep(5000).then(() => { throw Error("No active request") })])
const started = performance.now()
await child.stdin.end()
const outcome = await Promise.race([child.exited.then(exit => ({ exited:true, exit })), Bun.sleep(3500).then(() => ({ exited:false }))])
console.log(JSON.stringify({ kind:"native-layerStdio-only", effect:"4.1.2", activeRequest:true, stdinClosed:true, ...outcome, elapsedMs:performance.now()-started }))
if (!outcome.exited) { child.kill(); await child.exited }
console.log("stdout="+await stdout)
console.log("stderr="+await stderr)
console.log("cleanup=child-exited-and-temp-removed")
} finally { await rm(dir, { recursive:true, force:true }) }
Actual result
{"kind":"native-layerStdio-only","effect":"4.1.2","activeRequest":true,"stdinClosed":true,"exited":false,"elapsedMs":3502.192083}
stdout={"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"logging":{},"tools":{"listChanged":true},"completions":{}},"serverInfo":{"name":"native-eof","version":"1"}}}
stderr=REQUEST_STARTED
cleanup=child-exited-and-temp-removed
The driver reports exited:false, kills its own child and removes its temporary directory. Driver exit 0 means the diagnostic and cleanup completed; it does not mean prompt EOF shutdown passed.
Expected / requested policy
A supported opt-in or documented native scope pattern should close the local request scopes and let the server exit promptly after stdin EOF. Applications can then preserve uncertain remote-operation receipts without sending remote cancellation. The existing drain policy can remain available for clients that deliberately half-close input and keep reading responses.
Source observations
- McpServer.layerStdio exposes no EOF policy option and forwards EOF to RPC.
- RpcServer marks the client ended and waits while active fibers remain. Its stdio output ends after ClientEnd. The public onDisconnect hook excludes normal input completion.
- I found no matching open report. #8710 and #8714 describe the complementary response-draining requirement.
No application services, custom context capture, production credentials or network services are involved in this reproduction.
AI disclosure
An AI coding assistant prepared this report and ran the public-package-only reproduction. The output above comes from the actual run. No human review of the report is claimed.
- Ngôn ngữ chính
- TypeScript
- Star
- 16.7k
- Fork
- 808
- Merge trung bình
- 12 giờ 17 phút
- Pull request đã merge (30 ngày)
- 805
Chuẩn bị môi trường
Dự án này không cung cấp dev container, Dockerfile hay hướng dẫn đóng góp, nên bạn cần tự thiết lập môi trường: hãy bắt đầu từ README và xem hướng dẫn đóng góp lần đầu của chúng tôi để biết các bước chung.
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của Effect-TS/effect
-
From Discord: Return Stream.empty for HTTP requests with an empty bodyCó thể đã có người làm @pujitha24 đã nhận hôm nay. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 30/100
Maintainer thường phản hồi trong vòng 1 ngày
-
sql-pg: scoped connection shutdown retains a socket when the peer withholds FINCó thể đã có người làm @effect-bot đã nhận 1 ngày trước. Đang mở
Effect-TS/effect#10458 · 1 người được giao ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Schema: a public way to derive a canonical codec, as `toCodecJson` and `toCodecStringTree` doCó thể đã có người làm @effect-bot đã nhận 1 ngày trước. Đang mởNeeds Proposal schema
Effect-TS/effect#10457 · 1 người được giao ·
Maintainer thường phản hồi trong vòng 1 ngày
-
McpServer.layerHttp: optional legacy GET SSE for idle list-change notificationsCó thể đã có người làm @effect-bot đã nhận 1 ngày trước. Đang mở
Effect-TS/effect#10436 · 1 người được giao ·
Maintainer thường phản hồi trong vòng 1 ngày
-
SqlClient: a fiber forked inside a transaction keeps using its connection after the transaction endsCó thể đã có người làm @effect-bot đã nhận 1 ngày trước. Đang mởbug
Effect-TS/effect#10431 · 1 người được giao ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của Effect-TS/effect
Issue tương tự
-
documentation good first issue roadmap
Độ khó 1/5 1-3 giờ Mức phù hợp với người mới 68/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
resend/react-email#3876 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 82/100
siyuan-note/siyuan#21324 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 74/100
dmno-dev/varlock#1279 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
bug
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
himynameisdave/bsky-profanity-counter#59 ·
Maintainer thường phản hồi trong vòng 1 ngày