CodeGym /課程 /ChatGPT Apps /第一個 MCP 伺服器:從 SDK 到可用的 tools/resources/prompts

第一個 MCP 伺服器:從 SDK 到可用的 tools/resources/prompts

ChatGPT Apps
等級 6 , 課堂 3
開放

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 版本略有不同,但架構上一定是:

  1. 建立一個 MCP 伺服器物件;
  2. 在上面註冊 tools/resources/prompts;
  3. 把傳輸接到 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。概要步驟:

  1. 建立 MCP 伺服器實例;
  2. 在其上註冊工具、資源與提示詞;
  3. 啟動 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 時:

  1. 先進行握手:客戶端與伺服器交換支援能力(tools/resources/prompts 等);
  2. 客戶端呼叫探索(discovery)方法:拿到工具、資源、提示詞的清單,以及它們的描述與模式;
  3. 當模型決定要呼叫某個工具時,它會形成像 tools/call 的 JSON‑RPC 請求或類似的——伺服器端的 SDK 會把它轉成先前 registerTool 註冊的處理器呼叫;
  4. 處理器執行商業邏輯(在我們這裡是 suggestGifts 或回傳 giftCatalog),並以標準化格式回傳結果;
  5. 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 會處理這件事。使用 registerToolregisterResourceregisterPrompt,把 JSON‑RPC 細節交給函式庫。

錯誤 №3:不考慮傳輸層,試圖用 stdio 伺服器餵 ChatGPT。
Stdio 傳輸很適合本機客戶端(例如桌面環境)把伺服器當子程序啟動。但 ChatGPT 是透過 HTTPS 溝通,它需要 HTTP/串流端點。試圖「把 stdio 硬穿過通道」通常會很痛苦。對 ChatGPT App 一開始就做 HTTP 傳輸(Streamable HTTP)吧。

錯誤 №4:忽略 MIME 類型與資源結構。
對資源來說,不只內容重要,型別(mimeType)與 URI 也很重要。如果到處都寫 text/plain 且隨意丟 JSON 字串,客戶端(與檢查器)會更難理解資料性質。盡量標註正確的 MIME 類型(例如 application/jsontext/html 給 UI 模板等)與穩定的 URI。

錯誤 №5:把 MCP 伺服器當成「雜湊的 HTTP API」。
有時會想:「既然我都有 Express 了,就再掛個 /api/whatever,直接打它吧」。別把 MCP 端點與任意 REST 混在一起:那會使設定、路由與安全更複雜。保持明確契約:/mcpMCP,其他用途用其他路徑,或甚至另一個服務。上線時對 gateway 與授權配置尤其重要。總之,別把 MCP 伺服器變成「隨機 HTTP‑API」——一堆與 MCP 契約無關的 HTTP 端點。

錯誤 №6:不記錄進出站的 MCP 訊息。
沒有日誌的 MCP 伺服器就像黑盒子:「哪裡壞了我也不知道」。在第一個伺服器就該至少在 stderr 寫些精簡結構化日誌:工具方法、狀態、執行時間。重點是別記錄敏感資料與權杖;等談到安全性時我們會再詳細說。

錯誤 №7:還沒有檢查器就試圖直接透過 ChatGPT 除錯一切。
常見狀況:學生寫了 MCP 伺服器,立刻接到 ChatGPT App,然後「一切都壞掉且看不懂」。同時連檢查器都沒跑過一次。於是很難判斷問題在協議、在伺服器、在 Apps SDK,還是在模型行為。正確的流程是——先確定 MCP 伺服器在隔離環境裡運作良好(透過 MCP Jam / Inspector),再把它接到應用。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION