Insights·2026-08-27

stdin 是什么

stdin(标准输入)是程序读取输入的默认通道,即文件描述符 0。在 shell 中用管道(|)或重定向(<)传递数据时走的就是它。程序无需知道输入来自键盘、文件还是上一条命令的输出,连接由 shell 负责,这正是 Unix 管道成立的原因。但在 MCP 中,stdin 的地位发生了变化。在 stdio 传输下,stdin 与 stdout 不只是承载数据的通道,它们本身就是通信信道。本地 MCP 服务器是客户端启动的子进程,不开套接字也不开端口,通过 stdin 接收以换行分隔的 JSON-RPC 消息,并从 stdout 返回。因此规范明确规定:服务器不得向自己的 stdout 写入任何非有效 MCP 消息的内容。一行 console.log、一条库的横幅、一个 npm 警告混入该 JSON 流,客户端解析器就会崩溃。当 MCP 服务器显示连接失败时,这是首先要怀疑的原因,解决办法是把所有日志改送到 stderr。

터미널 화면 안에 프로세스 상자가 있고, 0번 화살표가 상자로 들어가고 1번과 2번 화살표가 각각 다른 방향으로 나간다
통로 세 개: 0으로 읽고, 1로 내보내고, 2로 로그한다

stdin 是什么

在终端里待久了就会遇到 stdin 这个词。它译作标准输入,实体却很简单:程序读取输入的默认通道。

操作系统启动进程时会默认打开三条带编号的通道,这些编号称为文件描述符。0 号是 stdin,1 号是 stdout(标准输出),2 号是 stderr(标准错误)。程序从 0 号读入,把结果写到 1 号,把错误和日志写到 2 号。

关键在于程序不需要知道这条通道连到了哪里。可能是键盘,可能是文件,也可能是上一条命令的输出。连接由 shell 代为完成,程序只管读 stdin。Unix 管道能够成立,原因正在于此。

而 1 号与 2 号分开这一点,是本文后半部分的核心。给人看的日志和要交给下一个程序的数据不能混在一起,所以通道从一开始就分成了两条。

名称编号(FD)方向默认连接用途
stdin0输入键盘程序读入的数据
stdout1输出屏幕交给下一个程序的结果
stderr2输出屏幕给人看的日志与错误

CLI 中 stdin 如何使用

终端画面显示三条向 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
EOF

isatty —— CLI 卡住的真正原因

终端画面显示检测 stdin 是否为终端的命令,以及让卡住的 CLI 不再等待输入的命令。

这里有一个新手最常踩的坑:cron 或 CI 脚本里的 CLI 毫无输出地卡住不动。

原因多半在 stdin。程序想问人一句话而在等待输入,可那里没有人。在终端里会弹出提示,情况看得见;在后台则什么也看不到,就那样永远卡着。

所以做得好的 CLI 会先判断 stdin 是否连着终端,这个检查叫 isatty。是终端就说明有人在,于是发问;不是就直接读管道送来的数据。同一条命令既能交互使用又能在脚本里跑,靠的就是这个分支。

别人的 CLI 卡住时,应对有两条。有 --non-interactive 或 -y 这类参数就先用;没有就给 stdin 接一个空的东西。命令末尾加上 < /dev/null,程序立刻遇到输入结束,便会放弃提问。在 cron 脚本里养成这个习惯,原因不明的卡死会大幅减少。

isatty 分支与避免卡死
# 在 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 服务器写成两个连字符后接可执行命令的形式,说的是同一件事。

注册一个 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 是干净的
# 直接把服务器跑起来,看 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。