mcp-raw
by Cynthiaflora
README.md
# mcp-raw
一个手写的MCP服务器和客户端——零依赖,不用 `@modelcontextprotocol/sdk`,不用TypeScript构建步骤。目的不是做成生产可用,而是让你看清楚MCP client和server通信时线上到底传了什么——这些东西被SDK的 `McpServer`/`server.tool()` 全部藏起来了。这里实现了两种transport:stdio和Streamable HTTP,协议逻辑(`rpc.js`)完全共用,只有"消息怎么进、响应怎么出"这一层不一样。
运行stdio版本:
```bash
npm run demo
```
这会把 `server.js` 作为子进程spawn起来,驱动它走完一整个session,把每条发出(`>>>`)和收到(`<<<`)的消息都打印出来。
运行Streamable HTTP版本:
```bash
npm run demo:http
```
这会把 `http-server.js` 起在 `localhost:3100` 上,用 `http-client.js` 发真实HTTP请求驱动同一套流程,把每次请求/响应的method、header、body都打出来。
## 一段话讲完整个协议
MCP就是通过某种transport交换JSON-RPC 2.0消息。**Transport**只负责搬运字节——stdio和Streamable HTTP是两种不同的搬运方式。不管用哪种transport,**协议**本身是一样的:固定的生命周期(`initialize` → `notifications/initialized` → 正常调用)加上几个方法(`tools/list`、`tools/call`……)。这也是为什么 `rpc.js` 里的 `handleMessage()` 不知道自己是被stdio还是HTTP调用的——协议逻辑本该和transport无关。SDK做的所有事情——`zod` schema、session管理、`McpServer.registerTool()`——都只是在这层协议之上加的便利封装。
## 消息的四种形态
区分依据是有没有 `id`,以及是请求还是响应:
```jsonc
// 请求 —— 发送方期待一个响应
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }
// 通知 —— 完全没有 "id"。永远不会有响应。看server.js里对
// notifications/initialized 的处理:直接return,什么都不发回去。
{ "jsonrpc": "2.0", "method": "notifications/initialized", "params": {} }
// 成功响应
{ "jsonrpc": "2.0", "id": 1, "result": { "tools": [...] } }
// 错误响应
{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32601, "message": "Method not found: bogus" } }
```
## 分帧方式(stdio上消息怎么分隔)
一行一条JSON消息,换行结尾,stdout上不能有别的任何东西。看 `server.js` 里的 `send()`——每次写入都是 `JSON.stringify(message) + "\n"`。这就是stdio传输的全部分帧协议了,没有长度前缀header(那是LSP的做法,MCP的stdio传输长得像它但不是同一个协议)。
**踩了会让一切崩掉的一条规矩:** stdout专门留给协议消息用。任何混进这条流里的 `console.log` 都会污染它——对方那边下一次 `JSON.parse` 就会失败,或者更糟,悄悄解析错。这也是为什么 `server.js` 里所有日志都走 `console.error`(stderr)而不是stdout。
## 生命周期,一步一步来
1. **Client → Server:`initialize` 请求。** Client提议一个协议版本,介绍自己(`clientInfo`)。Server回应自己的 `protocolVersion`、`capabilities`(支持哪些能力——这里只有 `{ tools: {} }`)和 `serverInfo`。对应 `server.js` 里的 `handleInitialize`。
2. **Client → Server:`notifications/initialized`。** 一条通知(没有`id`),确认client接受了这次握手。Server只是记录一下——不发任何响应,因为通知本来就不该有响应。
3. **正常操作阶段。** 现在 `tools/list` 和 `tools/call` 才被允许调用。`server.js` 用一个 `initialized` 标志强制这个顺序——初始化完成前发别的方法(除了`initialize`本身)都会被拒绝,返回 `-32002 Server not initialized`。真实的server也会强制同样的顺序;这个demo只是把它显式写出来了,而不是默默假设client会守规矩。
4. **`tools/list`。** Server返回工具的元信息:`name`、`description`、`inputSchema`——纯 [JSON Schema](https://json-schema.org/),不是zod。你在 `mcp-starter` 项目里写的 `z.string()`,SDK在发出去之前会转换成 `{ "type": "string" }`。这个demo因为没有zod做这个转换,就直接手写了这个JSON Schema(见 `tools.js`)。
5. **`tools/call`。** Server按 `params.name` 找到对应工具,跑 `handler(params.arguments)`,把返回值包成 `{ content: [{ type: "text", text: ... }] }`。如果handler抛异常,结果会带上 `isError: true`,而不是让整次RPC调用失败——工具执行失败是一个合法的*结果*,不是协议层的错误。可以试试:demo最后特意用非数字参数调用 `add`,打印出了那个 `isError: true` 的响应。
## Streamable HTTP:和stdio比多了什么
stdio里"一个进程=一个session",进程本身就是天然的隔离边界。HTTP里一个server进程要同时服务多个client,所以协议要求显式管理session,这就是`http-server.js`比`server.js`多出来的部分:
- **单一端点,三种方法。** 都是 `/mcp`:`POST`发消息(这个demo的主路径),`GET`开一条独立SSE流给server主动推送通知(这个demo没有需要推送的场景,直接返回405并说明原因,没有假装支持),`DELETE`终止session。
- **`Mcp-Session-Id` header。** `initialize`请求是唯一允许不带这个header发的请求——因为session还不存在。Server在`initialize`的响应头里把新生成的session id带回去(`http-server.js`里的`randomUUID()`),之后client每次请求都要原样带上它。带了无效session id → 404;该带却没带 → 400。跑`npm run demo:http`能看到这几种情况全部触发一遍,包括最后DELETE之后再请求拿到404。
- **一次POST,两种响应形态。** `initialize`和`tools/list`直接回一个JSON对象(`Content-Type: application/json`);`tools/call`走的是`text/event-stream`(SSE),哪怕这里的工具都是同步瞬时返回、流里只有一个`data: ...`事件——这么写是为了展示协议允许的另一条路径:真实场景里,一次耗时的调用可以在给出最终结果前,在同一条连接上先推送若干进度事件。
- **通知的响应是202,不是"没有响应".** 协议规定:POST body如果只包含通知(没有id),server必须回`202 Accepted`、空body,而不是完全不回应——HTTP请求总要有个响应,只是这个响应不携带JSON-RPC语义上的"结果"。
## 文件说明
- `rpc.js` —— 和transport无关的协议核心:session状态、`initialize`/`tools/list`/`tools/call`的处理逻辑、错误码。stdio和HTTP都调这一份。
- `server.js` —— stdio transport:把消息一行行读进来,调`rpc.js`,把响应一行行写出去
- `http-server.js` —— Streamable HTTP transport:单端点/mcp,session管理,JSON/SSE两种响应形态
- `tools.js` —— 三个demo工具(`get_current_time`、`echo`、`add`),都是 `{ name, description, inputSchema, handler }` 这种普通对象
- `client.js` —— spawn起`server.js`,走stdio驱动它完整走一遍生命周期,打印每条消息
- `http-client.js` —— spawn起`http-server.js`,用`node:http`发真实HTTP请求走一遍同样的生命周期,打印每次请求/响应的method、header、body
## 和 mcp-starter 对比
`mcp-starter` 项目(同级目录)走的是同一套协议,但通过 `@modelcontextprotocol/sdk` 实现:用 `McpServer.registerTool()` 代替手写的 `switch(method)`,用 `zod` schema代替手写的JSON Schema,用 `StreamableHTTPServerTransport`(内部靠`@hono/node-server`)代替这里的 `http.createServer()`。等这两个demo的流程看明白了,再看那个项目的 `src/index.ts`,应该就能读成"同样这几步,只是交给了一个库去做记账"。
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues