LOADING
2572 words
13 minutes
从零搭建一个 MCP 服务:MCP Blog Studio 开发手记

从零搭建一个 MCP 服务:MCP Blog Studio 开发手记

协议骨架已经跑通,但距离真正可用还缺什么?

前言

最近在做一个项目:MCP Blog Studio。

目标不是写一个 MCP Demo,而是做一个真正可以被 Codex 等 MCP Client 远程调用的博客管理服务。这篇文章记录 v1 基础框架的搭建过程和思考。

一、MCP 到底是什么?

MCP(Model Context Protocol)是 Anthropic 提出的开放协议,定义了大语言模型与外部工具、数据源之间的标准通信方式。你在 Cursor、Claude Desktop 里用的那些工具——读文件、查数据库、发请求——背后就是 MCP。

从协议层面看,MCP 基于 JSON-RPC 2.0,核心交互只有寥寥几个方法:

initialize 协商协议版本和能力
tools/list 获取工具列表
tools/call 调用某个工具

但就是这看似简单的协议,支撑了一个完整的”AI Agent 调用外部能力”的生态。

MCP SDK 替你做了 JSON-RPC 消息的序列化、协议协商、工具发现、错误包装。

你需要自己做的 是工具的业务实现、认证鉴权、数据持久化。

二、第一阶段的目标:先跑通协议

第一版不急着接数据库和完整权限系统。目标只有一个:

先把远程 MCP 的协议层跑通。

最终需要得到这样一条调用链:

Codex / MCP Client
│
│ MCP Streamable HTTP
▼
┌─────────────────────────┐
│ MCP Blog Studio │
│ │
│ MCP Transport ── 传输 │
│ ↓ │
│ Tool Registry ── 注册 │
│ ↓ │
│ Service Layer ── 业务 │
│ ↓ │
│ Blog System ── 系统 │
└─────────────────────────┘

这条链路打通之后,再来填充业务逻辑。

三、项目结构

MCP 部分大致拆成这样:

src/
├── app/
│ └── mcp/
│ └── route.ts # /mcp HTTP 路由
│
└── mcp/
├── server.ts # MCP Server 注册入口
├── contracts.ts # 核心契约(DTO、错误码、接口)
├── tools/
│ ├── read.ts # 6 个读工具
│ └── write.ts # 6 个写工具
└── services/
└── stub.ts # 桩服务(内存模拟数据)

这里刻意把 MCP Protocol → Tool → Service 三层拆开。

MCP Tool 本身不负责真正的数据操作。例如 create_post 不会直接 INSERT INTO posts ...,而是:

create_post
↓
BlogService.createPost()
↓
具体 Service 实现(当前为 Stub,可替换为 Database)

调用链清晰,每一层都可以独立替换。

四、注册的 12 个工具

按照设计文档,v1 阶段需要 12 个工具,分为两组:

6 个读工具(B 负责):

工具用途
get_current_user获取当前认证用户信息
list_posts获取文章列表,支持分页和筛选
get_post获取单篇文章详情(含 Markdown 正文)
search_posts搜索文章(标题/摘要/正文)
list_categories获取分类列表
list_media获取媒体文件列表

6 个写工具(A 负责):

工具用途
create_post创建文章草稿(幂等)
update_post修改文章(乐观锁)
publish_post发布文章(草稿 → 已发布)
unpublish_post下架文章(已发布 → 草稿)
trash_post移入回收站
restore_post从回收站恢复

每个工具都声明了完整的 inputSchema(参数类型、必填字段、枚举值),Client 可以通过 tools/list 发现全部工具的定义。

五、为什么第一版使用桩服务?

第一版没有直接接数据库,而是使用了一个简单的 Map<string, Post> 保存文章。

这样做是故意的。

因为这一阶段真正需要验证的是:

MCP Server 能不能初始化?
Client 能不能建立连接?
Tools 能不能被发现?
参数能不能传进来?
Tool 能不能调用 Service?
结果能不能通过 MCP 返回?

而不是:

数据库 ORM 有没有配对?迁移有没有跑通?连接池有没有溢出?

如果第一天就同时引入 MCP + Database + Authentication + Authorization,任何一个地方出问题,排查范围都会变得非常大。

所以第一版只验证协议层。先让链路通,再让数据真。

六、验证结果

通过 curl 模拟 MCP Client 发送 JSON-RPC 请求,逐一验证:

第一步:initialize 握手

← {"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}}}}

第二步:tools/list 发现能力

← {"result":{"tools":[
{"name":"get_current_user","inputSchema":{...}},
{"name":"create_post","inputSchema":{...}},
// ... 共 12 个工具
]}}

第三步:调用 create_post 创建文章

→ {"method":"tools/call","params":{"name":"create_post",
"arguments":{"title":"测试文章","markdown":"# Hello"}}}
← {"result":{"content":[{"type":"text","text":"{\"id\":1,\"title\":\"测试文章\",\"state\":\"draft\"}"}],"isError":false}}

第四步:list_posts 确认数据可读

← {"result":{"content":[{"type":"text","text":"{\"items\":[{\"id\":1,...}],\"total\":1}"}]}}

全部 200 OK。这意味着最基础的 Client ↔ Streamable HTTP ↔ MCP Server 链路已经跑通。

七、但成功调用不代表 MCP 已经可用

验证通过后,我重新审视了当前状态。发现问题不少:

检查项当前结果
TypeScript 类型检查✅ 通过
MCP 初始化✅ 通过
Tools Discovery✅ 发现 12 个工具
查询身份⚠️ 可调用,但为模拟身份
创建草稿⚠️ 可调用,但使用内存数据
真实博客数据库❌ 未接入
Authentication❌ 未完成
Authorization❌ 未完成
创建幂等❌ 未完成
完整参数校验❌ 未完成
多客户端 Session❌ 存在问题
Structured Output❌ 未完成

当前准确的状态是:MCP 协议基础骨架已经跑通,但业务层仍处于 Stub 阶段。

距离真正可用,还需要补齐以下关键能力:


问题一:数据只存在内存里,重启即丢失

Service 层使用 Map 保存文章:

const posts = new Map<number, Post>()

Server 重启后,所有数据全部消失。所以目前所谓的 create_post,实际上只能用于测试 MCP 调用链。下一步必须变成:

create_post → BlogService → Database → posts

问题二:根本没有真正的身份认证

测试过程中发现:不提供任何密钥,同样可以调用 MCP。 而且当前所有写操作使用固定管理员上下文——相当于任何 Client 都是 Admin。

后续需要:

Client → Authorization → API Key → resolveIdentity()
↓
userId + role + permissions
↓
Tool Call

问题三:requestId 没有真正实现幂等

创建文章时已经设计了 requestId 字段,希望实现”相同 ID 只创建一次”。但实际测试结果是:相同 requestId 重复调用了两次,创建了两篇文章。

这在 Agent 场景尤其危险。MCP Client 完全可能因为网络超时、重试、模型重复调用等原因再次执行同一 Tool。对 get_post 问题不大,但对 create_post、publish_post 就可能产生真实副作用。

所有写操作都必须认真处理幂等问题。幂等记录需要与创建动作在同一事务中写入,不能仅依赖内存缓存或”先查后建”。


问题四:有 Schema,不代表有 Validation

测试时故意传入非法 UUID,按预期应该返回 Validation Error,但实际结果是——创建成功。

这说明契约虽然存在,但输入约束没有落实到运行时。对于 MCP 这种给 AI Agent 调用的接口,你不能假设”模型一定会按照我们的预期生成参数”。Server 需要把 Client 输入视为不可信输入。


问题五:多客户端 Session 有 Bug

单客户端测试一切正常,但第二个客户端连接时返回 400。排查后发现当前在全局共享一个 Transport。对于单 Client Demo 可能没问题,但部署到生产环境,显然不能假设永远只有一个客户端。


问题六:缺少 Structured Output

目前 Tool 返回的是普通文本,但契约要求进一步提供 structuredContent 和 outputSchema。这样 Client 不需要从一大段文本里重新解析数据——Agent 可以直接拿到结构化的 { postId, status, title },后续调用 update_post、publish_post 都会更加稳定和可靠。

八、一个容易被忽视的点:服务层的独立接口

第一版做对了一件事:Service 层接口是独立于 MCP 协议层的。

interface BlogService {
createPost(ctx: ActorContext, params: CreatePostParams): Promise<PostSummary>
updatePost(ctx: ActorContext, params: UpdatePostParams): Promise<PostSummary>
// ...
}

这意味着:

  • 你可以把 StubBlogService 替换成 PostgresBlogService,MCP 层不需要改
  • 同一个 Service 接口可以被后台管理页面、REST API 和 MCP 共用
  • 工具层只负责参数映射、SDK 注册和结果包装,不触碰业务逻辑

这一点在双人协作时特别重要:A 开发者负责写入和权限,B 开发者负责协议和查询,双方只通过接口契约耦合。

九、关于 Session 模式的选择

在 Next.js App Router 中,Streamable HTTP Transport 可以选择**有状态(Stateful)和无状态(Stateless)**两种模式:

  • 有状态模式(当前采用):Transport 在内存中管理 Session,初始化时分配 Session ID,后续请求通过 Mcp-Session-Id 头关联会话。简单直接,适合单实例部署。
  • 无状态模式:每个请求创建新的 Transport。更适合 Serverless 环境,但对 Session 管理和消息追踪有更高要求。

当前选用有状态模式,因为 v1 阶段优先保证 Demo 场景的可用性。后续部署到 Vercel 时可以根据架构选择切换。

十、总结

第一版真正完成的并不是”一个可用的 MCP Blog Server”,而是:

Streamable HTTP → MCP initialize → Tool Discovery → Tool Call → Stub Service

这条基础链路。MCP Blog Studio 已经从代码骨架进入了协议可运行阶段。

回头看,这个阶段最有价值的收获不是代码,而是对于”一个生产可用的 MCP 服务”需要哪些组成部分的清晰认知:

┌──────────────────────────────────┐
│ 可用的 MCP 服务 │
│ │
│ ┌────────────────────────────┐ │
│ │ Transport Layer │ │
│ │ (Streamable HTTP) │ │
│ ├────────────────────────────┤ │
│ │ Authentication │ │
│ │ (Bearer API Key) │ │
│ ├────────────────────────────┤ │
│ │ Authorization │ │
│ │ (Role + Ownership) │ │
│ ├────────────────────────────┤ │
│ │ Tool Registry │ │
│ │ (12 Tools + Schema) │ │
│ ├────────────────────────────┤ │
│ │ Validation │ │
│ │ (Input / State / Ref) │ │
│ ├────────────────────────────┤ │
│ │ Idempotency │ │
│ │ (requestId + Revision) │ │
│ ├────────────────────────────┤ │
│ │ Business Logic │ │
│ │ (BlogService → Database) │ │
│ ├────────────────────────────┤ │
│ │ Audit │ │
│ │ (Request / Action Log) │ │
│ └────────────────────────────┘ │
└──────────────────────────────────┘

下一阶段目标:把 Stub Service 换成真正的 Blog Service,接入 Payload Local API 和 PostgreSQL。到那时,通过 MCP 创建的文章将真正写入数据库,打开博客后台就能看到。

下一篇会写如何实现这一步。


本文是 MCP Blog Studio 开发系列的第一篇。下一篇:将桩服务替换为真实数据库。

从零搭建一个 MCP 服务:MCP Blog Studio 开发手记
/posts/2026-10-1/从零搭建一个mcp服务/
Author
Atopos
Published at
2026-10-01
License
CC BY-NC-SA 4.0

Some information may be outdated