CodeGym /课程 /ChatGPT Apps /将 ChatGPT App 集成到现有产品与 SDK/MCP 迁移

将 ChatGPT App 集成到现有产品与 SDK/MCP 迁移

ChatGPT Apps
第 20 级 , 课程 2
可用

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.jsongift_catalog.v2.json
  • 或者在 URI/参数中传版本:/api/catalog?version=1

目的相同:不要在已运行的场景“脚下换数据”,而是让它们使用显式、固定的目录版本。

零停机迁移

工具迁移的典型流程:

  1. 并行增加新版本(_v2)。
  2. 更新 App/代理/system prompt,使其使用新版本。
  3. 对两版同时跑黄金用例与 LLM 评测,确认关键路径的质量不退化。
  4. 观察 v1 vs v2 的使用与错误指标。
  5. 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 很有用,以便模型能持续向用户准确说明发生了什么。

工具注解(readOnlyHintdestructiveHintopenWorldHint

注解是简单的布尔标记,但对用户体验与安全有明显影响:

  • 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 生命周期的常态,而不是“某天再说”的罕见事件。

典型升级流程

健康的升级大致如下:

  1. 阅读新版 Apps SDK/MCP SDK 的变更日志,标注所有潜在破坏性变更。
  2. 只在 dev/staging 环境升级依赖,不触碰生产。
  3. 跑 MCP Inspector / Jam 或其他客户端:
    • 检查握手;
    • tools/listresources/list
    • 执行若干测试性的 tools/call
  4. 依据新能力更新工具描述与 _meta
    • 例如添加新的 annotationswidgetDescription
  5. 跑黄金用例与 LLM 评测,确认 App 行为质量未退化(参见前面课程)。
  6. 最后再发到生产,尽量用金丝雀/特性开关只放部分流量。

示例:在新版 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 体验会突然崩掉。

正确路径:

  1. 新增模式与新工具 _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); // 新逻辑
  },
});
  1. 保留原 recommend_gifts,在 description 中加上 DEPRECATED 标注。
  2. 更新 system prompt 与 App 描述,让模型优先选择 recommend_gifts_v2(可以在指令中显式说明)。
  3. 更新 GiftGenius 小部件以理解新响应格式:例如 deliveryEstimateDays 字段等。
  4. 针对典型场景(如“在某日期前收货”的礼物推荐)跑一遍黄金用例与 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_v2recommend_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:不使用注解(destructiveHintreadOnlyHintopenWorldHint)。
若不向模型提示哪些工具安全、哪些潜在危险,它可能会表现得出人意料:对无害的 get_catalog 反复确认,却在删除数据时不作提醒。恰当的注解能让用户体验更可预期,并降低质量与安全事故风险。

错误 6:不跑黄金用例就“直升”生产中的 SDK。
新版 SDK/规范可能新增字段、改变握手或消息结构。若只是“更新依赖然后部署”,很容易引入质量回归(模型不再调用所需工具、错误文案变化等)。先 dev/staging、先 MCP Inspector,再黄金用例与 LLM 评测,最终才是生产。

错误 7:业务逻辑与某个工具版本硬绑定。
如果内部 Gift Service 的逻辑直接依赖具体的 recommend_gifts,那想无痛迁移到 recommend_gifts_v2 会很难。最佳实践是保持内部服务按自身节奏演进,而 *_v1*_v2 工具只是薄适配器,把新旧外部契约映射到通用的领域结构上。

错误 8:缺乏按工具版本的可观测性。
如果日志与指标中你们不区分具体工具及其版本,迁移调试就会变成猜谜。记录工具名、模式/SDK 版本与关键参数——这样任何回归都更容易与具体变更关联起来。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION