1. 为什么要讨论集成与迁移
到目前为止,我们大多按自己的偏好来设计 API 和工具。可在真实世界几乎总是反过来:你们已经拥有:
- 单体或一堆微服务;
- REST/GraphQL API;
- 在生产环境运行多年的业务逻辑。
突然来了个任务:“请通过 Apps SDK 和 MCP,把我们的产品接入 ChatGPT。”
把一切“重写成理想的 MCP 服务器”不可行。需要小心地在现有世界之上“套”一层薄薄的转换层,把你们后端的语言翻译成 ChatGPT 的语言:工具、资源与模式。
第二个问题:产品在演进。模式与 API 会变化。在常规前端里,至少你改了字段会立刻得到 TypeScript 错误。可在 LLM‑App 的世界里要狡猾得多:模型会继续自信地发送旧格式,tool 会崩溃,结果不是构建期的优雅失败,而是:
- MCP 服务器上的运行时错误;
- “我大概猜到了你想要这个字段”的幻觉;
- 恼人的质量事故。
因此在本讲中,我们把 MCP+Apps 层看作:
- 对既有后端的适配器;
- 需要多年维持的契约;
- 迁移对象:版本、注解、scopes 与 SDK。
2. 集成架构:把 MCP 当作既有后端之上的适配器
基础图景
回顾技术栈,但这次从生产视角出发:
flowchart LR U[ChatGPT 中的用户] --> G[ChatGPT 模型] G -->|调用 App| W["小部件(Apps SDK, Next.js)"] G -->|tools.call| MCP[MCP 服务器 / Gateway] MCP --> S1["Gift Service (你们的现有服务)"] MCP --> S2["Commerce Service (订单,ACP)"]
ChatGPT 并不是直接与您们的世界通信,而是通过 MCP 协议:tools/call 的列表、调用与事件流。
在这套架构里,MCP 服务器就是那个适配器:它既了解 ChatGPT(JSON‑RPC、工具),也了解你们的服务(REST/DB/队列),并在二者之间做转换。
MCP 作为 Gateway/Adapter
经典场景:你们已经有带 REST 端点的 Gift Service:
// 现有 REST API 示例
GET /api/gifts/recommendations?budget=100&occasion=birthday
POST /api/orders
与其重写业务逻辑,不如让 MCP 层把它包成一个 Tool:
// mcp/tools/recommendGifts.ts
import { z } from "zod";
import { server } from "./mcpServer"; // 假想的 SDK 实例
const recommendGiftsInput = z.object({
occasion: z.string(),
budgetUsd: z.number().int().positive(),
});
server.registerTool({
name: "recommend_gifts",
description: "在预算范围内推荐礼物创意",
inputSchema: recommendGiftsInput,
async execute(args) {
const { occasion, budgetUsd } = recommendGiftsInput.parse(args);
const res = await fetch(
`https://api.myapp.com/gifts/recommendations?budget=${budgetUsd}&occasion=${occasion}`,
);
return res.json(); // 关键:返回对模型和小部件都友好的 JSON
},
});
所有礼物推荐的逻辑仍在你们已有的服务中。MCP 层只是把 ChatGPT 的语言翻译成你们 API 的语言。
有时 MCP 层还会把请求路由到多个后端服务。这时它就演变成完整的 MCP Gateway——关于它的角色你们会在生产与网络模块里更深入地学习。
Monolith-integrated MCP vs Sidecar MCP
把 MCP 层“接”到哪里,通常有两个基本选项。
文字上大致是这样:
| 方案 | 说明 | MCP 代码所在位置 |
|---|---|---|
| Monolith-integrated | 全部在同一个 Next.js/Node 服务中 | 在 Next.js 的 API 路由或 Express 中 |
| Sidecar MCP | 独立容器/服务,与 API 通信 | 独立的 Node/Go 应用 |
在小项目里,往往第一个选项就够了:Next.js 应用、部署到 Vercel,上面有 /mcp 或 /api/mcp 路由,MCP 服务器就与其他 API 并排运行。
示例(大幅简化):
// app/api/mcp/route.ts (Next.js 16)
import { NextRequest } from "next/server";
import { mcpHandler } from "@/mcp/server";
export async function POST(req: NextRequest) {
const body = await req.json();
const response = await mcpHandler.handle(body); // JSON-RPC 请求
return new Response(JSON.stringify(response), {
headers: { "content-type": "application/json" },
});
}
在更成熟的架构里,如果有多个领域服务(Gift、Commerce、Analytics),把 MCP 层抽出来作为独立的 Gateway 服务会更方便。它负责接收来自 ChatGPT 的 MCP 流量,并按工具名将调用路由到不同的后端。
要点:从 ChatGPT 与 Apps SDK 的视角看,它始终是一个 MCP 服务器。至于它究竟是跑在单体里还是作为独立微服务,那是你们的架构决策。
MCP 层的架构说清楚了:它可以驻留在单体里,也可以作为独立 Gateway。接下来要回答的问题是,这一层具体收发什么——这就涉及模式与契约。
3. 单一事实来源:模式、类型与契约测试
如果你们同时有内部 DTO、外部 REST 契约以及面向工具的 MCP 模式——“凭感觉画模式”的诱惑很大。结果也可预期:
- 你们在后端改了字段,却忘了更新工具的 schema;
- 模型继续发送旧格式;
- 收获一整套运行时混乱。
正常做法:为数据结构建立单一事实来源,并在所有地方复用。在 TypeScript 世界,这很适合用 Zod 或类似库,MCP SDK 也能把它转换为 JSON Schema。
GiftGenius 的通用 Zod 模式
假设你们的 Gift 服务(在我们的教学项目 GiftGenius 中)已经用 Zod 做入参校验:
// domain/gifts.ts
import { z } from "zod";
export const giftRecommendationInputSchema = z.object({
occasion: z.string().describe("场合:birthday、wedding 等"),
budgetUsd: z.number().int().positive(),
recipientProfile: z.string().describe("对该人的简短描述"),
});
export type GiftRecommendationInput = z.infer<
typeof giftRecommendationInputSchema
>;
同一个模式还会用于:
- REST 端点(校验请求体);
- MCP 工具(作为 inputSchema);
- 测试(作为基准夹具)。
把模式接到 MCP 工具
// mcp/tools/recommendGifts.ts
import { giftRecommendationInputSchema } from "@/domain/gifts";
import { server } from "../mcpServer";
server.registerTool({
name: "recommend_gifts",
description: "按画像与预算推荐礼物",
inputSchema: giftRecommendationInputSchema,
async execute(args) {
const input = giftRecommendationInputSchema.parse(args);
const res = await fetch("https://api.myapp.com/gifts/recommendations", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(input),
});
return res.json();
},
});
SDK 会把 Zod 模式自动转换为 JSON Schema,ChatGPT 会在 tools/list 中看到。 这同时解决两件事:
- 工具入参的类型与代码强绑定;
- 当模式变化时,TypeScript 编译器会逼迫你们连处理器一起更新。
MCP ↔ 后端的契约测试
这里的契约测试并不吓人,不过是几条脚踏实地的检查。
最简单的单测/契约测可以这样写:
// tests/mcp/recommendGifts.contract.test.ts
import { giftRecommendationInputSchema } from "@/domain/gifts";
test("示例请求符合工具的模式", () => {
const sample = {
occasion: "birthday",
budgetUsd: 150,
recipientProfile: "同事,喜欢数码产品",
};
expect(() => giftRecommendationInputSchema.parse(sample)).not.toThrow();
});
这种测试不保证“天下太平”,但至少能在你们改了模式却忘记更新夹具时,尽早发现 MCP 层与后端期望的脱节。
接下来,很容易把同一思路扩展到:
- 外部 API(Stripe、CMS)的模拟响应;
- 在测试环境用真实 MCP 服务器跑一遍 MCP 客户端。
4. tools 与 resources 的版本化策略
模式早晚会变化。关键是别用“只是改个字段名,能出啥事”的心态。在 LLM 世界,这可能不仅搞崩构建,还会改变模型行为:老的提示词、已保存的对话与黄金用例会继续期望旧契约。
增量变化 vs 破坏性变化
粗略地说,变更分两类。
增量变化——添加不会破坏兼容性:
- 响应里新增可选字段;
- 新增带默认值的可选参数;
- enum 新增取值,UI 与模型可以忽略或中性对待。
例如,你们在工具的响应中新增 deliveryEstimateDays 字段,而旧小部件会直接忽略它。这是安全的:模式扩展了,但没人被迫使用它。
破坏性变化——打破既有预期:
- 把原本不存在的字段改成必填;
- 改变类型(字符串 → 对象);
- 改变参数语义(预算 USD → 本地货币),却不改字段名。
这种情况下,唯一安全的方式是推出工具的新版本。
Tool_v2 模式
典型做法:已有 recommend_gifts,你们想大改模式。别动旧工具,新建一个——recommend_gifts_v2。
// v1
const recommendGiftsInput_v1 = z.object({
occasion: z.string(),
budgetUsd: z.number().int().positive(),
});
// v2: 支持货币与配送过滤
const recommendGiftsInput_v2 = z.object({
occasion: z.string(),
maxPrice: z.number().int().positive(),
currency: z.enum(["USD", "EUR", "GBP"]),
deliverByDate: z.string().optional(); // ISO 字符串
});
server.registerTool({
name: "recommend_gifts",
description: "DEPRECATED: 请使用 recommend_gifts_v2",
inputSchema: recommendGiftsInput_v1,
async execute(args) { /* 旧逻辑 */ },
});
server.registerTool({
name: "recommend_gifts_v2",
description:
"按预算、货币和送达截止日期推荐礼物",
inputSchema: recommendGiftsInput_v2,
async execute(args) { /* 新逻辑 */ },
});
在你们更新前,模型和旧提示词/代理会继续使用 recommend_gifts。新的场景则基于 recommend_gifts_v2 来编写。
在迁移期结束后:
- 黄金用例与代理切到 v2;
- 指标显示 v1 基本不再被调用;
就可以开始逐步收缩 v1(例如先在 dev/staging 从工具列表隐藏,再到生产)。
资源的版本化
需要版本化的不止是 tools。如果你们有资源(resources)——比如礼物的静态目录——也最好进行版本管理。
常见做法:
- 把版本编码到资源名:gift_catalog.v1.json、gift_catalog.v2.json;
- 或者在 URI/参数中传版本:/api/catalog?version=1。
目的相同:不要在已运行的场景“脚下换数据”,而是让它们使用显式、固定的目录版本。
零停机迁移
工具迁移的典型流程:
- 并行增加新版本(_v2)。
- 更新 App/代理/system prompt,使其使用新版本。
- 对两版同时跑黄金用例与 LLM 评测,确认关键路径的质量不退化。
- 观察 v1 vs v2 的使用与错误指标。
- 当 v1 流量接近零,再开始下线。
这一套对模式迁移、SDK/协议升级、Auth 变更都适用。我们已经梳理了工具与资源如何通过 v1/v2 与增量变更来演化。契约的第二大块是认证与授权:OAuth、scopes 和 .well-known。它们同样要长期维护,并需要谨慎迁移。
5. 认证的演进:.well-known、scopes 与既有 OAuth
如果你们已经在用 OAuth 2.1/OpenID Connect,通过 MCP 接入 ChatGPT 并不是“再来一套登录”,而是一个新客户端,必须按你们授权服务器(Authorization Server)的通用规则来对话。
MCP 与 .well-known/oauth-protected-resource
关于完整的 OAuth 2.1/OpenID Connect 与授权服务器配置,我们在课程的另一模块详谈(见认证模块)。这里重点是实操:MCP 资源如何告诉 ChatGPT 它受 OAuth 保护,以及如何触发账户关联流程。
受保护 MCP 资源的标准模式:
- MCP 服务器暴露一个特殊端点 /.well-known/oauth-protected-resource;
- 响应中说明这是哪个资源、由哪些 AS(Authorization Server)保护;
- 当某个 MCP 调用返回 401 时,服务器在 WWW-Authenticate 头里带上该 .well-known 的链接,ChatGPT 会自动启动 OAuth 流程(“Link account”)。
Express 的最小示例:
// mcp-auth/.well-known.ts
import express from "express";
const app = express();
app.get("/.well-known/oauth-protected-resource", (_req, res) => {
res.json({
resource: "https://mcp.myapp.com",
authorization_servers: [
"https://auth.myapp.com/.well-known/openid-configuration",
],
});
});
app.listen(3000);
以及带有客户端提示的 401 处理:
res
.status(401)
.set(
"WWW-Authenticate",
'Bearer resource_metadata="https://mcp.myapp.com/.well-known/oauth-protected-resource"',
)
.end();
ChatGPT 看到这个头部后,就知道该找哪个 AS,以及如何为你们的 MCP 资源发起 OAuth 流程。
Scopes 与授权迁移
Scopes 也是迁移的来源。我们在 Auth 模块已详细讨论,这里在集成/迁移的语境下强调几点。
设想 GiftGenius 起初只支持读取目录(gifts.read),之后你们新增了 gifts.write 以创建订单。需要:
- 在客户端(ChatGPT App)配置中添加新 scope;
- 更新 MCP 服务器,仅对真正有副作用的工具才要求该 scope;
- 必要时在 .well-known 中描述变更。
从用户体验角度看,用户在下一次尝试使用新功能时,可能会看到请求“扩展权限”的提示。你们不希望这在进行中的对话中毫无预警地发生——因此这类变更需要:
- 提前公告(发布说明、文档);
- 在 staging 与测试用 AS 上验证;
- 配合更新工具描述(destructiveHint 等),让模型有意识地调用“危险”工具。
6. 元数据与注解:契约之上的提示层
Auth 层回答的是通过你们 App,谁能做什么。但即便 token 与 scopes 都正确,模型如何调用你们的工具、以及如何向用户解释行为也很重要。这就需要额外的提示层:元数据与注解。
契约(schema)说明工具接收/返回什么。元数据与注解帮助模型理解何时与如何调用它。随着 App 演进(新增带副作用的动作、改 UI、引入外部集成),这点尤为关键。
_meta["openai/widgetDescription"] 与 widgetCSP
在 Apps SDK 与 MCP 描述中有一个特殊字段 _meta,OpenAI 会在其中添加协议扩展。例如:
- _meta["openai/widgetDescription"]——简述你们小部件展示的内容;模型可利用它避免“复述” UI,并正确地为 App 做引导;
- _meta["openai/widgetCSP"]——声明你们小部件所需的 CSP 域(用于 fetch/图片/脚本)。
当你们调整 UI(例如新增下单步骤)时,更新 widgetDescription 很有用,以便模型能持续向用户准确说明发生了什么。
工具注解(readOnlyHint、destructiveHint、openWorldHint)
注解是简单的布尔标记,但对用户体验与安全有明显影响:
- readOnlyHint: true——工具仅做读取,不会修改。模型可在无需额外确认的情况下调用。
- destructiveHint: true——工具可能删除/修改数据。ChatGPT 会要求明确确认。
- openWorldHint: true——工具会向外部发布数据,或可能返回“大量信息”,需要做摘要。
带注解的工具描述示例:
server.registerTool({
name: "delete_saved_gift",
description: "删除用户已保存的礼物",
inputSchema: z.object({ giftId: z.string() }),
annotations: {
readOnlyHint: false,
destructiveHint: true,
openWorldHint: false,
},
async execute({ giftId }) {
// ...删除礼物
},
});
迁移时新增“危险”工具,注解就是好帮手:它们能避免 ChatGPT 暗中执行,并引导更谨慎的行为。
要理解的是,注解并非“真正的防护”。它们只影响客户端与模型行为。真正的安全仍由你们的服务器提供(Auth、scopes、校验)。
7. SDK 与 MCP 规范的迁移
MCP 与 Apps SDK 在快速演进——capabilities 中会出现新字段、新的消息类型、新的 _meta/annotations。文档也会诚实地提醒:“以 2025 年为准”——我们得与之共处。
因此,SDK 与规范版本迁移是 App 生命周期的常态,而不是“某天再说”的罕见事件。
典型升级流程
健康的升级大致如下:
- 阅读新版 Apps SDK/MCP SDK 的变更日志,标注所有潜在破坏性变更。
- 只在 dev/staging 环境升级依赖,不触碰生产。
- 跑 MCP Inspector / Jam 或其他客户端:
- 检查握手;
- tools/list 与 resources/list;
- 执行若干测试性的 tools/call。
- 依据新能力更新工具描述与 _meta:
- 例如添加新的 annotations 或 widgetDescription。
- 跑黄金用例与 LLM 评测,确认 App 行为质量未退化(参见前面课程)。
- 最后再发到生产,尽量用金丝雀/特性开关只放部分流量。
示例:在新版 SDK 中添加 openWorldHint
假设新版 Apps SDK 支持 openWorldHint,你们决定给 search_public_reviews(会抓取外部评价且可能返回大量噪声)打上这个标记。
步骤如下:
- 升级 SDK 与类型;
- 在工具描述中补上 annotations.openWorldHint = true;
- 更新 system prompt,让代理明确向用户说明将要访问外部世界;
- 跑安全类黄金用例(尤其是隐私/PII 相关),确保模型不会变得过度“话唠”。
我们讨论了 SDK 与注解更新的通用流程。现在把这些放到一个具体场景——recommend_gifts 工具的演进里看一眼。
8. 迷你案例:GiftGenius 中 recommend_gifts 的演进
让我们把前文合在一个具体场景里。
起始版本
基础工具是这样的:
const recommendGiftsInput_v1 = z.object({
occasion: z.string(),
budgetUsd: z.number().int().positive(),
recipientProfile: z.string(),
});
server.registerTool({
name: "recommend_gifts",
description: "以 USD 为单位推荐礼物",
inputSchema: recommendGiftsInput_v1,
async execute(args) {
const input = recommendGiftsInput_v1.parse(args);
return giftService.recommend(input); // 内部函数
},
});
只要用户来自美国且只有一种货币,一切都很好。
新的业务需求:多币种与送达截止
产品团队提出新需求:
- 需要支持 EUR/GBP;
- 需要考虑送达截止期(生日还有三天时,不要推荐一个月后才能到的礼物);
- 最好在响应里给出预计送达时间。
天真做法:直接改字段:
- 把 budgetUsd 重命名为 maxPrice;
- 新增 currency;
- 响应中添加 deliveryEstimateDays。
会出什么问题?
旧的提示词(包括黄金用例与 system prompt 中的描述)以及已保存的对话仍在发送 budgetUsd。模型不知道它不复存在。MCP 层在 parse 时会抛错。真实用户的 ChatGPT App 体验会突然崩掉。
正确路径:
- 新增模式与新工具 _v2。
const recommendGiftsInput_v2 = z.object({
occasion: z.string(),
maxPrice: z.number().int().positive(),
currency: z.enum(["USD", "EUR", "GBP"]),
recipientProfile: z.string(),
deliverByDate: z.string().optional(),
});
server.registerTool({
name: "recommend_gifts_v2",
description:
"考虑货币与期望送达日期的礼物推荐",
inputSchema: recommendGiftsInput_v2,
async execute(args) {
const input = recommendGiftsInput_v2.parse(args);
return giftService.recommendV2(input); // 新逻辑
},
});
- 保留原 recommend_gifts,在 description 中加上 DEPRECATED 标注。
- 更新 system prompt 与 App 描述,让模型优先选择 recommend_gifts_v2(可以在指令中显式说明)。
- 更新 GiftGenius 小部件以理解新响应格式:例如 deliveryEstimateDays 字段等。
- 针对典型场景(如“在某日期前收货”的礼物推荐)跑一遍黄金用例与 LLM 评测。
测试与可观测性
你会想要这些测试与观测:
新入参的契约测试:
test("v2 支持 EUR 与送达截止的场景", () => {
const sample = {
occasion: "birthday",
maxPrice: 100,
currency: "EUR",
recipientProfile: "同事",
deliverByDate: "2025-12-24",
};
expect(() => recommendGiftsInput_v2.parse(sample)).not.toThrow();
});
生产观测:
- recommend_gifts_v2 与 recommend_gifts 的调用占比;
- v1 的错误率(应保持不升);
- 迁移前后,黄金用例的 LLM 评测分(你们已在前面课程学会如何实现)。
当 v2 在质量与使用指标上都“胜出”时,就可以计划有序地下线 v1。
如果把要点简化为三条: (1) MCP 是瘦适配器,而不是新的单体; (2) 模式、Auth 与注解是 ChatGPT 与后端之间的长寿契约,版本化与测试要像对待常规 API 一样严谨; (3) 任何 SDK/规范迁移都是带 staging、黄金用例与观测的常规工程流程,而不是“周五晚上升个包”。 以此视角看 ChatGPT App,与你们现有产品的集成就不再像一团乱麻。
9. MCP/SDK 集成与迁移中的常见错误
错误 1:把 MCP 当“新后端”,而不是瘦适配层。
有时会忍不住把全部业务逻辑塞进 MCP 层:访问数据库、领域规则、计算。这会把 MCP 服务器变成另一个难以与其余后端同步的单体。更健康的做法是把 MCP 保持为既有服务之上的 Gateway/Adapter:全部领域逻辑仍在旧处,MCP 只在 JSON 间做转换。
错误 2:同一对象有多套模式定义。
常见反模式是“礼物”有三个定义:一个在数据库,一个在 REST API,一个在 MCP 工具,而且都略有不同。最终静态类型、契约、测试与常识都会崩。用一套模式(Zod/TypeBox 等)作为单一事实来源,并为 MCP 生成 JSON Schema,可显著降低风险。
错误 3:错误的模式迁移——“悄悄的”破坏性变更。
改字段名或改变语义却不改工具名,是隐性回归之路。模型会继续发送旧格式,事故只在一部分用户身上、且在很久之后才显现。遇到重大变更就上 *_v2,并行保留旧版,配合弃用标注与监控。
错误 4:忽视 Auth 与 scopes 的变更。
新增了带副作用的工具,却忘了更新 scopes 与 .well-known?用户可能在流程中段遇到 401,或者反过来,你们的 MCP 会在缺乏充分授权的情况下执行危险操作。像模式迁移一样谨慎规划 auth 层迁移:staging、测试与权限的平滑扩展。
错误 5:不使用注解(destructiveHint、readOnlyHint、openWorldHint)。
若不向模型提示哪些工具安全、哪些潜在危险,它可能会表现得出人意料:对无害的 get_catalog 反复确认,却在删除数据时不作提醒。恰当的注解能让用户体验更可预期,并降低质量与安全事故风险。
错误 6:不跑黄金用例就“直升”生产中的 SDK。
新版 SDK/规范可能新增字段、改变握手或消息结构。若只是“更新依赖然后部署”,很容易引入质量回归(模型不再调用所需工具、错误文案变化等)。先 dev/staging、先 MCP Inspector,再黄金用例与 LLM 评测,最终才是生产。
错误 7:业务逻辑与某个工具版本硬绑定。
如果内部 Gift Service 的逻辑直接依赖具体的 recommend_gifts,那想无痛迁移到 recommend_gifts_v2 会很难。最佳实践是保持内部服务按自身节奏演进,而 *_v1、*_v2 工具只是薄适配器,把新旧外部契约映射到通用的领域结构上。
错误 8:缺乏按工具版本的可观测性。
如果日志与指标中你们不区分具体工具及其版本,迁移调试就会变成猜谜。记录工具名、模式/SDK 版本与关键参数——这样任何回归都更容易与具体变更关联起来。
GO TO FULL VERSION