一、前言

这份教程的目标有两个:

  1. 讲清楚 MCP 是什么、解决什么问题、消息是怎么流动的。
  2. 用 C++ 实现一个最小可用的 MCP 服务,并说明如何接到 Codex CLI。

一、MCP 的概念

MCP,全称 Model Context Protocol,可以理解成:让大模型和外部工具、外部数据源用统一方式说话的协议。

如果没有 MCP,大模型要调用一个工具,通常会遇到这些问题:

  • 每个工具的调用方式都不一样
  • 有的走命令行,有的走 HTTP,有的走本地脚本
  • 工具返回格式不统一
  • 客户端很难自动发现“这个服务到底提供了哪些能力”

MCP 想解决的就是这些问题,它给出了一套统一规则:

  • 客户端怎么发现服务提供了哪些工具
  • 客户端怎么调用工具
  • 服务端怎么返回结果
  • 双方怎么在建立连接时确认自己支持哪些能力

官方文档把 MCP 说明为一个由多层组成的协议,至少包括:

  • 基础消息协议
  • 生命周期管理
  • 服务端能力,例如 tools、resources、prompts

在这里插入图片描述

参考:

  • Overview: https://modelcontextprotocol.io/specification/2025-11-25/basic
  • Architecture overview: https://modelcontextprotocol.io/docs/learn/architecture

二、MCP 里的角色

MCP里面主要可以抽象出3个角色:客户端、服务端、工具,下面详细介绍

2.1 客户端

客户端是“使用 MCP 服务的一方”,比如:

  • Codex CLI
  • Claude Code
  • Trae IDE

客户端负责:

  • 启动本地 MCP 服务,或者连接远程 MCP 服务
  • 读取服务能力
  • 在合适的时候调用工具

2.2 服务端

服务端是“提供能力的一方”,它负责:

  • 告诉客户端自己有哪些工具
  • 接收工具调用请求
  • 执行业务逻辑
  • 返回结构化结果

2.3 工具

工具就是服务端暴露出来的可调用动作,例如:

  • add_number
  • query_weather
  • search_infomation

工具可以理解成“远程函数”或者“协议化函数入口”。在这里插入图片描述

三、MCP 最常见的三类能力

MCP 里最常见的是这三类:

  • tools
  • resources
  • prompts

3.1 tools

工具,重点是“执行动作”,例如:

  • 转码视频
  • 查天气
  • 调数据库查询
  • 创建文件

3.2 resources

资源,重点是“读取内容”,例如:

  • 读取某个文件
  • 读取一段数据库记录
  • 提供某个 URI 对应的数据

3.3 prompts

提示模板,重点是“给客户端一套可复用提示结构”

四、 MCP 底层通讯格式

MCP 的消息格式基于 JSON-RPC 2.0,也就是说,双方传的每条消息本质上都是 JSON 对象,常见字段有:

  • jsonrpc
  • id
  • method
  • params
  • result
  • error

官方说明:

  • 所有消息都必须符合 JSON-RPC 2.0
  • JSON-RPC 消息必须用 UTF-8 编码

参考:

  • Overview: https://modelcontextprotocol.io/specification/2025-11-25/basic
  • Schema reference: https://modelcontextprotocol.io/specification/2025-11-25/schema

五、stdio 是什么

本地 MCP 最常见的传输方式是 stdio,意思很简单:

  • 客户端启动一个子进程
  • 服务端从 stdin 读请求
  • 服务端往 stdout 写响应

官方 draft 传输说明里,stdio 的关键点是:

  • 服务端从 stdin 读 JSON-RPC 消息
  • 服务端往 stdout 写 JSON-RPC 消息
  • 一条消息一行
  • stdout 不能输出任何非协议内容
  • 日志应该写到 stderr

参考:

  • Transports: https://modelcontextprotocol.io/specification/draft/basic/transports

因此,如果你的服务端往 stdout 打了调试日志,就可能把协议打坏。

六、MCP 调用流程

先看最基础、最常见、最容易理解的流程。

6.1 初始化

客户端先发:

{
	"jsonrpc": "2.0",
	"id": 1,
	"method": "initialize",
	"params": {
		"protocolVersion": "2024-11-05",
		"capabilities": {},
		"clientInfo": {
			"name": "example-client",
			"version": "1.0.0"
		}
	}
}

字段解析:

  1. “jsonrpc”: “2.0”:固定不变,表示这是 JSON-RPC 2.0 格式,MCP 就是基于这个协议的。
  2. “id”: 1,客户端给这次请求的编号,你回复时必须把 id 原样返回,不然客户端不认。
  3. “method”: "initialize"握手,客户端说:我要连接你了,我们先对一下版本和信息。
  4. “params”: { … } 客户端告诉你 3 件事:
    • protocolVersion”: “2024-11-05”,我用的 MCP 版本是 2024-11-05,你必须回复同样的版本,不然连不上。
    • capabilities”: {},我支持的功能是空的(代表只支持基础功能)
    • clientInfo”,客户端自我介绍:我是谁(name)我的版本(version)比如:Claude、Cursor、自研客户端 都会发这个

服务端回:

{
	"jsonrpc": "2.0",
	"id": 1,
	"result": {
		"protocolVersion": "2024-11-05",
		"capabilities": {
			"tools": {
				"listChanged": false
				}
			},
	"serverInfo": {
	"name": "cpp-demo",
	"version": "0.1.0"
		}
	}
}

字段解析:

  • “jsonrpc”: “2.0”:固定不变,表示这是 JSON‑RPC 2.0 格式,MCP 就是基于这个协议的。
  • “id”: 1,客户端给这次请求的编号,你回复时必须把 id 原样返回,不然客户端不认。
  • “result”: {…},服务端初始化成功返回的数据体,握手成功必须返回 result,失败返回 error。
  • “protocolVersion”: “2024‑11‑05”,我支持的 MCP 版本是 2024‑11‑05,必须和客户端版本一致,不然连不上。
  • “capabilities”: {…},服务端告诉客户端自身具备的能力:
    • tools”: {…},声明本 MCP 服务提供工具调用能力。
    • listChanged”: false,代表工具列表静态不变,不会运行时动态增删工具。
  • “serverInfo”: {…},服务端自我介绍:我是谁(name)、我的版本(version),用于客户端识别服务。

然后客户端再发一个通知:

{
	"jsonrpc": "2.0",
	"method": "notifications/initialized"
}

字段解析

  • “jsonrpc”: “2.0”:固定不变,表示这是 JSON‑RPC 2.0 格式,MCP 就是基于这个协议的。
  • “method”: “notifications/initialized”:通知类型消息,客户端告知服务端初始化握手已完成,连接正式就绪

上述过程是一个典型的MCP初始化的握手过程

参考:

  • Lifecycle: https://modelcontextprotocol.io/specification/2024-11-05/basic/lifecycle

6.2 可用工具查询

客户端发:

{
	"jsonrpc": "2.0",
	"id": 2,
	"method": "tools/list"
}

字段解析:

  • “jsonrpc”: “2.0”:固定不变,表示这是 JSON-RPC 2.0 格式,MCP 就是基于这个协议的。
  • “id”: 2,客户端给这次请求的编号,你回复时必须把 id 原样返回,不然客户端不认。
  • “method”: “tools/list”:获取工具列表,客户端说:请把你提供的所有工具告诉我,我要让 AI 使用。

服务端回:

{
	"jsonrpc": "2.0",
	"id": 2,
	"result": {
	"tools": [
	  {
		"name": "echo_upper",
		"description": "把输入文本转成大写",
		"inputSchema": {
		  "type": "object",
		  "properties": {
			"text": {
			  "type": "string"
			}
		  },
		  "required": ["text"]
		}
	  }
	 ]
	}
}

字段解析:

  • “jsonrpc”: “2.0”:固定不变,表示这是 JSON-RPC 2.0 格式,MCP 就是基于这个协议的。
  • “id”: 2,客户端给这次请求的编号,你回复时必须把 id 原样返回,不然客户端不认。
  • “result”: {…}:服务端成功返回工具列表数据,请求正常处理。
  • “tools”: [...]:服务端把自己支持的所有工具列表返回给客户端。
    • “name”: “echo_upper”:工具的唯一名称,客户端通过这个名字调用工具。
    • “description”: “把输入文本转成大写”:工具的功能描述,给大模型理解用途。
    • “inputSchema”:工具的入参格式定义,告诉客户端和大模型需要传什么参数。
      • “type”: “object”:参数是一个对象格式。
      • “properties”: {“text”: { “type”: “string”} }:定义参数 text 是字符串类型。
      • “required”: ["text"]:声明 text 是必传参数,不传会报错。

6.3 调用工具

客户端发:

{
	"jsonrpc": "2.0",
	"id": 3,
	"method": "tools/call",
	"params": {
		"name": "echo_upper",
		"arguments": {
		    "text": "hello mcp"
		}
	}
}

字段解析:

  • “jsonrpc”: “2.0”:固定不变,表示这是 JSON-RPC 2.0 格式,MCP 就是基于这个协议的。
  • “id”: 3,客户端给这次请求的编号,你回复时必须把 id 原样返回,不然客户端不认。
  • “method”: “tools/call”:执行工具调用,客户端说:我现在要调用你提供的工具。
  • “params”: {…}:客户端告诉你本次调用的详细信息:
    • “name”: “echo_upper”:要调用的工具名称,服务端根据名字反射执行对应函数。
    • “arguments”: {“text”: “hello mcp”}:传给工具的实际参数,包含必填字段 text。

服务端回:

{
	"jsonrpc": "2.0",
	"id": 3,
	"result": {
		"content": [
			{
				"type": "text",
				"text": "HELLO MCP"
			}
		],
		"isError": false
	}
}

这就是一个最小工具服务的核心闭环。

七、实现一个简易MCP服务

7.1 实现目标

这里简单使用C++实现一个MCP服务,只实现tool,而 resourcesprompts 暂不涉及,因为对初学者来说,tools 最直接。

tools 的思维模型很简单:

  • 定义一个动作
  • 列出来
  • 按名字调用
  • 返回结果

简易的MCP服务只需要支持以下功能即可:

  • 支持 stdio
  • 支持 initialize
  • 支持 notifications/initialized
  • 支持 tools/list
  • 支持 tools/call
  • 只暴露 1 个简单工具

实现后需要确认:

  • 进程能启动
  • 客户端能发现工具
  • 客户端能调用工具

这一步通了,基本就可以让agent接入我们的mcp服务了

7.2 代码实现

这里我们实现一个最小服务,名字叫 cpp-demo,它只提供 1 个工具:echo_upper,这个工具做的事很简单:输入一段字符串,返回它的大写版本

为了把例子写清楚,我们用一个非常常见的 JSON 库:nlohmann/json

项目地址:

  • https://github.com/nlohmann/json

完整 C++ 示例代码如下:

#include <algorithm>
#include <cctype>
#include <iostream>
#include <string>
#include <nlohmann/json.hpp>

using json = nlohmann::json;

static void log_error(const std::string& message) {
    std::cerr << "[mcp-cpp-demo] " << message << std::endl;
}

static std::string to_upper_copy(std::string s) {
    std::transform(s.begin(), s.end(), s.begin(), [](unsigned char ch) {
        return static_cast<char>(std::toupper(ch));
    });
    return s;
}

static json make_error_response(const json& id, int code, const std::string& message) {
    return {
        {"jsonrpc", "2.0"},
        {"id", id},
        {"error", {
            {"code", code},
            {"message", message}
        }}
    };
}

static json make_initialize_response(const json& id) {
    return {
        {"jsonrpc", "2.0"},
        {"id", id},
        {"result", {
            {"protocolVersion", "2024-11-05"},
            {"capabilities", {
                {"tools", {
                    {"listChanged", false}
                }}
            }},
            {"serverInfo", {
                {"name", "cpp-demo"},
                {"version", "0.1.0"}
            }}
        }}
    };
}

static json make_tools_list_response(const json& id) {
    return {
        {"jsonrpc", "2.0"},
        {"id", id},
        {"result", {
            {"tools", json::array({
                {
                    {"name", "echo_upper"},
                    {"description", "把输入文本转成大写"},
                    {"inputSchema", {
                        {"type", "object"},
                        {"properties", {
                            {"text", {{"type", "string"}}}
                        }},
                        {"required", json::array({"text"})}
                    }}
                }
            })}
        }}
    };
}

static json make_tool_call_response(const json& id, const std::string& text) {
    return {
        {"jsonrpc", "2.0"},
        {"id", id},
        {"result", {
            {"content", json::array({
                {{"type", "text"}, {"text", text}}
            })},
            {"isError", false}
        }}
    };
}

static void write_message(const json& message) {
    std::cout << message.dump() << '\n';
    std::cout.flush();
}

static void handle_message(const json& message) {
    if (!message.contains("jsonrpc") || message["jsonrpc"] != "2.0") {
        if (message.contains("id"))
            write_message(make_error_response(message["id"], -32600, "Invalid Request"));
        return;
    }

    const bool has_id = message.contains("id");
    const json id = has_id ? message["id"] : json(nullptr);
    const std::string method = message.value("method", "");

    if (method == "initialize") {
        if (!has_id) return;
        write_message(make_initialize_response(id));
        return;
    }

    if (method == "notifications/initialized")
        return;

    if (method == "tools/list") {
        if (!has_id) return;
        write_message(make_tools_list_response(id));
        return;
    }

    if (method == "tools/call") {
        if (!has_id) return;

        if (!message.contains("params") || !message["params"].is_object()) {
            write_message(make_error_response(id, -32602, "Invalid params"));
            return;
        }

        const auto& params = message["params"];
        const std::string& tool_name = params.value("name", "");

        if (tool_name != "echo_upper") {
            write_message(make_error_response(id, -32601, "Unknown tool"));
            return;
        }

        if (!params.contains("arguments") || !params["arguments"].is_object()) {
            write_message(make_error_response(id, -32602, "Missing arguments"));
            return;
        }

        const auto& arguments = params["arguments"];
        if (!arguments.contains("text") || !arguments["text"].is_string()) {
            write_message(make_error_response(id, -32602, "Argument 'text' must be a string"));
            return;
        }

        const std::string& input = arguments["text"].get<std::string>();
        const std::string output = to_upper_copy(input);
        write_message(make_tool_call_response(id, output));
        return;
    }

    if (has_id)
        write_message(make_error_response(id, -32601, "Method not found"));
}

int main() {
    std::ios::sync_with_stdio(false);
    std::cin.tie(nullptr);

    std::string line;
    while (std::getline(std::cin, line)) {
        if (line.empty()) continue;
        try {
            json message = json::parse(line);
            handle_message(message);
        } catch (const std::exception& ex) {
            log_error(std::string("JSON parse or handling error: ") + ex.what());
        }
    }
    return 0;
}

这个基于C++的MCP服务实现了:

  • 从标准输入按行读取 JSON-RPC
  • 处理初始化
  • 列出工具
  • 调用 echo_upper
  • 把日志写到标准错误

7.2.1 常见问题

1. 为什么用一行一条消息

因为 stdio 传输的 draft 说明里写得很清楚:

  • 一条消息一行
  • 不要在消息里塞多行

所以输出时用了:

std::cout << message.dump() << '\n';
2. 为什么日志写 stderr

因为 stdout 是协议通道,如果你这样写:

std::cout << "starting server..." << std::endl;

客户端看到的就不是合法 JSON-RPC 了,协议会坏掉,所以日志必须写到:

std::cerr
3. 为什么 notifications/initialized 不回消息

因为这是通知,不是请求,通知没有 id,不期待响应。

4. 为什么 tools/list 要带 inputSchema

因为客户端和大模型需要知道:

  • 这个工具叫什么
  • 它做什么
  • 它要什么参数
    inputSchema 就是参数说明书。

八、 本地测试

8.1 手动测试

1. initialize

可以先不用接 Codex CLI,直接手工喂消息,把程序跑起来后,输入一行:

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"manual-test","version":"0.1.0"}}}

可以看到一行响应 JSON:
在这里插入图片描述

2. notifications/initialized

{"jsonrpc":"2.0","method":"notifications/initialized"}

这里不应该有响应:
在这里插入图片描述

3. tools/list

{"jsonrpc":"2.0","id":2,"method":"tools/list"}

可以看到一行响应 JSON:
在这里插入图片描述

4. tools/call

{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"echo_upper","arguments":{"text":"hello mcp"}}}

可以看到一行响应 JSON:
在这里插入图片描述

8.2 接入Codex CLI 测试

我编译生成的.exe文件在这个目录:

D:\mcp_example\build\Release\mcp_cpp_demo.exe

那么在 C:\Users\Lenovo\.codex\config.toml 里加:

[mcp_servers.cpp_demo]
type = "stdio"
command = "D:\\mcp_example\\mcp_cpp_demo\\x64\\Debug\\mcp_cpp_demo.exe"
cwd = "D:\\mcp_example"
startup_timeout_sec = 120

在这里插入图片描述
字段解析:

  • [mcp_servers.cpp_demo]:定义一个 MCP 服务,名称为cpp_demo,用于标识当前服务配置。
  • typestdio,固定类型,表示服务通过标准输入输出进行通信,是 MCP 最常用的协议模式。
  • commandD:\\mcp_example\\build\\Release\\mcp_cpp_demo.exe,指定 MCP 服务端可执行文件的绝对路径,客户端会启动该程序。
  • cwdD:\\mcp_example,设置程序运行的工作目录,所有相对路径都以此目录为基准。
  • startup_timeout_sec120,客户端启动服务的超时时间(秒),120 秒内服务未正常响应则判定启动失败。

重启 Codex CLI 后,它就会把这个服务当作 MCP server 加载:
在这里插入图片描述
让codex调用这个mcp:
在这里插入图片描述

九、参考资料

  • MCP Overview: https://modelcontextprotocol.io/specification/2025-11-25/basic
  • MCP Lifecycle 2024-11-05: https://modelcontextprotocol.io/specification/2024-11-05/basic/lifecycle
  • MCP Transports draft: https://modelcontextprotocol.io/specification/draft/basic/transports
  • MCP Architecture overview: https://modelcontextprotocol.io/docs/learn/architecture
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐