CodeGym /Các khóa học /ChatGPT Apps /Hiện thực hóa công cụ phía máy chủ: từ lời gọi đến phản h...

Hiện thực hóa công cụ phía máy chủ: từ lời gọi đến phản hồi

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

1. Bức tranh tổng thể: đường đi gọi tool qua máy chủ

Trước khi viết code, hãy cố định kiến trúc. Điều này giúp không sa lầy vào chi tiết.

Trong thuật ngữ Apps SDK + MCP mọi thứ như sau: chúng ta có một máy chủ MCP (trong khoá học này là Route Handler app/mcp/route.ts trong Next.js), nơi đăng ký các công cụ và tài nguyên, đồng thời hiện thực các handler cho những công cụ đó.

Sơ đồ cấp cao:

sequenceDiagram
    participant User as Người dùng
    participant Chat as ChatGPT (mô hình)
    participant App as ChatGPT App
    participant MCP as Máy chủ MCP / backend
    participant DB as Catalog/API bên ngoài

    User->>Chat: "Hãy gợi ý quà tặng..."
    Chat->>App: quyết định gọi tool `suggest_gifts`
    App->>MCP: JSON-RPC call_tool (tên + tham số)
    MCP->>MCP: Xác thực dữ liệu, uỷ quyền
    MCP->>DB: Yêu cầu catalog/lọc
    DB-->>MCP: Danh sách ứng viên
    MCP-->>App: structuredContent + content + _meta
    App-->>Chat: Chuyển kết quả cho mô hình + vào widget
    Chat-->>User: Giải thích lựa chọn, hiển thị widget

Ý chính: máy chủ không biết gì về “ma thuật” của mô hình. Nó chỉ thấy một yêu cầu bình thường: tên công cụ + tham số, và có trách nhiệm trả về một phản hồi có cấu trúc. Còn mô hình thì không thấy code của bạn; nó chỉ thấy:

  • có những công cụ nào và schema của chúng;
  • các tham số mà chính nó đã tạo ra;
  • JSON phản hồi mà bạn trả về.

Vì vậy nhiệm vụ của chúng ta trong bài này — cẩn thận hiện thực phần ở giữa: máy chủ MCP và các handler cho tools.

Insight: mcp-tools limit

Trong máy chủ MCP, số lượng công cụ là một chỉ số bị giới hạn giống như bộ nhớ hay token ngữ cảnh. Về mặt hình thức bạn có thể đăng ký hàng chục, thậm chí hàng trăm tools, nhưng nền tảng và mô hình không làm việc với chúng theo tuyến tính: mỗi công cụ mới đều tăng “nhiễu” khi định tuyến.

Thực tế gợi ý các mốc:

  • trần cứng cho ChatGPT ≈ tối đa 128 MCP-tools trên một máy chủ;
  • dải làm việctối đa 50 công cụ. Vượt quá thì chất lượng giảm rõ rệt: mô hình bắt đầu nhầm lẫn những tools mô tả gần nhau, ít nhớ tool hiếm, và thường chọn sai.

Anthropic cũng tương tự: giới hạn cỡ tối đa 100 tools, và họ khuyến nghị giữ khoảng đến 50.

2. Máy chủ sống ở đâu trong template Next.js + Apps SDK

Trong module 2 chúng ta đã dựng template Next.js chính thức cho ChatGPT App và lướt qua cấu trúc của nó. Giờ xem MCP‑server sống ở đâu và liên kết với widget thế nào.

Nếu bạn dùng template này, máy chủ MCP thường được hiện thực trong tệp app/mcp/route.ts (App Router). Chính tại đó ChatGPT gửi các cuộc gọi JSON‑RPC: tools/call, resources/list, handshake v.v.

Cấu trúc dự án điển hình:

my-chatgpt-app/
├─ app/
│  ├─ mcp/
│  │  └─ route.ts          # Máy chủ MCP + đăng ký công cụ
│  ├─ page.tsx             # React widget (UI)
│  ├─ layout.tsx           # Root layout, Bootstrap SDK
│  └─ globals.css          # Global styles
│
├─ proxy.ts                # CORS và những thứ khác
├─ next.config.ts
├─ package.json
├─ tsconfig.json
└─ .env

Trong route.ts chúng ta:

  1. tạo một thể hiện máy chủ MCP (qua @modelcontextprotocol/sdk);
  2. đăng ký công cụ (server.registerTool(...));
  3. xác định HTTP handler, nhận request từ ChatGPT và chuyển tiếp vào máy chủ MCP.

Tiếp theo ta sẽ viết code bằng TypeScript dựa trên cấu trúc này.

3. Máy chủ MCP tối thiểu và handler của công cụ

Bắt đầu từ cái đơn giản nhất: tạo máy chủ và thêm công cụ học tập suggest_gifts, trả về placeholder.

Giả sử MCP‑SDK đã được cài:

pnpm add @modelcontextprotocol/sdk

Và tạo app/mcp/route.ts đơn giản:

// app/mcp/route.ts
import { NextRequest } from "next/server";
import { McpServer } from "@modelcontextprotocol/sdk/server";

const server = new McpServer({ name: "giftgenius-mcp" });

// Đăng ký công cụ với schema tối thiểu
server.registerTool(
  "suggest_gifts",
  {
    title: "Gợi ý quà tặng",
    description: "Gợi ý quà theo sở thích và ngân sách.",
    inputSchema: {
      type: "object",
      properties: {
        query: { type: "string", description: "Mô tả ngắn về người nhận." },
      },
      required: ["query"],
    },
  },
  async ({ input }) => {
    // Logic nghiệp vụ sẽ đặt ở đây
    return {
      content: [
        {
          type: "text",
          text: `Placeholder: quà tặng cho "${input.query}".`,
        },
      ],
      structuredContent: {},
    };
  }
);

// HTTP handler của Next.js
export async function POST(req: NextRequest) {
  const body = await req.text(); // Chuỗi JSON-RPC
  const response = await server.handle(body);
  return new Response(response, {
    status: 200,
    headers: { "Content-Type": "application/json" },
  });
}

Đây đã là một biến thể chạy được: ChatGPT có thể gọi suggest_gifts, và máy chủ trả về một placeholder dạng văn bản.

Quan trọng là server.registerTool nhận:

  • tên công cụ;
  • metadata và JSON Schema của input;
  • handler — hàm async, nơi nhận tham số input.

Nhưng hiện chưa có kiểm tra hợp lệ, chưa có structured output đúng chuẩn, cũng chưa có uỷ quyền. Giờ ta sẽ làm các phần đó.

4. Kiểm tra hợp lệ dữ liệu đầu vào và tách lớp

Tại sao chỉ JSON Schema là chưa đủ

Đúng là nền tảng tự kiểm tra những thứ cơ bản theo schema: kiểu dữ liệu, thuộc tính bắt buộc, v.v. Nhưng:

  • mô hình có thể gửi dữ liệu không hợp lý (ví dụ, ngân sách −100 hoặc danh sách sở thích 1000 phần tử);
  • bạn có các ràng buộc nghiệp vụ (ngân sách tối đa, đơn vị tiền tệ hỗ trợ, v.v.);
  • đôi khi ChatGPT hoặc client khác có thể hành xử lạ và gửi thứ gì đó hoàn toàn bất ngờ.

Vì vậy bên trong handler vẫn cần kiểm tra hợp lệ bổ sung.

Tách code: handler ↔ logic nghiệp vụ

Để code phía máy chủ không biến thành “mì ống”, tiện hơn là giữ logic nghiệp vụ riêng. Ví dụ, tạo app/mcp/gifts.ts:

// app/mcp/gifts.ts
export type SuggestGiftsInput = {
  age?: number | null;
  relationship: "friend" | "partner" | "colleague";
  maxBudget: number;
  interests: string[];
};

export type GiftItem = {
  id: string;
  title: string;
  price: number;
  currency: "USD";
  score: number;
  tags: string[];
  shortDescription: string;
};

// "Cơ sở" quà tặng đơn giản
const CATALOG: GiftItem[] = [
  {
    id: "board-game-1",
    title: "Board game 'Chiến lược vũ trụ'",
    price: 39,
    currency: "USD",
    score: 0.93,
    tags: ["board_games", "strategy", "2-4_players"],
    shortDescription: "Món quà tuyệt vời cho người yêu thích board game.",
  },
  // ...
];

export function suggestGifts(input: SuggestGiftsInput): GiftItem[] {
  if (input.maxBudget <= 0) {
    throw new Error("Ngân sách phải là số dương.");
  }

  const filtered = CATALOG.filter(
    (item) => item.price <= input.maxBudget
  );

  // Đơn giản hoá: chỉ sắp xếp theo score và lấy top-3
  return filtered.sort((a, b) => b.score - a.score).slice(0, 3);
}

Giờ trong handler của MCP tool chúng ta sẽ làm:

  • phân tích input;
  • ánh xạ sang kiểu SuggestGiftsInput;
  • gọi an toàn suggestGifts;
  • đóng gói kết quả về định dạng mà ChatGPT và UI của chúng ta hiểu.

5. Hiện thực handler: từ input đến structuredContent

Viết lại registerTool trong route.ts, dùng logic nghiệp vụ của chúng ta:

// app/mcp/route.ts (trích đoạn)
import { suggestGifts, SuggestGiftsInput } from "./gifts";

server.registerTool(
  "suggest_gifts",
  {
    title: "Gợi ý quà tặng",
    description:
      "Dùng khi cần gợi ý quà theo sở thích, ngân sách và loại mối quan hệ.",
    inputSchema: {
      type: "object",
      properties: {
        age: {
          type: "integer",
          minimum: 0,
          maximum: 120,
          description: "Tuổi của người nhận (nếu biết).",
        },
        relationship: {
          type: "string",
          enum: ["friend", "partner", "colleague"],
          description: "Loại mối quan hệ với người nhận.",
        },
        maxBudget: {
          type: "number",
          minimum: 1,
          description: "Ngân sách tối đa bằng USD.",
        },
        interests: {
          type: "array",
          items: { type: "string" },
          description: "Sở thích của người nhận (ví dụ: board games, hiking).",
        },
      },
      required: ["relationship", "maxBudget", "interests"],
    },
  },
  async ({ input }) => {
    // Kiểm tra logic cơ bản
    if (!Array.isArray(input.interests) || input.interests.length === 0) {
      return {
        isError: true,
        content: [
          {
            type: "text",
            text: "Cần cung cấp ít nhất một sở thích của người nhận.",
          },
        ],
        structuredContent: { errorCode: "NO_INTERESTS" },
      };
    }

    const payload: SuggestGiftsInput = {
      age: input.age ?? null,
      relationship: input.relationship,
      maxBudget: input.maxBudget,
      interests: input.interests,
    };

    const items = suggestGifts(payload);

    if (items.length === 0) {
      return {
        content: [
          {
            type: "text",
            text:
              "Không tìm thấy quà phù hợp trong ngân sách đã cho. Hãy tăng ngân sách hoặc thay đổi sở thích.",
          },
        ],
        structuredContent: {
          items: [],
          emptyReason: "NO_MATCHES",
        },
      };
    }

    return {
      content: [
        {
          type: "text",
          text: `Đã tìm thấy ${items.length} gợi ý quà phù hợp.`,
        },
      ],
      structuredContent: {
        items: items.map((item) => ({
          id: item.id,
          title: item.title,
          price: item.price,
          currency: item.currency,
          shortDescription: item.shortDescription,
          tags: item.tags,
        })),
      },
    };
  }
);

Có vài điểm quan trọng ở đây.

Thứ nhất, ta kiểm tra rõ ràng rằng interests không phải danh sách rỗng. Dù JSON Schema về hình thức có cho phép mảng rỗng, với chúng ta thì yêu cầu như vậy vô nghĩa. Tốt hơn là trả lỗi dễ hiểu ngay, thay vì cố gắng dựng một danh sách ngẫu nhiên.

Thứ hai, ta trả về hai nhóm dữ liệu:

  • content — cho mô hình. Đây là phần tóm tắt ngắn: “đã tìm thấy N phương án”. Mô hình sẽ dùng nó trong câu trả lời cho người dùng.
  • structuredContent — cho mô hình và cho UI. Đây là JSON có cấu trúc với danh sách quà tặng mà widget của chúng ta có thể render dưới dạng thẻ.

Lỗi thường gặp — nhồi cả đống JSON vào content. Không nên làm vậy: mô hình sẽ tốn token và có thể bị rối. Hãy giữ content ngắn gọn, còn chi tiết đưa vào structuredContent.

6. Thêm UI template và _meta/openai/outputTemplate

Ở cấp độ Apps SDK, máy chủ cũng nói với ChatGPT dùng UI template nào để hiển thị kết quả của công cụ. Việc này thông qua resources và _meta["openai/outputTemplate"]: máy chủ đăng ký một tài nguyên HTML với mimeType: "text/html+skybridge", và tool trong phản hồi sẽ tham chiếu đến nó.

Trong template Next.js điều này thường được bọc sẵn, nhưng giản lược sẽ như sau:

// đâu đó khi khởi tạo máy chủ MCP
server.registerResource("ui://widget/gifts.html", {
  name: "Gift suggestions widget",
  mimeType: "text/html+skybridge",
  // tiếp theo: cách trả HTML (template nhúng hoặc tệp)
});

Còn trong phản hồi của công cụ:

return {
  content: [{ type: "text", text: `Đã tìm thấy ${items.length} món quà.` }],
  structuredContent: { items: /* ... */ },
  _meta: {
    "openai/outputTemplate": "ui://widget/gifts.html",
  },
};

Khi đó ChatGPT không chỉ hiểu cấu trúc kết quả, mà còn tải đúng HTML/JS cho widget; React component của chúng ta bên trong iframe sẽ đọc window.openai.toolOutput và render danh sách quà tặng.

Phần UI sẽ được nói kỹ hơn trong các bài về xử lý ToolOutput → UI (cùng module này), nên bây giờ chỉ chú ý đến mối liên hệ: handler của công cụ chịu trách nhiệm không chỉ cho dữ liệu nghiệp vụ, mà còn cho việc gắn vào UI template nào. Ở đây chúng ta nhìn vào liên kết đó từ góc độ máy chủ MCP: chỉ định template nào và bỏ gì vào structuredContent.

Insight

Nhà phát triển ChatGPT thiết kế widget như một template để hiển thị JSON. Vì thế họ dùng tên outputTemplate cho nó. Ý tưởng gốc là: ChatGPT gọi mcp-tool, và mcp-tool trả về JSON và đôi khi kèm widget. Nếu không có widget, ChatGPT tự quyết định cách hiển thị JSON.

Còn nếu có widget, ChatGPT hiển thị widget, truyền JSON vào widget dưới dạng toolOutput và widget phải hiển thị JSON. Widget là template để hiển thị JSON. Chính vì vậy nó được cache ngay ở giai đoạn đăng ký ứng dụng trong Store.

Bạn có thể dùng widget tuỳ ý: trong đó có thể gọi fetch(). Nhưng nếu hiểu ý đồ ban đầu của đội ChatGPT, bạn sẽ dễ chấp nhận một số giới hạn hiện tại và, có thể, cả các thay đổi trong tương lai.

7. Uỷ quyền và quyền truy cập trong handler

Đến giờ ta giả định mọi thứ — dữ liệu công khai. Trên thực tế, một số công cụ yêu cầu uỷ quyền: truy cập tài khoản người dùng, đơn hàng, thanh toán, tài liệu, v.v.

Trong thuật ngữ Apps SDK / MCP, bạn có thể đặt securitySchemes cho công cụ và sau đó kiểm tra token cùng ngữ cảnh trong handler.

Ví dụ đơn giản:

server.registerTool(
  "list_user_orders",
  {
    title: "Danh sách đơn hàng của người dùng",
    description: "Trả về các đơn hàng gần đây của người dùng đã đăng nhập.",
    inputSchema: { type: "object", properties: {}, additionalProperties: false },
    _meta: {
        securitySchemes: [{ type: "oauth2", scopes: ["orders.read"] }],        
    }  
  },
  async ({ auth }) => {
    if (!auth?.accessToken) {
      return {
        isError: true,
        content: [
          {
            type: "text",
            text: "Cần đăng nhập để xem đơn hàng.",
          },
        ],
        _meta: {
          // Yêu cầu ChatGPT mở UI OAuth
          "mcp/www_authenticate": [
            'Bearer resource_metadata="https://your-mcp.example.com/.well-known/oauth-protected-resource", error="insufficient_scope", error_description="Hãy đăng nhập để tiếp tục."',
          ],
        },
      };
    }

    // Tại đây kiểm tra token, issuer, audience, scope...
    const orders = await fetchUserOrders(auth.accessToken);

    return {
      content: [
        {
          type: "text",
          text: `Đã tìm thấy ${orders.length} đơn hàng gần đây.`,
        },
      ],
      structuredContent: { orders },
    };
  }
);

Điều quan trọng cần hiểu là:

  • ChatGPT không “tự đoán” các kiểm tra của bạn. Nó chỉ chuyển token và ngữ cảnh; còn bạn phải làm uỷ quyền đúng chuẩn.
  • Trường đặc biệt _meta["mcp/www_authenticate"] nói với nền tảng: “cần hiển thị UI đăng nhập/cập nhật token cho người dùng”. Nếu không có cái này, ChatGPT chỉ thấy lỗi.

Những phức tạp của uỷ quyền sẽ nói riêng ở module 10; hiện tại nắm khái niệm cơ bản: kiểm tra token trong handler, không tin vào mô hình.

8. Tương tác với API bên ngoài và CSDL: lớp và thực hành

Cám dỗ “làm hết trong handler” rất lớn: parse tham số, truy vấn DB, lọc, ánh xạ sang structuredContent, ghi log và đôi chút triết học — tất cả trong một hàm 150 dòng. Nó giống như viết cả ứng dụng trong pages/index.tsx — làm được nhưng rất đau đớn.

Tốt hơn nhiều là tách lớp:

// gifts-repository.ts
import type { GiftItem } from "./gifts";

export async function fetchGiftsFromApi(
  maxBudget: number,
  interests: string[]
): Promise<GiftItem[]> {
  const resp = await fetch("https://example.com/api/gifts", {
    method: "POST",
    body: JSON.stringify({ maxBudget, interests }),
    headers: { "Content-Type": "application/json" },
  });

  if (!resp.ok) {
    throw new Error(`Gift API error: ${resp.status}`);
  }

  const data = (await resp.json()) as GiftItem[];
  return data;
}
// gifts.ts (cập nhật)
import { fetchGiftsFromApi } from "./gifts-repository";

export async function suggestGifts(input: SuggestGiftsInput): Promise<GiftItem[]> {
  if (input.maxBudget <= 0) {
    throw new Error("Ngân sách phải là số dương.");
  }

  const items = await fetchGiftsFromApi(input.maxBudget, input.interests);

  return items.sort((a, b) => b.score - a.score).slice(0, 3);
}
// route.ts (trích đoạn handler)
  async ({ input }) => {
    try {
      const payload: SuggestGiftsInput = {
        age: input.age ?? null,
        relationship: input.relationship,
        maxBudget: input.maxBudget,
        interests: input.interests,
      };

      const items = await suggestGifts(payload);

      // ...
    } catch (err) {
      console.error("suggest_gifts failed", err);
      return {
        isError: true,
        content: [
          {
            type: "text",
            text: "Đã xảy ra lỗi khi gợi ý quà. Vui lòng thử lại sau.",
          },
        ],
        structuredContent: {
          errorCode: "INTERNAL_ERROR",
        },
      };
    }
  }

Cách tiếp cận này mang lại vài lợi ích.

  • Dễ kiểm thử: có thể viết unit test cho suggestGiftsfetchGiftsFromApi mà không cần chạy máy chủ MCP.
  • Dễ đọc: handler vẫn là adapter mỏng giữa giao thức (MCP) và logic của bạn.
  • Tái sử dụng: nếu sau này cần cùng tính năng gợi ý quà ở nơi khác (ví dụ, REST API riêng), không cần “bóc” logic ra khỏi MCP.

9. Ghi log và quan sát cơ bản

Hiện thực công cụ phía máy chủ là nơi tuyệt vời để chăm lo quan sát tối thiểu ngay từ đầu. Trong production bạn sẽ muốn biết:

  • các công cụ nào được gọi;
  • với tham số nào (dĩ nhiên không có PII);
  • mất bao lâu để xử lý;
  • bao nhiêu lỗi và lỗi gì.

Hiện chúng ta đang tìm hiểu ChatGPT App, nên để logger chuyên nghiệp sau. Một logger bao cho handler có thể như sau:

// simple-logger.ts
export function logToolInvocationStart(tool: string, args: unknown) {
  console.log(
    JSON.stringify({
      level: "info",
      event: "tool_invocation_started",
      tool,
      timestamp: new Date().toISOString(),
      // Không bao giờ log PII trong prod!
      args,
    })
  );
}

export function logToolInvocationEnd(tool: string, ms: number, success: boolean) {
  console.log(
    JSON.stringify({
      level: "info",
      event: "tool_invocation_finished",
      tool,
      durationMs: ms,
      success,
      timestamp: new Date().toISOString(),
    })
  );
}
// route.ts (wrapper cho handler)
import { logToolInvocationStart, logToolInvocationEnd } from "./simple-logger";

server.registerTool(
  "suggest_gifts",
  { /* ...meta... */ },
  async ({ input }) => {
    const startedAt = Date.now();
    logToolInvocationStart("suggest_gifts", {
      relationship: input.relationship,
      maxBudget: input.maxBudget,
      interestsCount: Array.isArray(input.interests)
        ? input.interests.length
        : 0,
    });

    try {
      // ... logic chính ...
      const duration = Date.now() - startedAt;
      logToolInvocationEnd("suggest_gifts", duration, true);
      return result;
    } catch (err) {
      const duration = Date.now() - startedAt;
      logToolInvocationEnd("suggest_gifts", duration, false);
      throw err;
    }
  }
);

Về sau, trong các module về metric, SLO và monitoring, bạn có thể xây biểu đồ và cảnh báo dựa trên các log này. Nhưng nên tập thói quen ghi log ngay bây giờ.

10. Kết quả máy chủ đi vào widget (và ngược lại) như thế nào

Ở mục 6 ta đã gắn kết quả công cụ với UI template qua _meta["openai/outputTemplate"]. Giờ nhìn theo hướng còn lại — structuredContent đi vào React widget và làm gì với nó trong UI.

Dù bài này tập trung vào máy chủ, quan trọng là hiểu bạn đang thiết kế không chỉ “API cho mô hình” mà còn “API cho UI”. Máy chủ trả về:

  • structuredContent — dữ liệu mà cả mô hình và widget đều thấy (qua toolOutput);
  • content — mô tả “nén” của kết quả cho mô hình;
  • _meta — trường private cho widget: openai/outputTemplate, openai/widgetCSP, openai/widgetDomain v.v.

Bên trong React widget bạn sẽ làm kiểu như:

// app/page.tsx (trích đoạn)
type ToolOutput = {
  items?: {
    id: string;
    title: string;
    price: number;
    currency: string;
    shortDescription: string;
    tags: string[];
  }[];
  emptyReason?: string;
};

declare global {
  interface Window {
    openai?: {
      toolOutput?: ToolOutput;
    };
  }
}

export default function GiftWidget() {
  const output = typeof window !== "undefined"
    ? window.openai?.toolOutput
    : undefined;

  if (!output) {
    return <div>Đang chờ kết quả gợi ý quà tặng…</div>;
  }

  if (!output.items || output.items.length === 0) {
    return <div>Không có quà phù hợp. Hãy thử thay đổi điều kiện.</div>;
  }

  return (
    <ul>
      {output.items.map((item) => (
        <li key={item.id}>
          <strong>{item.title}</strong> — {item.price} {item.currency}
        </li>
      ))}
    </ul>
  );
}

Đó là lý do quan trọng để structuredContent có hợp đồng ổn định và thân thiện với UI: các trường riêng rẽ, không lồng nhau đến 10 tầng.

Phần này sẽ nói chi tiết trong một bài khác của module 4; tại đây chỉ khẳng định: máy chủ và widget dựa vào cùng một cấu trúc structuredContent.

11. Xử lý lỗi trên máy chủ: định dạng và chiến lược

Ở mục 8–9 chúng ta đã lướt qua lỗi và ghi log trong handler. Giờ gom lại thành một định dạng thống nhất: trả lỗi công cụ thế nào để cả mô hình và UI cùng xử lý được.

Lỗi trong handler là không tránh khỏi: API ngoài có thể sập, input có thể xấu, hay chính bạn gõ nhầm. Điều chính yếu — đừng biến chúng thành “500 Internal Server Error không giải thích” đối với mô hình và người dùng.

Hiện thực tốt cho công cụ phía máy chủ:

  • phân biệt lỗi kiểm tra hợp lệ từ người dùng/mô hình và lỗi nội bộ;
  • trả về trường rõ ràng isErrorerrorCode dễ hiểu trong structuredContent;
  • đưa cho con người một thông điệp thân thiện trong content.

Ví dụ (giả sử metadata của công cụ — title, description, inputSchema v.v. — đã được tách ra biến meta để không lặp ở đây):

function makeErrorResult(message: string, code: string) {
  return {
    isError: true,
    content: [
      {
        type: "text",
        text: message,
      },
    ],
    structuredContent: {
      errorCode: code,
    },
  };
}

server.registerTool(
  "suggest_gifts",
  meta,
  async ({ input }) => {
    try {
      if (input.maxBudget > 10000) {
        return makeErrorResult(
          "Ngân sách quá lớn. Vui lòng điều chỉnh yêu cầu (tối đa 10000 USD).",
          "BUDGET_TOO_HIGH"
        );
      }

      const items = await suggestGifts({
        age: input.age ?? null,
        relationship: input.relationship,
        maxBudget: input.maxBudget,
        interests: input.interests,
      });

      if (!items.length) {
        return {
          content: [
            {
              type: "text",
              text:
                "Không tìm thấy quà với ngân sách này. Hãy thử thay đổi sở thích hoặc tăng ngân sách.",
            },
          ],
          structuredContent: {
            items: [],
            emptyReason: "NO_MATCHES",
          },
        };
      }

      return {/* kết quả bình thường */};
    } catch (err) {
      console.error(err);
      return makeErrorResult(
        "Lỗi nội bộ máy chủ khi gợi ý quà.",
        "INTERNAL_ERROR"
      );
    }
  }
);

Định dạng như vậy giúp cả mô hình (có thể thử thay đổi tham số) và UI (widget có thể hiển thị thông báo đặc thù cho từng errorCode).

Tính bền bỉ, idempotence và thiết kế công cụ an toàn sẽ được nói ở các bài kế tiếp; ngay bây giờ hãy hình thành thói quen: tốt hơn trả lỗi rõ ràng còn hơn âm thầm làm điều kỳ lạ.

Cuối bài chúng ta sẽ gom các điểm này thành danh sách lỗi điển hình khi hiện thực công cụ phía máy chủ, để tiện dùng như checklist.

12. Ví dụ ngắn end‑to‑end: từ yêu cầu đến phản hồi

Gom những gì đã làm thành một chuỗi logic trong ứng dụng GiftGenius.

  1. Người dùng nhắn trong ChatGPT:
    “Hãy gợi ý quà cho bạn, người đó thích board game, ngân sách tối đa 50 đô.”
  2. Mô hình, biết về công cụ suggest_gifts và schema của nó, quyết định gọi và tạo tool_call:
    {
      "tool": "suggest_gifts",
      "arguments": {
        "relationship": "friend",
        "maxBudget": 50,
        "interests": ["board games"],
        "age": null
      }
    }
    
  3. Nền tảng gửi JSON‑RPC này đến máy chủ MCP của chúng ta (POST /app/mcp), Next.js truyền body vào server.handle(...).
  4. Handler suggest_gifts của chúng ta:
    • kiểm tra rằng interests không rỗng;
    • gọi suggestGifts(payload);
    • nhận mảng GiftItem[] (top‑3 theo score);
    • đóng gói vào structuredContent.items và thêm _meta["openai/outputTemplate"] = "ui://widget/gifts.html".
  5. ChatGPT nhận phản hồi, đưa structuredContent vào ngữ cảnh, tải tài nguyên HTML của widget gifts.html, truyền toolOutput vào đó.
  6. React widget của chúng ta đọc window.openai.toolOutput.items và render danh sách quà; mô hình dựa trên contentstructuredContent để viết cho người dùng lời giải thích vì sao các món quà phù hợp.
  7. Người dùng bấm, ví dụ, “Hiển thị thêm” trong widget — widget gọi callTool qua SDK → lại vào handler của chúng ta nhưng với tham số khác (ví dụ, tăng ngân sách).

Toàn bộ chuỗi này dựa vào việc hiện thực công cụ phía máy chủ:

  • nhận input có cấu trúc theo JSON Schema đã thống nhất;
  • kiểm tra dữ liệu cẩn thận;
  • gọi logic nghiệp vụ tách biệt;
  • trả structured output ổn định;
  • khi cần, chỉ định UI template và metadata.

13. Lỗi điển hình khi hiện thực công cụ phía máy chủ

Lỗi số 1: “Tất cả trong một chỗ” — handler khổng lồ.
Khi toàn bộ logic và tương tác API ngoài sống bên trong server.registerTool(..., async () => { ... }), code sẽ phình to và thành khối đơn khó đọc. Chỉ một thay đổi nhỏ cũng dễ làm hỏng mọi thứ. Nên tách logic nghiệp vụ ra các hàm/module riêng và giữ handler là adapter mỏng.

Lỗi số 2: Tin mù quáng vào JSON Schema.
Nhiều nhà phát triển nghĩ: “Đã có schema — tức là đầu vào luôn hợp lệ”. Nhưng mô hình có thể gửi giá trị lạ, và client ngoài càng dễ. Không thể chỉ dựa vào kiểu và JSON Schema — cần kiểm tra logic (giới hạn ngân sách, độ dài mảng, giá trị cho phép, v.v.).

Lỗi số 3: Nhồi mọi thứ vào content và bỏ qua structuredContent.
Đôi khi người ta đặt cả JSON khổng lồ dưới dạng chuỗi vào content “cho chắc”. Điều này làm prompt của mô hình ồn ào và tốn token, còn UI khổ sở vì phải giải mã chuỗi thay vì nhận cấu trúc chuẩn. Tốt hơn là giữ content ngắn, còn chi tiết bỏ vào structuredContent.

Lỗi số 4: Định dạng structured output không ổn định.
Hôm nay items là mảng object có id, title, price, ngày mai bỗng đổi price thành amount, thế là widget hỏng. Hoặc thêm tầng lồng mới. Có thể làm, nhưng cần version hoá contract, hoặc tiến hoá schema từng bước nhỏ. Nếu không, UI và test sẽ liên tục vỡ.

Lỗi số 5: Thiếu xử lý lỗi có ý nghĩa.
Ném exception và hy vọng nền tảng “tự xử lý” không phải chiến lược hay. Mô hình sẽ thấy JSON‑RPC error khó hiểu, người dùng thấy một mảng đỏ, còn bạn mất ngữ cảnh. Tốt hơn là trả về isError, errorCode rõ ràng và thông điệp thân thiện, đồng thời log chi tiết ở máy chủ.

Lỗi số 6: Bỏ qua uỷ quyền và tin vào mô hình.
Đôi khi dev nghĩ: “Mô hình thông minh, nó sẽ không gọi công cụ này nếu người dùng chưa đăng nhập”. Thực ra mô hình không biết ACL và hạn mức của bạn; nó chỉ thấy mô tả tools. Mọi kiểm tra quyền phải nằm trong handler phía máy chủ, bất kể công cụ được mô tả ra sao.

Lỗi số 7: Ghi log mọi thứ, kể cả PII.
Rất dễ theo thói quen log toàn bộ input. Với ChatGPT App điều đó có thể chứa PII (tên, e‑mail, địa chỉ, v.v.), vi phạm chính sách OpenAI và lẽ thường. Nên chỉ log thông tin tổng hợp/ẩn danh: loại quan hệ, dải ngân sách, số lượng sở thích.

Lỗi số 8: Không có timeout và retry khi gọi API ngoài.
Nếu công cụ trong handler gọi fetch đến API ngoài mà không có timeout và retry, bất kỳ độ trễ nào của API đó sẽ trông như “ChatGPT bị treo”. Người dùng nghĩ ứng dụng hỏng. Phía máy chủ cần đặt giới hạn thời gian, xử lý timeout và trả lỗi có ý nghĩa.

1
Nhiệm vụ
ChatGPT Apps, mức độ, bài học
Đã khóa
Hồ sơ Echo (MCP tool tối thiểu)
Hồ sơ Echo (MCP tool tối thiểu)
1
Nhiệm vụ
ChatGPT Apps, mức độ, bài học
Đã khóa
Split bill (handler mỏng + logic nghiệp vụ tách riêng + lỗi)
Split bill (handler mỏng + logic nghiệp vụ tách riêng + lỗi)
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION