Skip to main content
Glama
Cynthiaflora

mcp-raw

by Cynthiaflora

mcp-raw

一个手写的MCP服务器和客户端——零依赖,不用 @modelcontextprotocol/sdk,不用TypeScript构建步骤。目的不是做成生产可用,而是让你看清楚MCP client和server通信时线上到底传了什么——这些东西被SDK的 McpServer/server.tool() 全部藏起来了。这里实现了两种transport:stdio和Streamable HTTP,协议逻辑(rpc.js)完全共用,只有"消息怎么进、响应怎么出"这一层不一样。

运行stdio版本:

npm run demo

这会把 server.js 作为子进程spawn起来,驱动它走完一整个session,把每条发出(>>>)和收到(<<<)的消息都打印出来。

运行Streamable HTTP版本:

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,协议本身是一样的:固定的生命周期(initializenotifications/initialized → 正常调用)加上几个方法(tools/listtools/call……)。这也是为什么 rpc.js 里的 handleMessage() 不知道自己是被stdio还是HTTP调用的——协议逻辑本该和transport无关。SDK做的所有事情——zod schema、session管理、McpServer.registerTool()——都只是在这层协议之上加的便利封装。

Related MCP server: node-mcp-poc

消息的四种形态

区分依据是有没有 id,以及是请求还是响应:

// 请求 —— 发送方期待一个响应
{ "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回应自己的 protocolVersioncapabilities(支持哪些能力——这里只有 { tools: {} })和 serverInfo。对应 server.js 里的 handleInitialize

  2. Client → Server:notifications/initialized 一条通知(没有id),确认client接受了这次握手。Server只是记录一下——不发任何响应,因为通知本来就不该有响应。

  3. 正常操作阶段。 现在 tools/listtools/call 才被允许调用。server.js 用一个 initialized 标志强制这个顺序——初始化完成前发别的方法(除了initialize本身)都会被拒绝,返回 -32002 Server not initialized。真实的server也会强制同样的顺序;这个demo只是把它显式写出来了,而不是默默假设client会守规矩。

  4. tools/list Server返回工具的元信息:namedescriptioninputSchema——纯 JSON Schema,不是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.jsserver.js多出来的部分:

  • 单一端点,三种方法。 都是 /mcpPOST发消息(这个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,两种响应形态。 initializetools/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_timeechoadd),都是 { 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,应该就能读成"同样这几步,只是交给了一个库去做记账"。

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    A demonstration MCP server supporting both Stdio and SSE transports, providing example tools (echo, add, time, UUID generation) and resources for learning and testing MCP implementations.
    4 npm
    MIT
  • F
    license
    Not graded
    quality
    D
    maintenance
    A minimal MCP server that exposes tools for addition, echoing text, time lookup, and URL fetching, with support for HTTP and stdio transports.
    -
  • F
    license
    Not graded
    quality
    C
    maintenance
    A model-agnostic MCP server exposing example tools (add1, multiply2, greet) for learning purposes, working with any LLM through stdio transport.
    -