1. 今天要打造什麼,如何融入應用程式
回想一下我們的教學應用:我們要做一個 選禮物助理。在前面的模組中我們已經有:
- ChatGPT 中的元件(Next.js 16 + Apps SDK),負責顯示 UI、狀態,並能呼叫 callTool;
- 一個簡單的後端(透過 Apps SDK / Next.js 路由),會回傳禮物的暫時假資料(stub)。
現在我們想把助理的「大腦」移到一個 獨立的 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 伺服器。官方的 JS/TS SDK 在套件 @modelcontextprotocol/sdk 中。它會代勞 JSON‑RPC、驗證與模式轉換:你用 Zod 模式描述參數,SDK 會自動把它轉成模型看得懂的 JSON Schema。
作為傳輸層,我們需要 HTTP 版本:ChatGPT 是透過網路與遠端 MCP 伺服器溝通,而不是經由 stdio/本機。MCP 規格描述了標準的「串流 HTTP」格式——本質上是舊有 HTTP+SSE 的演進。實作上就是一個處理請求(POST/GET)並在需要時串流回應的 HTTP 端點。在 TypeScript 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、target 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[] {
// 先用模擬資料(mocks)即可
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 伺服器實例;
- 在其上註冊工具、資源與提示詞;
- 啟動 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 端點
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/串流 的處理器。
到這一步伺服器還做不了什麼,但它已經能夠:
- 完成 MCP 握手(handshake);
- 回應基本的探索(discovery)請求(tools/resources/prompts 清單——此刻仍是空的)。
下一步——註冊第一個工具。
6. 透過 MCP SDK 註冊 tool 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 可由小工具(widget)使用
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,但實務上可以是資料庫匯出或 product 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 會在探索(discovery)時被客戶端看見(你稍後可在 MCP 檢查工具中看到它);
- 描述子包含人類可讀的說明與 MIME 類型;
- 處理器回傳包含 URI 與文字的 contents 陣列——這是 MCP 的標準資源格式。
接下來你可以:
- 從客戶端讀取這個資源(例如代理或檢查器);
- 把它用作 UI 的模板/資料;
- 做實驗:看看模型如何利用現成型錄來向使用者說明選項。
8. 註冊一個簡單的 prompt
MCP 的第三種實體是 提示詞(prompts),也就是預先準備好的提示。它能避免反覆貼長長的系統或使用者提示,把它們以名稱存放在伺服器上。
做個小例子:提示詞 birthday_gift,可以作為「選生日禮物對話」的預填模板。
// src/server.ts(片段)
mcpServer.registerPrompt("birthday_gift", {
title: "Birthday gift helper",
description: "用於挑選生日禮物的請求模板。",
messages: [
{
role: "system",
content:
"你是一名禮物搜尋助理。請先提出釐清問題,並提供數個選項。",
},
{
role: "user",
content:
"我需要生日禮物。請先詢問必要的細節,並幫我挑選。",
},
],
});
在底層,MCP 允許客戶端:
- 取得提示詞清單(你會在檢查器中看到 birthday_gift);
- 請求其內容並作為模型的基礎提示使用。
另外,在關於 system‑prompt 與指令的模組中,我們會詳細討論這些提示詞如何與應用的全域指令搭配。此處我們只需要把它們「看見」為 MCP 伺服器的一部分即可。
9. 執行期中它如何運作
把全貌串起來。
當客戶端(例如 MCP Inspector 或 ChatGPT)連到我們的 HTTP 端點 /mcp 時:
- 先進行握手:客戶端與伺服器交換支援能力(tools/resources/prompts 等);
- 客戶端呼叫探索(discovery)方法:拿到工具、資源、提示詞的清單,以及它們的描述與模式;
- 當模型決定要呼叫某個工具時,它會形成像 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 版本而異,不要在意重不完全一樣:這堂課的目標不是背下所有名字,而是讓你 敢打開 JSON 回應來看,並能理解其結構——這點你已在前面幾堂課練過了。
11. 與我們的 ChatGPT App 的串接與後續發展
目前 MCP 伺服器是獨立運作的。在接下來的模組,你會:
- 把它接到 MCP Inspector,學會把 tools/resources/prompts 分開除錯,而不用動 ChatGPT;
- 設定 ChatGPT App,使其能把這個 MCP 伺服器視為工具來源;
- 把原本實作在 Apps SDK 內的部分邏輯(例如內建 tools)搬到 MCP 層;
- 在現有骨架上加入授權、日誌、串流情境等功能。
目前重點在於:
- 你已經有一個獨立服務,負責應用的「能力」與「資料」;
- 這個服務與客戶端使用 標準的 MCP 溝通,而不是自訂 REST;
- 你已經能不畏懼協議,親手註冊工具、資源與提示詞。
12. 關於程式結構與一些最佳實務
即使在這麼小的範例,也能養成好習慣。
首先,請把 伺服器設定獨立。凡是名稱、版本、日誌、傳輸設定(連同 /mcp 路徑與埠)都能丟到小小的 config.ts 模組。之後部署到 Vercel 或放在 MCP gateway 後面時,會需要環境變數,你會感謝現在的自己。
其次,盡量讓 registerTool/registerResource/registerPrompt 的方法本身保持「薄」。模式描述、文字與商業邏輯很適合各自放在獨立檔案:
- gifts.ts——選禮函式;
- catalog.ts——商品型錄處理;
- prompts.ts——提示詞集合。
這樣 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 伺服器時的常見錯誤
錯誤 №1:把所有商業邏輯都塞進 registerTool。
在工具處理器裡「快快寫完一切」的誘惑很大,尤其在教學範例中。但之後它會變成可讀性很差的巨獸,驗證、資料庫存取與回應格式化混成一團。最好一開始就把商業函式(suggestGifts、型錄處理)移到獨立模組,處理器只做「黏合」。
錯誤 №2:死綁 MCP 的特定 JSON 方法名稱。
有時學生會開始寫 if (method === "tools/list"),並手動剖析 JSON。別這麼做:那是 SDK 的工作。MCP 規格與方法名稱會演進,而 SDK 會處理這件事。使用 registerTool、registerResource、registerPrompt,把 JSON‑RPC 細節交給函式庫。
錯誤 №3:不考慮傳輸層,試圖用 stdio 伺服器餵 ChatGPT。
Stdio 傳輸很適合本機客戶端(例如桌面環境)把伺服器當子程序啟動。但 ChatGPT 是透過 HTTPS 溝通,它需要 HTTP/串流端點。試圖「把 stdio 硬穿過通道」通常會很痛苦。對 ChatGPT App 一開始就做 HTTP 傳輸(Streamable HTTP)吧。
錯誤 №4:忽略 MIME 類型與資源結構。
對資源來說,不只內容重要,型別(mimeType)與 URI 也很重要。如果到處都寫 text/plain 且隨意丟 JSON 字串,客戶端(與檢查器)會更難理解資料性質。盡量標註正確的 MIME 類型(例如 application/json、text/html 給 UI 模板等)與穩定的 URI。
錯誤 №5:把 MCP 伺服器當成「雜湊的 HTTP API」。
有時會想:「既然我都有 Express 了,就再掛個 /api/whatever,直接打它吧」。別把 MCP 端點與任意 REST 混在一起:那會使設定、路由與安全更複雜。保持明確契約:/mcp 給 MCP,其他用途用其他路徑,或甚至另一個服務。上線時對 gateway 與授權配置尤其重要。總之,別把 MCP 伺服器變成「隨機 HTTP‑API」——一堆與 MCP 契約無關的 HTTP 端點。
錯誤 №6:不記錄進出站的 MCP 訊息。
沒有日誌的 MCP 伺服器就像黑盒子:「哪裡壞了我也不知道」。在第一個伺服器就該至少在 stderr 寫些精簡結構化日誌:工具方法、狀態、執行時間。重點是別記錄敏感資料與權杖;等談到安全性時我們會再詳細說。
錯誤 №7:還沒有檢查器就試圖直接透過 ChatGPT 除錯一切。
常見狀況:學生寫了 MCP 伺服器,立刻接到 ChatGPT App,然後「一切都壞掉且看不懂」。同時連檢查器都沒跑過一次。於是很難判斷問題在協議、在伺服器、在 Apps SDK,還是在模型行為。正確的流程是——先確定 MCP 伺服器在隔離環境裡運作良好(透過 MCP Jam / Inspector),再把它接到應用。
GO TO FULL VERSION