从零搭建一个 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 开发系列的第一篇。下一篇:将桩服务替换为真实数据库。
Some information may be outdated