stdin 是什么
在终端里待久了就会遇到 stdin 这个词。它译作标准输入,实体却很简单:程序读取输入的默认通道。
操作系统启动进程时会默认打开三条带编号的通道,这些编号称为文件描述符。0 号是 stdin,1 号是 stdout(标准输出),2 号是 stderr(标准错误)。程序从 0 号读入,把结果写到 1 号,把错误和日志写到 2 号。
关键在于程序不需要知道这条通道连到了哪里。可能是键盘,可能是文件,也可能是上一条命令的输出。连接由 shell 代为完成,程序只管读 stdin。Unix 管道能够成立,原因正在于此。
而 1 号与 2 号分开这一点,是本文后半部分的核心。给人看的日志和要交给下一个程序的数据不能混在一起,所以通道从一开始就分成了两条。
| 名称 | 编号(FD) | 方向 | 默认连接 | 用途 |
|---|---|---|---|---|
| stdin | 0 | 输入 | 键盘 | 程序读入的数据 |
| stdout | 1 | 输出 | 屏幕 | 交给下一个程序的结果 |
| stderr | 2 | 输出 | 屏幕 | 给人看的日志与错误 |
CLI 中 stdin 如何使用

用法大致有四种。亲手敲一遍最容易建立感觉。
第一是管道。把前一条命令的 stdout 接到后一条命令的 stdin 上,用于处理长到不便作参数传递的数据。写成 cat error.log | claude -p "总结这份日志",整份日志就通过 stdin 进去了。claude -p 发现 stdin 上有数据时,会把它拼在提示词前面一并处理。
第二是重定向。把文件直接接到 stdin 上,形如 claude -p "解释一下" < README.md。效果与管道相近,但少启动一个进程。
第三是一个连字符。许多 CLI 约定,在文件参数的位置写 - 表示从 stdin 读取,例如 kubectl apply -f - 或 docker build -t img -。这是惯例而非规范,各工具支持情况不一,请查帮助。
第四是 heredoc。不建临时文件,直接把多行内容送进 stdin,传 SQL 或配置片段时很方便。
# 1) 管道 —— 前一条命令的 stdout 接到后一条的 stdin
echo hello | cat
cat error.log | claude -p "总结这份日志"
git diff | claude -p "帮我评审"
# 2) 重定向 —— 把文件接到 stdin
claude -p "解释一下" < README.md
wc -l < /etc/hosts
# 3) 一个连字符 = 从 stdin 读取(惯例)
curl -s https://example.com/a.json | jq . -
kubectl apply -f -
# 4) heredoc —— 不建临时文件的多行输入
cat <<'EOF' | sort
banana
apple
EOFisatty —— CLI 卡住的真正原因

这里有一个新手最常踩的坑:cron 或 CI 脚本里的 CLI 毫无输出地卡住不动。
原因多半在 stdin。程序想问人一句话而在等待输入,可那里没有人。在终端里会弹出提示,情况看得见;在后台则什么也看不到,就那样永远卡着。
所以做得好的 CLI 会先判断 stdin 是否连着终端,这个检查叫 isatty。是终端就说明有人在,于是发问;不是就直接读管道送来的数据。同一条命令既能交互使用又能在脚本里跑,靠的就是这个分支。
别人的 CLI 卡住时,应对有两条。有 --non-interactive 或 -y 这类参数就先用;没有就给 stdin 接一个空的东西。命令末尾加上 < /dev/null,程序立刻遇到输入结束,便会放弃提问。在 cron 脚本里养成这个习惯,原因不明的卡死会大幅减少。
# 在 Python 里分支
import sys
if sys.stdin.isatty():
name = input("姓名: ") # 有人在终端前
else:
data = sys.stdin.read() # 是管道送进来的
# 在 shell 里判断 —— 终端为 0,管道为 1
[ -t 0 ] && echo "终端" || echo "管道"
# 别人的 CLI 等输入卡住时
some-cli --non-interactive # 先试参数
some-cli < /dev/null # 没有就给它一个空的 stdin在 MCP 中 stdin 就是通信信道本身
从这里开始 stdin 的地位变了。此前它是承载数据的通道,而在 MCP 的 stdio 传输下,stdin 与 stdout 就是通信信道本身。
MCP 规范定义了两种传输。一种是 Streamable HTTP,供远程服务器使用;另一种就是 stdio。按规范自己的说法,它在客户端启动的子进程的标准流之上收发以换行分隔的消息。
也就是说,本地 MCP 服务器并不特别,它就是一个进程。Claude Code 这样的客户端把它作为子进程启动,不开套接字也不开端口,直接用管道对话。服务器从 stdin 读请求,往 stdout 写响应,机制就这些。
来回传递的是 JSON-RPC 2.0 消息,一行一条。规范写明消息以换行分隔,且不得包含内嵌换行。因此解析器读一行按 JSON 解释即可,就这么简单。
配置文件所做的也恰恰只有这些。Claude Code 的 MCP 配置里写的 command 和 args 只是说明用什么命令启动进程,其余不过是接上该进程的 stdin 与 stdout。claude mcp add 帮助中把 stdio 服务器写成两个连字符后接可执行命令的形式,说的是同一件事。
# 两个连字符之后就是要执行的命令
claude mcp add my-server -- npx my-mcp-server
# 同时传环境变量
claude mcp add my-server -e API_KEY=xxx -- npx my-mcp-server
# 查看已注册的服务器
claude mcp list
claude mcp get my-server
# 对照:远程服务器走 HTTP,不走 stdio
claude mcp add --transport http sentry https://mcp.sentry.dev/mcp→ 送入服务器的 stdin:
{"jsonrpc":"2.0","id":1,"method":"tools/list"}
← 从服务器的 stdout 返回:
{"jsonrpc":"2.0","id":1,"result":{"tools":[...]}}所以把日志打到 stdout 会让服务器死掉
把上一节反过来看,就说明了 MCP 服务器开发中最常见的事故。stdout 是协议信道,所以不能往这条信道里写非协议的东西。
规范的措辞很硬:服务器不得向自己的 stdout 写入任何非有效 MCP 消息的内容。是 MUST NOT,是禁止而非建议。
而这个错误犯起来出奇地容易。一行 console.log("服务器已启动") 就够了。哪怕不是自己写的也会发生:引入的库在初始化时打了横幅,npx 在拉包时吐了警告,开发时留下的一句调试输出没删干净——它们都会混进 JSON 流。客户端解析器把那一行当 JSON 解析失败,连接随即断开。
所以 MCP 服务器显示连接失败时,首先要查的就是这个。把代码里所有写到 stdout 的输出找出来,改送 stderr。规范也写明服务器可以出于任何日志目的向 stderr 写 UTF-8 字符串,并且客户端不应把 stderr 上有输出视为出错。stderr 才是规范认可的日志通道。
补充一点:规范在近期修订中已把服务器向客户端发送日志的 Logging 能力标为弃用,并指引新实现改为写到 stderr。方向是一致的。
// 会打断协议 —— stdout 是信道
console.log("服务器已启动");
console.log(JSON.stringify(debugState));
// 正确 —— 日志走 stderr
console.error("服务器已启动");
process.stderr.write(`[debug] tool called: ${name}\n`);
// Python 同理
print("服务器已启动") # 会打断
print("服务器已启动", file=sys.stderr) # 正确
// 想从源头杜绝,就在入口处封死
console.log = console.error;# 直接把服务器跑起来,看 stdout 上出现了什么。
# 只要有一行非 JSON 的字符,那就是元凶。
node my-server.js < /dev/null
# 丢掉 stderr 只留 stdout,会看得更清楚
node my-server.js < /dev/null 2>/dev/null不经客户端,直接戳一戳服务器
MCP 服务器不过是一个用 stdin 与 stdout 说话的进程——这个事实带来一项实用回报:可以绕开 Claude Code 直接测试它。
把一行请求 JSON 用管道送进服务器的 stdin 即可。询问工具列表的 tools/list 是最简单的测试。响应 JSON 原样返回,说明服务器正常;什么也没回来,或混进了奇怪的字符,那就是 stdout 被污染了。
这个方法有用,是因为它能缩小问题范围。客户端连不上时,无法判断毛病出在服务器代码、配置还是客户端。直接戳一下,若有响应,服务器无罪,接着去看配置或客户端即可。
还有更省事的工具。MCP 官方 Inspector 可以把服务器跑起来,在浏览器或终端里查看工具列表与调用结果,不必手写 JSON,重复作业时更合适。
要在 Claude Code 内部查看服务器的 stderr 日志,就打开调试模式。过去文档里的 --mcp-debug 如今已不在帮助中。现在的形式是 -d 或 --debug 并可附类别过滤,若要落到文件则用 --debug-file 加路径。依赖它之前先看一眼帮助更稳妥。
# 把 tools/list 请求直接送进服务器的 stdin
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node my-server.js
# 正常的服务器会这样回应
# {"jsonrpc":"2.0","id":1,"result":{"tools":[...]}}
# 或者用官方 Inspector 跑起来看
npx @modelcontextprotocol/inspector node my-server.js
# 在 Claude Code 里看调试日志(不是 --mcp-debug)
claude --help | grep -i -E 'mcp|debug'
claude -d mcp
claude --debug-file /tmp/claude-debug.log小结 —— stdin 的故事到哪里为止
stdin 本身很简单。它是程序读取输入的 0 号通道,shell 会把键盘、文件或上一条命令的输出接到它上面。在 CLI 里以管道、重定向、连字符、heredoc 四种形态出现;要在无人环境中不卡住,则需要 isatty 分支或 < /dev/null。
MCP 把这条通道用作通信信道。其余一切都从这一个决定里长出来:本地服务器是进程、配置里只写命令与参数、不能往 stdout 打日志、日志要走 stderr、一行 echo 就能不经客户端测试服务器——都是同一个事实的不同侧面。
所以自己做的 MCP 服务器连不上时,顺序是这样:先看 stdout 上有没有非 JSON 的东西,有就挪到 stderr;还不行就用 echo 直接戳,分清是服务器还是客户端的问题。这两步能解决绝大多数情况。
最后把范围说清楚。以上只适用于 stdio 传输。远程 MCP 服务器走 Streamable HTTP,与 stdin 无关,往 stdout 打什么都不会破坏协议。装在本地使用的服务器,则大多是 stdio。
