1. 今天要构建什么,以及它如何融入应用
回顾一下我们的教学应用:我们在做一个礼物推荐助手。在前面的模块里,我们已经有:
- ChatGPT 中的一个小部件(Next.js 16 + Apps SDK),用于展示 UI、状态,并能调用 callTool;
- 一个简单的后端(通过 Apps SDK / Next.js 路由),用于返回礼物的占位数据。
现在我们希望把助手的“大脑”拆到一个独立的 MCP 服务器中。最终的架构如下:
flowchart TD
subgraph ChatGPT
U[用户
在聊天中]
W["App 小部件
(Apps SDK)"]
end
subgraph MCP 客户端
C[ChatGPT MCP client]
end
subgraph OurServer[我们的 MCP 服务器]
T1[Tool: suggest_gifts]
R1[Resource: gift_catalog]
P1[Prompt: birthday_template]
end
U --> W
W -- callTool --> C
C <-- JSON-RPC / HTTP --> OurServer
OurServer --> C
C --> W
也就是说:
- ChatGPT 内的模型可以看到我们的 MCP 服务器,把它视作一套标准的 tools/resources/prompts;
- 来自小部件的 callTool 在逻辑上会转化为内部的 MCP 调用;
- 我们的服务器负责描述契约(模式与说明)并实现业务逻辑。
到本课结束,你应当拥有一个独立的 Node/TypeScript 项目,其中的 MCP 服务器可以:
- 用一条命令在本地启动;
- 至少注册一个工具和一个资源;
- 返回有意义的数据(即便只是简单的 mock);
- 具备可继续演进的合理结构。
同时,我们不会重写现有的 Apps SDK/Next.js 后端:它保持不变,而 MCP 服务器作为旁边的独立服务启动。之后你可以把它“挂”到 ChatGPT App 上,并逐步把礼物逻辑迁移过去,替换掉旧的占位实现。
2. 技术栈:TypeScript + MCP SDK + HTTP 传输
我们将用基于 Node.js 的 TypeScript来编写 MCP 服务器。MCP 的官方 JS/TS SDK 位于包 @modelcontextprotocol/sdk。它会代劳 JSON‑RPC、校验与模式转换等琐事:你用 Zod 模式描述参数,SDK 会自动把它们转换为模型可理解的 JSON Schema。
传输层我们需要HTTP 方案:ChatGPT 与远程 MCP 服务器通过网络通信,而不是通过 stdio/本地通道。MCP 规范描述了一种标准的“流式 HTTP”格式——本质上是旧 HTTP+SSE 的演进。在实践中就是一个 HTTP 端点,处理请求(POST/GET),并在需要时以流的形式返回响应。TypeScript 版 MCP SDK 一般已经内置兼容这种格式的传输实现,可以接到 Express 或 Hono 上。
为避免分散注意力,我们假设你已有:
- 服务器对象 McpServer(来自 @modelcontextprotocol/sdk);
- HTTP 传输(例如 StreamableHttpServerTransport 或类似实现),可与 Express 协作。
具体类名可能会随 SDK 版本略有差异,但架构上始终是:
- 创建一个 MCP 服务器对象;
- 在其上注册 tools/resources/prompts;
- 把传输对接到 HTTP 应用上。
3. 项目结构与准备
为 MCP 服务器新建一个独立文件夹。把它与前端应用放在一起但作为独立的 Node 项目管理会更方便:
chatgpt-gift-app/
app/ ← Next.js + Apps SDK(小部件)
mcp-server/ ← 我们的 MCP 服务器
在 mcp-server 中:
mcp-server/
src/
server.ts ← MCP 服务器入口
gifts.ts ← 礼物推荐的业务逻辑
package.json
tsconfig.json
一个简单的 gifts.ts 我们稍后再写,现在先专注 server.ts。
假设你已经初始化了项目:
mkdir mcp-server
cd mcp-server
npm init -y
npm install typescript ts-node-dev zod express @modelcontextprotocol/sdk
tsconfig.json——使用最普通的配置(esnext modules、目标 node、strict)。可以直接复用你任意一个 TS 项目的配置。
4. 将业务逻辑拆到独立模块
我们很想马上写出 server.registerTool(..., async () => {...}),并把所有逻辑都堆进去。但更好的做法是从一开始就拆分:
- 一个对 MCP 一无所知的模块,不关心 JSON‑RPC 等细节;
- 一个只关心 MCP 的模块,而尽量少知道业务逻辑。
在 src/gifts.ts 中先写一个简单的礼物推荐函数:
// src/gifts.ts
export type GiftIdea = {
id: string;
title: string;
price: number;
occasion: string;
};
export type SuggestGiftsInput = {
age: number;
relationship: "friend" | "partner" | "child" | "coworker";
budget: number;
};
export function suggestGifts(input: SuggestGiftsInput): GiftIdea[] {
// 先用一些 mock
return [
{
id: "book-1",
title: "与对方爱好相关的书",
price: Math.min(input.budget, 30),
occasion: "generic",
},
{
id: "game-1",
title: "适合聚会的桌游",
price: Math.min(input.budget, 50),
occasion: "party",
},
];
}
这个函数是纯函数:输入是参数,输出是创意列表。它可以做单元测试、在其他地方复用,而且与 MCP 无关。正如推荐实践:服务端“胶水”在一处,业务函数在另一处。
5. 创建 MCP 服务器并接入 HTTP 传输
现在来看入口 src/server.ts。大体需要:
- 创建一个 MCP 服务器实例;
- 在其上注册工具、资源与 prompt;
- 启动 HTTP 服务器(如 Express),并把 MCP 传输接入其中。
从一个脚手架开始:
// src/server.ts
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server";
import { StreamableHttpServerTransport } from "@modelcontextprotocol/sdk/transport/streamable-http";
const app = express();
// 1. 创建 MCP 服务器
const mcpServer = new McpServer({
name: "gift-assistant-mcp",
version: "0.1.0",
});
// 2. 这里稍后注册 tools/resources/prompts
// 3. 在 HTTP 之上配置传输
const transport = new StreamableHttpServerTransport({
path: "/mcp", // 单一 MCP endpoint
app, // 嵌入到 Express 应用
});
transport.attach(mcpServer);
const PORT = process.env.PORT ?? 4000;
app.listen(PORT, () => {
console.log(`MCP server listening on http://localhost:${PORT}/mcp`);
});
具体的传输类名也许不同,但模式一致:创建一个 HTTP 端点,并把 MCP 服务器作为 JSON‑RPC over HTTP/stream 的处理器接上去。
此时服务器还没做什么有用的事,但它已经可以:
- 完成 MCP 握手;
- 响应基础的发现(discovery)请求(tools/resources/prompts 列表——当前为空)。
下一步——注册第一个工具。
6. 通过 MCP SDK 注册工具 suggest_gifts
官方的 Apps SDK 与 MCP 文档都展示了类似的工具注册模式:调用 registerTool,传入名称、描述对象(标题、说明、参数模式)以及处理器。
我们已经在 gifts.ts 里定义了类型 SuggestGiftsInput。现在加上 Zod 模式,便于服务器校验输入参数,并自动向 LLM 提供正确的 JSON Schema。
// src/server.ts(片段)
import { z } from "zod";
import { suggestGifts } from "./gifts";
const suggestGiftsInputSchema = z.object({
age: z.number().int().min(0).max(120),
relationship: z.enum(["friend", "partner", "child", "coworker"]),
budget: z.number().min(0),
});
现在注册工具:
// 仍在 server.ts
mcpServer.registerTool(
"suggest_gifts",
{
title: "Suggest gift ideas",
description:
"根据年龄、关系类型与预算推荐礼物创意。",
// SDK 会把 Zod 模式转换成模型可理解的 JSON Schema
inputSchema: suggestGiftsInputSchema,
},
async ({ input }) => {
const ideas = suggestGifts(input);
const text = ideas
.map(
(g) =>
`• ${g.title} — ~${g.price} USD (occasion: ${g.occasion}, id: ${g.id})`
)
.join("\n");
return {
content: [
{
type: "text",
text,
},
],
// structuredContent 可用于小部件
structuredContent: {
ideas,
},
};
}
);
关键点:
- inputSchema 是 Zod 模式。TS 版 SDK 能把它转换为 JSON Schema,从而自动为模型描述该工具。
- 处理器接收包含 input 的对象(其类型来自模式)。你可以在内部调用你的业务函数。
- 在 result 中返回 content——这是模型可见的文本结果;如有需要,还可返回 structuredContent,其中的 JSON 结构可供你的小部件消费。
如果你在前面模块里用 Apps SDK 写过工具,这段代码应该很眼熟:模式完全相同,只是现在它位于一个独立的 MCP 服务器中。
7. 添加用于数据的资源 gift_catalog
工具是动作。而有时我们还希望提供“数据作为资源”,便于模型读取、搜索,或者为你的小部件按需加载模板、组件等。MCP 单独定义了带 URI、MIME 类型与内容的资源概念。
我们做一个简单的资源 gift_catalog,返回可用礼物列表。暂时仍用 mock;在实际项目里,它可以是数据库导出或商品 feed。
先是目录本身:
// src/gifts.ts(补充)
export const giftCatalog: GiftIdea[] = [
{
id: "book-1",
title: "编程类图书",
price: 25,
occasion: "learning",
},
{
id: "lego-1",
title: "LEGO 积木套装",
price: 60,
occasion: "fun",
},
];
再在服务器上注册该资源:
// src/server.ts(片段)
import { giftCatalog } from "./gifts";
mcpServer.registerResource(
"gift_catalog",
{
title: "Gift catalog",
description: "用于演示与调试的简单礼物目录。",
mimeType: "application/json",
},
async () => {
return {
contents: [
{
uri: "mcp://gift-catalog",
mimeType: "application/json",
text: JSON.stringify(giftCatalog, null, 2),
},
],
};
}
);
从逻辑上这里发生了什么:
- 资源名 gift_catalog 会在发现阶段暴露给客户端(在 MCP Inspector 里能看到该资源);
- 描述对象包含人类可读的说明与 MIME 类型;
- 处理器返回 contents 数组,包含 URI 与文本——这是 MCP 资源的标准格式。
之后你可以:
- 从客户端(例如代理或 Inspector)读取该资源;
- 将其作为 UI 模板/数据使用;
- 做一些实验:看看模型如何使用现成目录向用户解释备选项。
8. 注册一个简单的 prompt
MCP 的第三类实体是prompts(提示词),即预先准备好的提示。它们能避免重复冗长的系统或用户提示,把它们以名称形式存放在服务器端。
做个小例子:prompt birthday_gift,可作为“关于生日礼物的对话模板”来调用。
// src/server.ts(片段)
mcpServer.registerPrompt("birthday_gift", {
title: "Birthday gift helper",
description: "用于挑选生日礼物的请求模板。",
messages: [
{
role: "system",
content:
"你是一名礼物搜索助手。请提出澄清性问题,并提供若干备选方案。",
},
{
role: "user",
content:
"我需要一份生日礼物。请先询问必要的细节并帮助我选择。",
},
],
});
在底层,MCP 允许客户端:
- 获取 prompt 列表(在 Inspector 中能看到 birthday_gift);
- 请求其内容,并将其用作模型的基础提示。
在另一个关于 system‑prompt 与指令的模块中,我们会详细讲解这类 prompt 与应用全局指令如何协同。这里仅需把它们“看作” MCP 服务器的一部分即可。
9. 运行时它们如何协同工作
把全貌再梳理一下。
当客户端(例如 MCP Inspector 或 ChatGPT)连接到我们的 HTTP 端点 /mcp 时:
- 进行握手:客户端与服务器互换支持的能力信息(tools/resources/prompts 等);
- 客户端调用发现(discovery)方法:获取工具、资源、prompt 列表及其说明与模式;
- 当模型决定调用某个工具时,它会形成一个类似 tools/call 的 JSON‑RPC 请求——服务器端的 SDK 会把它转换为内部的 registerTool 处理器调用;
- 处理器执行业务逻辑(在我们这里是 suggestGifts 或返回 giftCatalog),并以标准格式返回结果;
- SDK 将响应重新序列化为 JSON‑RPC,通过相同的 HTTP/流式传输返回给客户端。
所有关于 JSON‑RPC、生成 id、方法路由等细节都隐藏在 @modelcontextprotocol/sdk 内部。对你而言,其接口与 Apps SDK 十分相似:你只需使用 registerTool/registerResource/registerPrompt 与对应的处理器,不必操心协议细节。
10. 本地启动与首个简单测试
假设你已经把以上内容都添加好了。接下来就是启动。
在 package.json 中加入脚本:
{
"scripts": {
"dev": "ts-node-dev src/server.ts"
}
}
运行:
npm run dev
控制台应看到类似输出:
MCP server listening on http://localhost:4000/mcp
完整的检查与手动调用工具我们会在下一课用 MCP Inspector / MCP Jam 来做。但现在仍可用 curl 做一个超简单的 smoke 测试:
curl -X POST http://localhost:4000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
这个 curl 只是给喜欢看“原始” JSON 响应的人做的可选 smoke 测试。在真正的开发中,你几乎总是通过 SDK 与 MCP 服务器交互,而不是手工拼 JSON‑RPC 请求。
具体的方法名取决于协议与 SDK 版本,但关键在于你会得到一个包含 tools 的 JSON 列表,其中能看到 suggest_gifts。如果方法名对不上也没关系:本课的目的不是死记硬背所有名称,而是让你不惧查看 JSON 响应并理解其结构,这在之前的课程里我们已经打好基础。
11. 与我们的 ChatGPT App 的关联与后续发展
目前 MCP 服务器是独立运行的。在接下来的模块中你将:
- 把它接入 MCP Inspector,并学习在不触碰 ChatGPT 的情况下,分别调试 tools/resources/prompts;
- 配置 ChatGPT App,使其将该 MCP 服务器识别为工具来源;
- 把此前在 Apps SDK 中实现的部分逻辑(例如通过内置 tools)迁移到 MCP 层;
- 在现有骨架上增加鉴权、日志、流式场景等能力。
此刻重点在于:
- 你有一个独立服务,负责应用的“能力”和“数据”;
- 该服务与客户端通过 MCP 标准通信,而不是自定义 REST;
- 你已经能不惧协议地手动注册工具、资源与 prompt 了。
12. 关于代码结构与一些最佳实践
即便是这么小的示例,也可以养成好习惯。
其一,把服务器配置独立出来。凡是关于名称、版本、日志、传输配置(端口、/mcp 路径)的东西,都可以放进一个小小的 config.ts。等你要部署到 Vercel 或放到 MCP 网关后面时,会需要添加环境变量,届时你会感谢现在的抽离。
其二,尽量让 registerTool/registerResource/registerPrompt 方法保持尽可能“薄”。模式定义、文案与业务逻辑都很适合放在独立文件里:
- gifts.ts——礼物选择函数;
- catalog.ts——商品目录相关;
- prompts.ts——prompt 集合。
这样 server.ts 就更像一个“MCP 提供者”,把一切拼装起来即可。
其三,记住 MCP 服务器的本质是响应式:它等待客户端连接与请求。这意味着任何阻塞或过慢的工具内部操作都会直接影响 ChatGPT 的体验。在后续模块我们会讨论超时、异步操作与流式响应,但现在就该思考哪些操作可以后台处理,哪些必须快速响应。
Insight:ChatGPT 仅支持 MCP 的一部分
务必理解:ChatGPT Apps 使用 MCP 作为传输与格式,但并不是一个完整的 MCP 客户端。如果只看协议,很容易对运行时行为产生不切实际的预期。
“纯” MCP 承诺的能力:
- 资源(resources)可以被客户端按需动态读取,而非“一次性读取”;
- 服务器可以发送 resourceChanged/toolChanged 通知,从而“推送”更新而无需重启客户端;
- 可以构建足够灵活的系统,用配置或外部状态来管理 tools/resources/prompts 的集合。
在ChatGPT Apps 的语境里并非如此。对应用而言,图景更偏静态:
- 在注册 App 时,ChatGPT 会读取所有 tools 与 resources 的描述一次;
- 随后该配置实际上被缓存为应用版本的一部分;
- 通过 MCP 通知进行的动态更新不受支持——平台会直接忽略它们。
13. 编写第一个 MCP 服务器时的常见错误
错误一:把全部业务逻辑都塞进 registerTool。
在工具处理器里“先快写一版”的诱惑很大,尤其在教学示例里。但它很快会变成一锅粥:校验、数据库访问、响应格式化全混在一起。更好的做法是尽早把业务函数(如 suggestGifts、目录处理)拆到独立模块,在处理器里只做“拼装”。
错误二:死绑某个具体的 MCP JSON 方法名。
有些同学会开始写 if (method === "tools/list") 并手动解析 JSON。这不需要:这正是 SDK 的工作。MCP 规格与方法名可能演进,而 SDK 会处理这些变化。请使用 registerTool、registerResource、registerPrompt,把“JSON‑RPC 长什么样”交给库。
错误三:不考虑传输层,试图把 ChatGPT 对接到 stdio 服务器。
Stdio 传输非常适合本地客户端(如桌面环境)把服务器作为子进程来启动。但 ChatGPT 通过 HTTPS 通信,它需要 HTTP/流式端点。试图“用隧道把 stdio 搞过去”只会徒增痛苦。面对 ChatGPT App,直接做 HTTP 传输(Streamable HTTP)即可。
错误四:忽视 MIME 类型与资源结构。
对资源而言,重要的不仅是内容,还有类型(mimeType)与 URI。如果处处都用 text/plain 并随意丢 JSON 字符串,客户端(与 Inspector)会更难理解这些数据是什么。请尽量提供正确的 MIME 类型(application/json、text/html 用于 UI 模板等)与稳定的 URI。
错误五:把 MCP 服务器当作“杂乱的 HTTP API”。
有时会产生这样的冲动:“既然我已经有了 Express,我再挂个 /api/whatever,直接去调它。”不建议把 MCP 端点与任意 REST 混在一起:这会让配置、路由与安全复杂化。更清晰的做法是:/mcp 专用于 MCP,其他用途用单独路径,甚至单独服务。在生产环境,这对配置网关与授权尤为重要。不要把 MCP 服务器变成“杂乱 HTTP‑API”——一堆与 MCP 契约无关的随机 HTTP 接口。
错误六:不记录进出站的 MCP 消息。
没有日志,MCP 服务器就是个黑箱:“哪里不工作我也不知道。”从第一个服务器开始,就至少在 stderr 打印简洁结构化日志:工具方法、状态、耗时。注意不要记录敏感数据与令牌;等到讲安全时我们会单独展开。
错误七:没用 Inspector,就直接在 ChatGPT 里调试一切。
常见情形:同学写了 MCP 服务器,马上接到 ChatGPT App,结果“一切都神秘失灵”。但 Inspector 甚至一次都没启动过。于是就难以判断问题出在协议、服务器、Apps SDK,还是模型行为。正确路径是——先确保 MCP 服务器在隔离环境(通过 MCP Jam / Inspector)下工作正常,再把它接入应用。
GO TO FULL VERSION