1. 為何需要談整合與遷移
到目前為止,我們多半是以自己方便的方式設計 API 與工具。現實世界幾乎總是反過來:你通常已經有:
- 單體或一堆微服務;
- REST/GraphQL API;
- 已在生產環境運行多年的商業邏輯。
然後需求來了:「請透過 Apps SDK 與 MCP 把我們的產品連到 ChatGPT」。
把一切重寫成「理想的 MCP 伺服器」並不可行。需要在既有世界之上輕巧地「套」一層,將你的後端語言轉換為 ChatGPT 的語言:工具、資源與 schema。
第二個問題:產品是活的。Schema 與 API 會變。在一般前端裡,當你改了欄位,至少會立刻得到 TypeScript 錯誤。在 LLM‑Apps 的世界更狡猾:模型會繼續自信地送舊格式,tool 會崩,結果你得到的不是漂亮的編譯失敗,而是:
- MCP 伺服器上的 runtime 錯誤;
- 「我大概猜到你想要什麼欄位」之類的幻覺;
- 惱人的品質事故。
因此在本講,我們把 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/resources 清單、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 或 Express 的 API route |
| 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 運作沒問題。接下來的問題是,它究竟收什麼、回什麼——這就需要談 schema 與契約。
3. Single Source of Truth:schema、型別與契約測試
若你同時擁有內部 DTO、對外 REST 契約,以及 MCP 工具的 schema——「照感覺畫出 schema」的誘惑會很大。結果可想而知:
- 你改了後端的欄位,卻忘了更新工具的 schema;
- 模型仍然送舊格式;
- 收穫一個歡樂的 runtime 動物園。
正確做法:為資料結構建立單一事實來源,並讓它到處通用。在 TypeScript 世界,很適合用 Zod 或類似庫,MCP SDK 能把它轉成 JSON Schema。
GiftGenius 的通用 Zod schema
假設你在我們的教學專案 GiftGenius 的 Gift 服務中,已用 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
>;
同一份 schema 會用在:
- REST 端點(驗證請求 body);
- MCP 工具(作為 inputSchema);
- 測試(作為 fixtures 的基礎)。
把 schema 掛到 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 schema 轉為 JSON Schema,讓 ChatGPT 在 tools/list 看見。 這同時解兩個問題:
- 工具參數型別與程式碼緊密連動;
- 當 schema 改變,TypeScript 編譯器會逼你同步更新處理器。
MCP ↔ 後端的契約測試
這裡的契約測試不嚇人,其實就是幾個很落地的檢查。
最簡單的 unit/contract 測試可能長這樣:
// tests/mcp/recommendGifts.contract.test.ts
import { giftRecommendationInputSchema } from "@/domain/gifts";
test("範例請求符合工具的 schema", () => {
const sample = {
occasion: "birthday",
budgetUsd: 150,
recipientProfile: "同事,喜歡電子產品",
};
expect(() => giftRecommendationInputSchema.parse(sample)).not.toThrow();
});
這種測試不能保證世界美好,但至少能在你改了 schema 卻忘了更新 fixtures 時,抓到 MCP 層與後端期望的不同步。
接著很容易把同樣的方法擴展到:
- 外部 API(Stripe、CMS)的模擬回應;
- 在測試環境用真實 MCP 伺服器跑一次 MCP 客戶端。
4. tools 與 resources 的版本策略
Schema 早晚會變。重點是不該「就改個欄位名稱,會出什麼事?」在 LLM 世界,你不只會把建置搞掛,還會影響模型行為:舊的 prompt、已保存的對話與 golden case 會持續期待舊契約。
增補式 vs 破壞性變更
大致可分兩類。
增補式變更——你新增了某些東西,但不會弄壞誰:
- 回應新增非必填欄位;
- 新增非必填參數且有預設值;
- enum 新增額外值,而 UI 與模型可以中性對待。
例如,你在工具回應裡加了 deliveryEstimateDays 欄位,但舊版小工具會直接忽略。這是安全的:schema 可擴充,但沒人被迫使用它。
破壞性變更——你破壞了既有預期:
- 把本來沒有的欄位變成必填;
- 更改型別(字串 → 物件);
- 改變參數意義(預算從 USD → 在地貨幣),卻不改欄位名稱。
這種情況唯一安全之道——建立新版本的工具。
模式 Tool_v2
典型模式:你有 recommend_gifts,現在想大改 schema。不要動舊工具,直接建立新工具——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) { /* 新邏輯 */ },
});
模型與舊的 prompt/代理會持續使用 recommend_gifts,直到你去更新它們。新的情境則改寫成使用 recommend_gifts_v2。
遷移一段時間後:
- golden case 與代理都切到 v2;
- 指標顯示 v1 幾乎不再被呼叫;
即可開始有計畫地收攤 v1(例如先在 dev/staging 的工具清單隱藏,再到 prod)。
資源的版本化
不只 tools 需要版本。若你有資源(resources)——例如禮物靜態目錄——也最好版本化。
常見做法:
- 把版本寫進資源名稱:gift_catalog.v1.json、gift_catalog.v2.json;
- 或透過 URI/參數傳遞版本:/api/catalog?version=1。
意義相同:不要在已經跑著的情境腳下偷偷換資料,而是提供明確固定的目錄版本。
零停機遷移
工具遷移的典型循環:
- 在舊版旁邊新增工具新版本(_v2)。
- 更新 App/代理/system prompt,讓它們使用新版本。
- 對兩個版本都跑一次 golden case 與 LLM‑eval,確保關鍵情境的品質未下降。
- 觀察 v1 與 v2 的使用率(以及錯誤)。
- 當 v1 的流量接近零時,開始逐步停用。
這種方法對 schema 遷移、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 與 Auth Server 設定在課程的另一個模組有詳談(見身分驗證模組)。此處只看實務面:MCP 資源如何告訴 ChatGPT 它受 OAuth 保護,以及如何啟動 linking flow。
受保護 MCP 資源的標準模式:
- 你的 MCP 伺服器曝露特殊端點 /.well-known/oauth-protected-resource;
- 回應中說明這是什麼資源,以及由哪些 AS(Authorization Server)保護;
- 當 MCP 呼叫回傳 401 時,伺服器在 WWW-Authenticate 標頭放上這個 .well-known 的連結,ChatGPT 會自動啟動 OAuth flow(「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 flow。
Scopes 與授權遷移
Scopes 也是遷移的來源。我們在 Auth 模組裡已經詳談,但在整合/遷移情境下有幾點很重要。
想像 GiftGenius 一開始只會讀目錄(gifts.read),後來你新增了 gifts.write 來建立訂單。你需要:
- 在客戶端(ChatGPT App)設定中加上新 scope;
- 更新 MCP 伺服器,讓它只在真正有改動的工具上要求此 scope;
- 若有需要,更新 .well-known 的描述。
從 UX 角度看,使用者在下一次嘗試使用新功能時,可能會看到要求「擴充權限」的提示。你不會想讓它在既有對話中毫無預警地發生——因此這類變更需要:
- 事先公告(發佈說明、文件);
- 在 staging 上用測試 AS 驗證;
- 搭配更新工具描述(destructiveHint 等),讓模型「有意識地」呼叫具破壞性的 tools。
6. 中介層:以 metadata/annotations 作為契約之上的 hint
Auth 層回答的是:誰能做什麼。但即使 token 與 scopes 都正確,模型如何呼叫你的工具、如何向使用者解釋行為仍很重要。此時就需要額外的 hint 層:metadata 與 annotations。
契約(schema)說明工具接受/回傳什麼。Metadata 與 annotations 則幫助模型理解何時、如何呼叫。當你演進 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)
註解是簡單的布林旗標,卻能明顯影響 UX 與安全性:
- 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 環境更新依賴,先不要動 prod。
- 跑一次 MCP Inspector / Jam 或其他客戶端:
- 檢查 handshake;
- tools/list / resources/list;
- 若干測試性的 tools/call。
- 依據新能力更新工具描述與 _meta:
- 例如加入新的 annotations 或 widgetDescription。
- 跑一次 golden case 與 LLM‑eval(如同前幾講所述),以確保 App 的品質層面未受影響。
- 之後才把更新推到 prod,能用 canary/feature flag 分流更好。
範例:在新 SDK 版本中加入 openWorldHint
假設新版 Apps SDK 加入了 openWorldHint,你決定把它加到工具 search_public_reviews 上,此工具會抓取外部評論,可能回傳大量雜訊。
步驟如下:
- 更新 SDK 與型別;
- 在工具描述中新增 annotations.openWorldHint = true;
- 更新 system prompt,讓代理明確告知使用者接下來會對外發出請求;
- 跑一輪安全性 golden case(尤其是隱私/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。
會出什麼問題?
舊的 prompt(包含 golden case 與 system prompt 描述)與已保存的對話仍會送 budgetUsd。模型不知道它不存在了。MCP 層在 parse 時會崩。ChatGPT App 的行為會在真實使用者處突然壞掉。
正確作法:
- 新增新 schema 與新工具 _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 等欄位。
- 針對典型情境(例如在某日期前送達的禮物挑選)跑一次 golden case 與 LLM‑eval。
測試與可觀測性
有幾種測試特別值得擁有:
新輸入的契約測試:
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 的錯誤率(預期不會上升);
- 遷移前/後 golden case 的 LLM‑eval 分數(你已在前幾講學過如何執行)。
當 v2 在品質與使用率上都「勝出」,即可規劃逐步停用 v1。
若簡化成三點: (1) MCP 是薄配接器,而非新的單體; (2) schema、auth 與註解是 ChatGPT 與你的後端之間的長期契約,必須像一般 API 一樣嚴謹地版本化與測試; (3) 任何 SDK/規格的遷移都是正常的工程流程,要有 staging、golden case 與可觀測性,而不是「週五晚上更新套件」。 用這個視角看待 ChatGPT App,與既有產品的整合不再像是一團混沌。
9. MCP/SDK 整合與遷移中的常見錯誤
錯誤 №1:把 MCP 當成「新後端」,而非薄配接器。
有時會想把所有商業邏輯都塞進 MCP 層:連資料庫、網域規則、計算都搬來。這會把 MCP 伺服器變成另一個難以與其餘後端同步的單體。健康作法是把 MCP 維持為既有服務之上的 Gateway/Adapter:網域邏輯仍待在 ChatGPT 之前的地方,MCP 只做 JSON 的來回轉換。
錯誤 №2:對同一個物件使用不同 schema。
常見反模式是擁有三個「禮物」定義:一個在 DB,一個在 REST API,一個在 MCP 工具,且彼此略有差異。最後靜態型別、契約、測試與常識一起崩。使用單一 schema(Zod/TypeBox 等)作為 Single Source of Truth,並為 MCP 產生 JSON Schema,可大幅降低風險。
錯誤 №3:錯誤的 schema 遷移——「無聲」的破壞性變更。
不改工具名稱就改欄位名稱或語意,會導致隱性回歸。模型持續送舊格式,事故只在部分使用者、且過一陣子才浮現。遇到重大變更請建立 *_v2,讓舊版本並行,搭配棄用註記與監控。
錯誤 №4:忽略 Auth 變更與 scopes。
新增具副作用的工具,卻忘了更新 scopes 與 .well-known?使用者可能在流程中途遇到 401,或反過來,你的 MCP 在沒有足夠授權下執行破壞性操作。像對待 schema 遷移一樣審慎規劃 auth 層遷移:經過 staging、測試與平滑擴權。
錯誤 №5:不使用註解(destructiveHint、readOnlyHint、openWorldHint)。
若不提示模型哪些工具安全、哪些潛在危險,行為會很詭異:對無害的 get_catalog 反而要求確認,卻在刪除資料時不發一語。正確的註解能讓行為對使用者更可預期,並降低品質與安全事故風險。
錯誤 №6:未跑 golden case 就在 prod 更新 SDK。
新版 SDK/規格可能新增欄位、改變 handshake 行為或訊息結構。若只是「更新相依後直接部署」,很容易撞見品質回歸(模型不再呼叫該用的工具、錯誤訊息措辭改變等)。先走 dev/staging、MCP Inspector,再跑 golden case 與 LLM‑eval,最後才上 prod。
錯誤 №7:把商業邏輯硬綁到某個工具版本。
當內部 Gift Service 直接依賴特定的 recommend_gifts,要遷移到 recommend_gifts_v2 就會很痛。最佳實務是讓內部服務按自己的規則演進,而 *_v1、*_v2 工具只是薄 adapter,把新舊外部契約對映到共同的網域結構。
錯誤 №8:缺乏依工具版本的可觀測性。
若在日誌與指標中無法區分是哪個工具與版本被呼叫,遷移除錯會變成猜謎。請記錄工具名稱、schema/SDK 版本與關鍵參數——任何回歸就能更容易對上特定變更。
GO TO FULL VERSION