CodeGym /Các khóa học /ChatGPT Apps /Máy chủ MCP đầu tiên: từ SDK đến các tools/resources/prom...

Máy chủ MCP đầu tiên: từ SDK đến các tools/resources/prompts hoạt động

ChatGPT Apps
Mức độ , Bài học
Có sẵn

1. Hôm nay chúng ta sẽ xây gì và nó khớp với ứng dụng thế nào

Nhắc lại ứng dụng học tập của chúng ta: chúng ta đang làm trợ lý chọn quà tặng. Ở các mô‑đun trước chúng ta đã có:

  • một widget trong ChatGPT (Next.js 16 + Apps SDK) hiển thị UI, trạng thái và biết gọi callTool;
  • một backend đơn giản (qua Apps SDK / các route của Next.js) trả về danh sách quà tặng giả lập.

Giờ chúng ta muốn “tách phần não” của trợ lý sang một MCP server riêng. Cuối cùng sơ đồ sẽ như sau:

flowchart TD
  subgraph ChatGPT
    U[Người dùng
trong chat] W["Widget App
(Apps SDK)"] end subgraph MCP client C[ChatGPT MCP client] end subgraph OurServer[MCP server của chúng tôi] 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

Tức là bây giờ:

  • mô hình bên trong ChatGPT nhìn thấy MCP server của chúng ta như một tập chuẩn tools/resources/prompts;
  • callTool từ widget về mặt logic trở thành một lời gọi MCP nội bộ;
  • máy chủ của chúng ta mô tả các hợp đồng (schema, mô tả) và hiện thực business logic.

Đến cuối bài giảng, bạn sẽ có một dự án Node/TypeScript riêng với MCP server, mà:

  • có thể chạy cục bộ bằng một lệnh;
  • đăng ký ít nhất một công cụ và một resource;
  • trả về dữ liệu có ý nghĩa (dù chỉ là mock đơn giản);
  • được tổ chức để có thể phát triển tiếp.

Trong khi đó backend hiện có qua Apps SDK/Next.js chúng ta chưa viết lại: nó vẫn giữ nguyên, còn MCP server được dựng như một dịch vụ riêng bên cạnh. Sau này bạn có thể “gắn” nó vào ChatGPT App và dần dần chuyển logic quà tặng sang đó thay cho các giả lập cũ.

2. Stack: TypeScript + MCP SDK + HTTP transport

Chúng ta sẽ viết MCP server bằng TypeScript trên Node.js. JS/TS SDK chính thức cho MCP nằm trong gói @modelcontextprotocol/sdk. Nó lo phần việc nặng về JSON‑RPC, kiểm tra tính hợp lệ và chuyển đổi schema: bạn mô tả tham số bằng Zod và SDK tự chuyển chúng sang JSON Schema để mô hình hiểu.

Về transport, chúng ta cần biến thể HTTP: ChatGPT nói chuyện với MCP server từ xa qua mạng, chứ không phải stdio/cục bộ. Đặc tả MCP mô tả định dạng “HTTP streaming” tiêu chuẩn — về bản chất là sự tiến hóa của HTTP+SSE cũ. Trên thực tế đây là một HTTP endpoint, xử lý request (POST/GET) và khi cần sẽ stream phản hồi. Trong TypeScript SDK của MCP thường đã có sẵn transport cho định dạng này, có thể gắn vào Express hoặc Hono.

Để tập trung, ta giả định có:

  • đối tượng máy chủ McpServer từ @modelcontextprotocol/sdk;
  • HTTP transport (ví dụ StreamableHttpServerTransport hoặc tương tự), có thể tích hợp với Express.

Tên class có thể hơi khác giữa các phiên bản SDK, nhưng kiến trúc luôn là:

  1. tạo đối tượng MCP server;
  2. đăng ký tools/resources/prompts lên đó;
  3. kết nối transport vào ứng dụng HTTP.

3. Cấu trúc dự án và chuẩn bị

Tạo một thư mục riêng cho MCP server. Tiện nhất là đặt cạnh ứng dụng frontend, nhưng là một dự án Node riêng:

chatgpt-gift-app/
  app/              ← Next.js + Apps SDK (widget)
  mcp-server/       ← MCP server của chúng tôi

Bên trong mcp-server:

mcp-server/
  src/
    server.ts       ← entry point của MCP server
    gifts.ts        ← business logic chọn quà tặng
  package.json
  tsconfig.json

Ví dụ đơn giản cho gifts.ts chúng ta sẽ làm ngay sau, giờ tập trung vào server.ts.

Giả sử bạn đã khởi tạo dự án:

mkdir mcp-server
cd mcp-server
npm init -y
npm install typescript ts-node-dev zod express @modelcontextprotocol/sdk

tsconfig.json — cấu hình bình thường (esnext modules, target node, strict). Có thể dùng từ bất kỳ dự án TS nào của bạn.

4. Tách business logic sang module riêng

Rất dễ muốn viết ngay server.registerTool(..., async () => {...}) và nhét toàn bộ logic vào đó. Nhưng tốt hơn ngay từ đầu hãy tách:

  • module không biết gì về MCP, JSON‑RPC hay các phức tạp khác;
  • module chỉ biết về MCP, nhưng ít biết về business logic.

Trong src/gifts.ts mô tả một hàm chọn quà đơn giản:

// 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[] {
  // tạm thời là mock
  return [
    {
      id: "book-1",
      title: "Sách về sở thích yêu thích",
      price: Math.min(input.budget, 30),
      occasion: "generic",
    },
    {
      id: "game-1",
      title: "Boardgame cho nhóm bạn",
      price: Math.min(input.budget, 50),
      occasion: "party",
    },
  ];
}

Hàm này là pure: đầu vào là tham số, đầu ra là mảng ý tưởng. Có thể test bằng unit tests, tái sử dụng ở nơi khác, và nó không phụ thuộc vào MCP. Đúng khuyến nghị: phần khung máy chủ riêng, các hàm nghiệp vụ riêng.

5. Tạo MCP server và gắn HTTP transport

Giờ là entry point src/server.ts. Về mặt sơ đồ, chúng ta cần:

  1. tạo một instance của MCP server;
  2. đăng ký tools, resources và prompts lên đó;
  3. chạy HTTP server (ví dụ Express) và gắn MCP transport vào.

Bắt đầu với khung sẵn:

// 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. Tạo MCP server
const mcpServer = new McpServer({
  name: "gift-assistant-mcp",
  version: "0.1.0",
});

// 2. Ở đây sẽ đăng ký tools/resources/prompts

// 3. Cấu hình transport trên HTTP
const transport = new StreamableHttpServerTransport({
  path: "/mcp", // endpoint MCP duy nhất
  app,          // tích hợp vào ứng dụng Express
});

transport.attach(mcpServer);

const PORT = process.env.PORT ?? 4000;
app.listen(PORT, () => {
  console.log(`MCP server listening on http://localhost:${PORT}/mcp`);
});

Tên class transport cụ thể có thể khác, nhưng pattern giống nhau: bạn tạo HTTP endpoint và gắn MCP server vào như một handler JSON‑RPC chạy trên HTTP/stream.

Ở giai đoạn này server chưa làm gì hữu ích, nhưng nó đã có thể:

  • thực hiện MCP handshake;
  • trả lời các yêu cầu discovery cơ bản (danh sách tools/resources/prompts — hiện đang trống).

Bước tiếp theo — đăng ký công cụ đầu tiên.

6. Đăng ký tool suggest_gifts qua MCP SDK

Apps SDK chính thức và tài liệu MCP cho thấy cùng một pattern đăng ký công cụ: phương thức registerTool, nơi bạn truyền tên, descriptor (tiêu đề, mô tả, schema tham số) và handler.

Chúng ta đã mô tả type SuggestGiftsInput trong gifts.ts. Bây giờ thêm schema Zod để server có thể validate đầu vào và tự động cung cấp JSON Schema chuẩn cho LLM.

// src/server.ts (đoạn trích)
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),
});

Giờ đăng ký công cụ:

// vẫn ở trong server.ts

mcpServer.registerTool(
  "suggest_gifts",
  {
    title: "Suggest gift ideas",
    description:
      "Gợi ý ý tưởng quà tặng theo độ tuổi, loại mối quan hệ và ngân sách.",
    // SDK sẽ chuyển Zod schema thành JSON Schema cho mô hình
    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 có thể dùng trong widget
      structuredContent: {
        ideas,
      },
    };
  }
);

Điểm chính:

  • inputSchema — schema Zod. SDK cho TS có thể chuyển nó sang JSON Schema và nhờ vậy mô tả công cụ cho mô hình một cách tự động.
  • Handler nhận object có input (loại bạn có được từ schema). Bên trong bạn gọi hàm business của mình.
  • Trong result bạn trả về content — là văn bản mô hình sẽ thấy như kết quả, và nếu muốn, structuredContent với cấu trúc JSON để widget của bạn có thể tiêu thụ.

Nếu trước đó bạn đã làm công cụ qua Apps SDK, đoạn code này sẽ rất quen: pattern giống hệt, chỉ khác là giờ sống trong MCP server riêng.

7. Thêm resource gift_catalog cho dữ liệu

Tool là hành động. Đôi khi ta muốn cung cấp thêm dữ liệu như một resource, để mô hình có thể đọc/tìm kiếm hoặc widget có thể tải template, component, v.v. MCP mô tả riêng khái niệm resource với URI, MIME type và nội dung.

Tạo resource đơn giản gift_catalog trả về danh sách quà tặng khả dụng. Tạm thời đây cũng là mock, nhưng thực tế có thể là xuất từ DB hoặc product feed.

Đầu tiên là catalog:

// src/gifts.ts (bổ sung)
export const giftCatalog: GiftIdea[] = [
  {
    id: "book-1",
    title: "Sách về lập trình",
    price: 25,
    occasion: "learning",
  },
  {
    id: "lego-1",
    title: "Bộ LEGO",
    price: 60,
    occasion: "fun",
  },
];

Giờ đăng ký resource trên server:

// src/server.ts (đoạn trích)
import { giftCatalog } from "./gifts";

mcpServer.registerResource(
  "gift_catalog",
  {
    title: "Gift catalog",
    description: "Catalog quà tặng đơn giản để demo và debug.",
    mimeType: "application/json",
  },
  async () => {
    return {
      contents: [
        {
          uri: "mcp://gift-catalog",
          mimeType: "application/json",
          text: JSON.stringify(giftCatalog, null, 2),
        },
      ],
    };
  }
);

Về mặt logic đang xảy ra gì:

  • tên resource gift_catalog sẽ hiển thị với client khi discovery (trong MCP inspector bạn sẽ thấy nó ở danh sách resources);
  • descriptor chứa mô tả dễ đọc và MIME type;
  • handler trả về mảng contents với URI và text — đó là định dạng chuẩn của resource trong MCP.

Sau này bạn có thể:

  • đọc resource này từ client (ví dụ agent hoặc inspector);
  • dùng nó làm template/dữ liệu cho UI;
  • thử nghiệm: mô hình sử dụng catalog có sẵn thế nào để giải thích lựa chọn cho người dùng.

8. Đăng ký prompt đơn giản

Thực thể thứ ba của MCPprompts, các gợi ý được chuẩn bị sẵn. Chúng giúp không phải lặp lại các system/user prompt dài, mà lưu trên server với tên.

Làm ví dụ nhỏ: prompt birthday_gift, có thể gọi như “mẫu cuộc hội thoại về quà sinh nhật”.

// src/server.ts (đoạn trích)

mcpServer.registerPrompt("birthday_gift", {
  title: "Birthday gift helper",
  description: "Mẫu yêu cầu để gợi ý quà sinh nhật.",
  messages: [
    {
      role: "system",
      content:
        "Bạn là trợ lý tìm quà tặng. Hãy đặt các câu hỏi làm rõ và đề xuất vài lựa chọn.",
    },
    {
      role: "user",
      content:
        "Tôi cần một món quà sinh nhật. Hãy hỏi các thông tin cần thiết và giúp tôi chọn.",
    },
  ],
});

Ở bên dưới, MCP sẽ cho phép client:

  • lấy danh sách prompts (trong inspector bạn sẽ thấy birthday_gift);
  • lấy nội dung của nó và dùng như prompt cơ sở cho mô hình.

Riêng ở module về system prompt và hướng dẫn, chúng ta sẽ phân tích sâu cách các prompt như vậy kết hợp với hướng dẫn toàn cục của ứng dụng. Ở đây điều quan trọng là “nhìn thấy” chúng như một phần của MCP server.

9. Tất cả hoạt động trong runtime như thế nào

Gộp bức tranh tổng thể.

Khi client (ví dụ MCP Inspector hoặc ChatGPT) kết nối tới HTTP endpoint /mcp của chúng ta:

  1. diễn ra handshake: client và server trao đổi thông tin về các khả năng được hỗ trợ (tools/resources/prompts, v.v.);
  2. client gọi các phương thức discovery: nhận danh sách tools, resources, prompts cùng mô tả và schema;
  3. khi mô hình quyết định gọi một tool, nó tạo JSON‑RPC request với method kiểu tools/call hoặc tương tự — SDK phía server chuyển thành lời gọi nội bộ tới handler đã đăng ký qua registerTool;
  4. handler thực thi business logic (ở đây là suggestGifts hoặc trả về giftCatalog) và trả kết quả theo định dạng chuẩn;
  5. SDK tuần tự hóa phản hồi về JSON‑RPC và gửi lại cho client qua cùng HTTP/stream transport.

Tất cả chi tiết của JSON‑RPC, tạo id, định tuyến method, v.v. nằm bên trong @modelcontextprotocol/sdk. Đối với bạn, interface rất giống Apps SDK: bạn làm việc với registerTool/registerResource/registerPrompt và các handler, không cần bận tâm tới giao thức.

10. Chạy cục bộ và bài test đơn giản đầu tiên

Giả sử bạn đã thêm mọi thứ như trên. Giờ chỉ còn chạy.

Trong package.json có thể thêm script:

{
  "scripts": {
    "dev": "ts-node-dev src/server.ts"
  }
}

Chạy:

npm run dev

Trong console sẽ xuất hiện gì đó kiểu:

MCP server listening on http://localhost:4000/mcp

Việc kiểm tra đầy đủ và gọi tool thủ công chúng ta sẽ làm ở bài sau qua MCP Inspector / MCP Jam. Nhưng ngay bây giờ bạn có thể làm một smoke test siêu đơn giản bằng curl:

curl -X POST http://localhost:4000/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

curl này — chỉ là smoke test tùy chọn cho ai thích nhìn “raw” JSON responses. Trong phát triển thực tế bạn gần như luôn giao tiếp với MCP server qua SDK, chứ không tự tay tạo JSON‑RPC request.

Tên method chính xác phụ thuộc phiên bản giao thức và SDK, nhưng ý tưởng là bạn sẽ nhận JSON list, trong đó ở phần tools có suggest_gifts. Nếu method không khớp — không sao: mục tiêu của bài giảng không phải là thuộc lòng mọi tên gọi, mà là để bạn không ngại xem JSON response và hiểu cấu trúc của chúng nhờ các bài trước.

11. Kết nối với ChatGPT App của chúng ta và phát triển tiếp

Hiện tại MCP server chạy độc lập. Ở các mô‑đun tiếp theo bạn sẽ:

  • kết nối nó với MCP Inspector và học cách debug tools/resources/prompts riêng rẽ, không đụng đến ChatGPT;
  • cấu hình ChatGPT App để nó thấy MCP server này như nguồn công cụ;
  • chuyển một phần logic trước kia được hiện thực trong Apps SDK (ví dụ qua built‑in tools) sang lớp MCP;
  • thêm xác thực, logging, kịch bản streaming — trên bộ khung đã sẵn sàng.

Hiện giờ điều quan trọng là:

  • bạn có một service riêng chịu trách nhiệm cho “khả năng” và “dữ liệu” của ứng dụng;
  • service này nói chuyện với client qua tiêu chuẩn MCP, chứ không phải REST tự chế;
  • bạn đã biết tự tay đăng ký tools, resources và prompts mà không ngại giao thức.

12. Vài điều về cấu trúc code và best practices

Ngay cả ở ví dụ nhỏ như thế này, bạn có thể đặt nền cho thói quen tốt.

Thứ nhất, giữ cấu hình server tách riêng. Mọi thứ về tên, phiên bản, logging, thiết lập transport (cổng, path /mcp) dễ dàng tách ra module nhỏ config.ts. Sau này khi deploy lên Vercel hoặc sau MCP gateway, bạn sẽ phải thêm biến môi trường, và bạn sẽ thấy biết ơn vì đã tách sẵn.

Thứ hai, cố gắng để các phương thức registerTool/registerResource/registerPrompt càng “mỏng” càng tốt. Việc mô tả schema, văn bản và business logic — những thứ hợp lý nằm ở các file riêng:

  • gifts.ts — các hàm chọn quà;
  • catalog.ts — làm việc với catalog sản phẩm;
  • prompts.ts — tập hợp các prompt.

Bản thân server.ts khi đó trở thành kiểu “nhà cung cấp MCP”, chỉ việc ghép mọi thứ với nhau.

Thứ ba, nhớ rằng MCP server theo bản chất là reactive: nó chờ client kết nối và gửi request. Điều đó có nghĩa là mọi thao tác blocking hoặc quá lâu bên trong tool sẽ ảnh hưởng trực tiếp đến UX trong ChatGPT. Ở các mô‑đun tiếp theo chúng ta sẽ nói về timeout, thao tác bất đồng bộ và phản hồi dạng stream, nhưng ngay bây giờ hãy nghĩ xem tác vụ nào có thể đưa ra nền, tác vụ nào cần trả lời nhanh.

Insight: ChatGPT chỉ hỗ trợ một phần MCP

Cần hiểu rằng: ChatGPT Apps dùng MCP như transport và format, nhưng không phải là MCP client đầy đủ. Nếu chỉ đọc giao thức, rất dễ đặt kỳ vọng sai về cách mọi thứ sẽ chạy trong runtime.

“MCP thuần” hứa hẹn:

  • resources có thể được đọc động theo yêu cầu của client, chứ không phải một lần cố định;
  • server có thể gửi thông báo resourceChanged/toolChanged và “đẩy” cập nhật mà không cần khởi động lại client;
  • có thể xây một hệ thống khá linh hoạt, nơi tập tools/resources/prompts được điều khiển bởi config hoặc trạng thái bên ngoài.

Trong ngữ cảnh ChatGPT Apps thì không như vậy. Với ứng dụng, bức tranh tĩnh hơn nhiều:

  • khi đăng ký App, ChatGPT đọc một lần mô tả toàn bộ tools và resources;
  • rồi cấu hình này thực tế được cache như một phần của phiên bản ứng dụng;
  • các cập nhật động qua thông báo MCP không được hỗ trợ — nền tảng đơn giản là bỏ qua.

13. Các lỗi thường gặp khi viết MCP server đầu tiên

Lỗi #1: Nhét toàn bộ business logic thẳng vào registerTool.
Cám dỗ “viết nhanh hết vào handler của tool” là rất lớn, nhất là trong ví dụ học tập. Nhưng sau đó nó biến thành cỗ máy khó đọc, lẫn lộn xác thực, làm việc với DB và định dạng phản hồi. Tốt hơn là tách sớm các hàm nghiệp vụ (suggestGifts, làm việc với catalog) sang module riêng, còn handler chỉ làm “ghép nối”.

Lỗi #2: Gắn chặt vào các tên method JSON của MCP.
Đôi khi học viên bắt đầu viết if (method === "tools/list") và tự tay parse JSON. Không cần làm vậy: đó là việc của SDK. Đặc tả MCP và tên method có thể tiến hóa, còn SDK lo phần này. Hãy dùng registerTool, registerResource, registerPrompt và để thư viện quyết định chúng trông ra sao trong JSON‑RPC.

Lỗi #3: Không nghĩ về transport và cố gắng cho ChatGPT dùng server stdio.
Stdio transport lý tưởng cho client cục bộ như môi trường desktop, nơi client có thể chạy server như subprocess. Nhưng ChatGPT giao tiếp qua HTTPS và cần HTTP/stream endpoint. Cố “bắc cầu stdio” qua tunnel thường dẫn đến rắc rối. Với ChatGPT App, hãy làm HTTP transport (Streamable HTTP) ngay từ đầu.

Lỗi #4: Bỏ qua MIME type và cấu trúc của resource.
Với resource, quan trọng không chỉ nội dung mà còn type (mimeType) và URI. Nếu ở đâu cũng ghi text/plain và ném bừa chuỗi JSON, client (và inspector) sẽ khó hiểu dữ liệu đó là gì. Hãy cố gắng chỉ rõ MIME type chính xác (application/json, text/html cho template UI, v.v.) và URI ổn định.

Lỗi #5: Dùng MCP server như “HTTP API ngẫu nhiên”.
Đôi khi nảy sinh ý định: “Vì đã có Express, mình treo thêm /api/whatever và gọi trực tiếp vào đó”. Trộn lẫn MCP endpoint với REST tùy ý là không nên: nó làm phức tạp cấu hình, định tuyến và bảo mật. Dễ hơn là có hợp đồng rõ ràng: /mcp cho MCP, đường khác cho nhu cầu khác, hoặc thậm chí service khác. Ở production điều này đặc biệt quan trọng cho cấu hình gateways và xác thực. Đừng biến MCP server thành “HTTP API ngẫu nhiên” — tập hợp các HTTP handler rời rạc, không gắn với hợp đồng MCP.

Lỗi #6: Không log các thông điệp MCP vào/ra.
Không có log thì MCP server thành hộp đen: “có gì đó không chạy, nhưng không biết là gì”. Ngay từ server đầu tiên, nên ghi ít nhất vào stderr các log có cấu trúc gọn gàng: method của tool, trạng thái, thời gian thực thi. Quan trọng — không log dữ liệu nhạy cảm và token, phần này ta sẽ bàn riêng khi nói về bảo mật.

Lỗi #7: Cố debug mọi thứ ngay qua ChatGPT mà không có inspector.
Tình huống thường gặp: học viên viết MCP server, gắn ngay vào ChatGPT App, và “mọi thứ hỏng một cách khó hiểu”. Trong khi inspector thậm chí chưa chạy lần nào. Kết quả khó biết vấn đề ở giao thức, server, Apps SDK hay hành vi của mô hình. Cách đúng — trước hết đảm bảo MCP server chạy đúng trong môi trường tách biệt (qua MCP Jam / Inspector), rồi mới gắn vào ứng dụng.

Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION