AgentDepthAgent 工程学习指南
输入概念或问题,检索正文和待编写目录。
第一次学习?从 L0 总览开始 →

MCP传输:让JSON消息跨进程交换

我的学习 →

正在读取学习记录…

1. 消息沿哪条路走

传输负责把消息从一个程序送到另一个程序。stdio使用标准输入输出管道,适合应用启动本地子进程;Streamable HTTP通过HTTP连接远端服务。

标准输入stdin是程序接收文字的通道,标准输出stdout是程序发送结果的通道,标准错误stderr是诊断日志通道。把它们想成服务窗口的收件口、回件口和内部工作日志:各走各的,接收方才容易解析。

客户端通过stdin发送JSON行,服务器通过stdout返回JSON行,通过stderr输出日志
协议消息与诊断日志分开。客户端分别解析响应和查看日志。

MCP2025-11-25的传输规范规定stdio消息使用UTF-8并以换行分隔。下面实际启动一个子进程,交换初始化和空目录消息。

2. 创建两个程序

同一目录创建stdio_server.py和stdio_client.py。

先在 PowerShell 创建本篇的独立目录并进入;路径可换成你自己的非系统盘位置:

Bash
mkdir -Force "F:\学习\agentdepth-l2-mcp-transport"
cd "F:\学习\agentdepth-l2-mcp-transport"

将本篇的 stdio_server.py、stdio_client.py、test_stdio_demo.py 保存到这个目录。保存全部文件后运行 dir *.py,核对上述文件名,再执行下文命令;新开窗口时先回到该目录,并按 Python 环境篇核对 python 的解释器路径。

Python
"""通过标准输入接收消息,分别输出协议响应与诊断日志。"""

import json
import sys


# 依据方法名构造响应,沿用收到的请求编号。
def reply(message):
    request_id = message.get("id")
    method = message.get("method")
    if method == "initialize":
        return {"jsonrpc": "2.0", "id": request_id, "result": {
            "protocolVersion": "2025-11-25", "capabilities": {"tools": {}},
            "serverInfo": {"name": "stdio-demo", "version": "0.1.0"}}}
    if method == "tools/list":
        return {"jsonrpc": "2.0", "id": request_id, "result": {"tools": []}}
    return {"jsonrpc": "2.0", "id": request_id,
            "error": {"code": -32601, "message": "Method not found"}}


def main():
    initialized = False
    ready = False
    for raw in sys.stdin:
        try:
            message = json.loads(raw)
            if not isinstance(message, dict):
                raise ValueError("object_required")
        except (json.JSONDecodeError, ValueError):
            print("invalid_input", file=sys.stderr)
            continue
        method = message.get("method")
        if method == "notifications/initialized":
            if initialized:
                ready = True
                print("initialized_notification", file=sys.stderr)
            else:
                print("notification_before_initialize", file=sys.stderr)
            continue
        if method == "initialize":
            initialized = True
            response = reply(message)
        elif method == "tools/list" and not ready:
            response = {"jsonrpc": "2.0", "id": message.get("id"),
                        "error": {"code": -32000, "message": "Not initialized"}}
        else:
            response = reply(message)
        print(json.dumps(response, ensure_ascii=False), flush=True)
        print("handled_method=" + str(method), file=sys.stderr)


if __name__ == "__main__":
    main()
Python
"""启动子进程,逐次发送消息,并分别读取响应与日志。"""
import json
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

def exchange():
    server = Path(__file__).with_name("stdio_server.py")
    process = subprocess.Popen([sys.executable, str(server)],
        stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
        text=True, encoding="utf-8")
    pool = ThreadPoolExecutor(max_workers=1)
    def send(message):
        # 一行一个JSON消息,flush让服务端及时收到。
        process.stdin.write(json.dumps(message, ensure_ascii=False) + "\n")
        process.stdin.flush()
    def receive(expected_id):
        # 在线程中等待一行,调用方最多等待5秒。
        line = pool.submit(process.stdout.readline).result(timeout=5)
        response = json.loads(line)
        if response.get("id") != expected_id or "error" in response:
            raise ValueError("响应编号或状态异常")
        return response
    try:
        send({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
            "protocolVersion": "2025-11-25", "capabilities": {},
            "clientInfo": {"name": "stdio-client-demo", "version": "0.1.0"}}})
        initialized = receive(1)
        if initialized["result"].get("protocolVersion") != "2025-11-25":
            raise ValueError("服务端协议版本与当前客户端不匹配")
        # 收到并核对初始化响应后,再发送准备通知和目录请求。
        send({"jsonrpc": "2.0", "method": "notifications/initialized"})
        send({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
        catalog = receive(2)
        process.stdin.close()
        if process.wait(timeout=5) != 0:
            raise RuntimeError("服务进程退出异常")
        return [initialized, catalog], process.stderr.read().splitlines()
    finally:
        if process.poll() is None:
            process.kill()
            process.wait(timeout=5)
        pool.shutdown(wait=True)
        for stream in (process.stdin, process.stdout, process.stderr):
            if hasattr(stream, "close"):
                stream.close()

if __name__ == "__main__":
    result, diagnostics = exchange()
    print("response_ids", [item["id"] for item in result])
    print("tools", result[-1]["result"]["tools"])
    print("stderr_events", diagnostics)
Bash
python stdio_client.py

输出:

TXT
response_ids [1, 2]
tools []
stderr_events ['handled_method=initialize', 'initialized_notification', 'handled_method=tools/list']

response_ids是两个响应编号,分别对应初始化和目录请求。准备通知没有编号,因此这里有两条响应。tools为空列表,表示服务声明支持工具但当前目录为空。stderr_events显示处理消息的诊断记录。

客户端用subprocess.Popen启动服务器,send向stdin写入JSON行。先用receive取得初始化响应并核对版本,再发送准备通知和目录请求。每次读取在线程中最多等待5秒,finally负责关闭子进程和通道。stdout和stderr分别读取。服务器逐行解析,每条响应打印后flush,及时交给客户端;日志使用file=sys.stderr。

把服务器一条诊断print中的file=sys.stderr去掉,再运行,客户端解析stdout时会遇到非JSON日志。恢复之后成功。这就是分开通道的实际用途。

4. 选择本地与远端传输

传输进程关系配置重点
stdio应用启动本地子进程可执行路径、参数、环境与日志
Streamable HTTP客户端访问服务地址地址、认证、超时、会话与协议版本

HTTP客户端通过POST发送消息,服务可返回JSON或SSE事件流;GET可用于建立接收服务消息的流。SSE是一种HTTP上的逐条事件传递方式。不同版本传输规则可能变化,SDK、服务与规范版本需要对应。

本地子进程要有明确退出策略:客户端结束时关闭输入并等待退出,必要时终止进程。示例设置5秒超时;服务无法退出时,客户端可以定位子进程日志。远端连接则要设置连接与读取超时,并处理断线后的会话状态。

总结:方法决定消息内容,传输决定它怎样到达;协议走stdout,诊断走stderr。接着读 工具发现。

5. 验证消息与边界

测试文件保存到同一目录:

下面的测试启动本机子进程,检查初始化、准备完成通知和工具目录查询的消息交换,并核对异常消息与日志出口。这个服务返回空工具目录,工具发现篇继续学习分页收集和筛选目录。

测试代码:test_stdio_demo.py
Python
import unittest
import subprocess
import sys
import json
from pathlib import Path
from unittest.mock import patch
import io

from stdio_client import exchange
from stdio_server import reply


class StdioTests(unittest.TestCase):
    def test_initialize_error_stops_before_notification(self):
        sent = []
        class Input(io.StringIO):
            def write(self, text):
                sent.append(json.loads(text))
                return super().write(text)
        class Fake:
            stdin = Input()
            stdout = io.StringIO(json.dumps({"jsonrpc":"2.0","id":1,
                "error":{"code":-32602,"message":"Unsupported version"}})+"\n")
            stderr = io.StringIO("")
            def wait(self, timeout=None): return 0
            def poll(self): return 0
            def kill(self): pass
        with patch("stdio_client.subprocess.Popen", return_value=Fake()):
            with self.assertRaises(ValueError): exchange()
        self.assertEqual([item["method"] for item in sent], ["initialize"])
    def test_initialized_waits_for_initialize_response(self):
        # 假进程只在读取初始化响应后允许发送准备通知。
        class Input(io.StringIO):
            def write(self, text):
                if "notifications/initialized" in text:
                    self_test.assertTrue(fake.read_initialize)
                return super().write(text)
        class Output:
            def readline(self):
                if not fake.read_initialize:
                    fake.read_initialize = True
                    return json.dumps({"jsonrpc":"2.0","id":1,"result":{
                        "protocolVersion":"2025-11-25","capabilities":{"tools":{}}}})+"\n"
                return json.dumps({"jsonrpc":"2.0","id":2,"result":{"tools":[]}})+"\n"
        class Fake:
            read_initialize = False
            stdin = Input()
            stdout = Output()
            stderr = io.StringIO("")
            def wait(self, timeout=None): return 0
            def poll(self): return 0
            def kill(self): pass
        self_test = self
        fake = Fake()
        with patch("stdio_client.subprocess.Popen", return_value=fake):
            responses, _ = exchange()
        self.assertEqual([row["id"] for row in responses], [1,2])
    def test_subprocess_framing(self):
        responses, stderr = exchange()
        self.assertEqual([item["id"] for item in responses], [1, 2])
        self.assertEqual(responses[-1]["result"]["tools"], [])
        self.assertIn("initialized_notification", stderr)

    def test_diagnostics_do_not_pollute_protocol_stdout(self):
        responses, stderr = exchange()
        self.assertEqual(len(responses), 2)
        self.assertTrue(any(item.startswith("handled_method=") for item in stderr))

    def test_unknown_method_returns_json_rpc_error(self):
        self.assertEqual(reply({"id": 7, "method": "unknown"})["error"]["code"], -32601)

    def test_tools_list_before_initialize_is_rejected(self):
        server = Path(__file__).with_name("stdio_server.py")
        wire = json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) + "\n"
        output = subprocess.run([sys.executable, str(server)], input=wire,
                                capture_output=True, text=True, encoding="utf-8", timeout=5, check=True)
        self.assertEqual(json.loads(output.stdout)["error"]["message"], "Not initialized")


if __name__ == "__main__":
    unittest.main()
Bash
python -m unittest test_stdio_demo -v

恢复实验中的改动后运行测试。失败时先查看对应参数、编号或权限条件,再核对程序分支。

课后练习展开习题收起习题

4 道练习 · 客观题规则评分 · 其余题目待批改

正在读取本地记录…

不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。

练习 1 / 4 · v1.0.1

选择题 · 先判断边界

关于“MCP Transport 与生命周期”,哪种做法更稳妥?

对应知识点:L2-mcp-transport

选择一个答案
练习 2 / 4 · v1.0.0

简答题 · 解释失败

写出一个失败输入并说明记录字段。

对应知识点:L2-mcp-transport

练习 3 / 4 · v1.0.0

代码题 · 边界测试

补充正常、边界、失败三组输入和预期结果。

对应知识点:L2-mcp-transport

练习 4 / 4 · v1.0.0

场景题 · 结果未知

证据不足时如何设计状态和人工接管?

对应知识点:L2-mcp-transport

图片查看器
100%

文章配图 · 按 Esc 关闭