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 路由),用于返回礼物的占位数据。

现在我们希望把助手的“大脑”拆到一个独立的 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 服务器。MCP 的官方 JS/TS SDK 位于包 @modelcontextprotocol/sdk。它会代劳 JSON‑RPC、校验与模式转换等琐事:你用 Zod 模式描述参数,SDK 会自动把它们转换为模型可理解的 JSON Schema。

传输层我们需要HTTP 方案:ChatGPT 与远程 MCP 服务器通过网络通信,而不是通过 stdio/本地通道。MCP 规范描述了一种标准的“流式 HTTP”格式——本质上是旧 HTTP+SSE 的演进。在实践中就是一个 HTTP 端点,处理请求(POST/GET),并在需要时以流的形式返回响应。TypeScript 版 MCP 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、目标 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[] {
  // 先用一些 mock
  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. 在其上注册工具、资源与 prompt;
  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 endpoint
  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/stream 的处理器接上去。

此时服务器还没做什么有用的事,但它已经可以:

  • 完成 MCP 握手;
  • 响应基础的发现(discovery)请求(tools/resources/prompts 列表——当前为空)。

下一步——注册第一个工具。

6. 通过 MCP SDK 注册工具 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 可用于小部件
      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;在实际项目里,它可以是数据库导出或商品 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 会在发现阶段暴露给客户端(在 MCP Inspector 里能看到该资源);
  • 描述对象包含人类可读的说明与 MIME 类型;
  • 处理器返回 contents 数组,包含 URI 与文本——这是 MCP 资源的标准格式。

之后你可以:

  • 从客户端(例如代理或 Inspector)读取该资源;
  • 将其作为 UI 模板/数据使用;
  • 做一些实验:看看模型如何使用现成目录向用户解释备选项。

8. 注册一个简单的 prompt

MCP 的第三类实体是prompts(提示词),即预先准备好的提示。它们能避免重复冗长的系统或用户提示,把它们以名称形式存放在服务器端。

做个小例子:prompt birthday_gift,可作为“关于生日礼物的对话模板”来调用。

// src/server.ts(片段)

mcpServer.registerPrompt("birthday_gift", {
  title: "Birthday gift helper",
  description: "用于挑选生日礼物的请求模板。",
  messages: [
    {
      role: "system",
      content:
        "你是一名礼物搜索助手。请提出澄清性问题,并提供若干备选方案。",
    },
    {
      role: "user",
      content:
        "我需要一份生日礼物。请先询问必要的细节并帮助我选择。",
    },
  ],
});

在底层,MCP 允许客户端:

  • 获取 prompt 列表(在 Inspector 中能看到 birthday_gift);
  • 请求其内容,并将其用作模型的基础提示。

在另一个关于 system‑prompt 与指令的模块中,我们会详细讲解这类 prompt 与应用全局指令如何协同。这里仅需把它们“看作” MCP 服务器的一部分即可。

9. 运行时它们如何协同工作

把全貌再梳理一下。

当客户端(例如 MCP Inspector 或 ChatGPT)连接到我们的 HTTP 端点 /mcp 时:

  1. 进行握手:客户端与服务器互换支持的能力信息(tools/resources/prompts 等);
  2. 客户端调用发现(discovery)方法:获取工具、资源、prompt 列表及其说明与模式;
  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 版本,但关键在于你会得到一个包含 tools 的 JSON 列表,其中能看到 suggest_gifts。如果方法名对不上也没关系:本课的目的不是死记硬背所有名称,而是让你不惧查看 JSON 响应并理解其结构,这在之前的课程里我们已经打好基础。

11. 与我们的 ChatGPT App 的关联与后续发展

目前 MCP 服务器是独立运行的。在接下来的模块中你将:

  • 把它接入 MCP Inspector,并学习在不触碰 ChatGPT 的情况下,分别调试 tools/resources/prompts;
  • 配置 ChatGPT App,使其将该 MCP 服务器识别为工具来源;
  • 把此前在 Apps SDK 中实现的部分逻辑(例如通过内置 tools)迁移到 MCP 层;
  • 在现有骨架上增加鉴权、日志、流式场景等能力。

此刻重点在于:

  • 你有一个独立服务,负责应用的“能力”和“数据”;
  • 该服务与客户端通过 MCP 标准通信,而不是自定义 REST;
  • 你已经能不惧协议地手动注册工具、资源与 prompt 了。

12. 关于代码结构与一些最佳实践

即便是这么小的示例,也可以养成好习惯。

其一,把服务器配置独立出来。凡是关于名称、版本、日志、传输配置(端口、/mcp 路径)的东西,都可以放进一个小小的 config.ts。等你要部署到 Vercel 或放到 MCP 网关后面时,会需要添加环境变量,届时你会感谢现在的抽离。

其二,尽量让 registerTool/registerResource/registerPrompt 方法保持尽可能“薄”。模式定义、文案与业务逻辑都很适合放在独立文件里:

  • gifts.ts——礼物选择函数;
  • catalog.ts——商品目录相关;
  • prompts.ts——prompt 集合。

这样 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 服务器时的常见错误

错误一:把全部业务逻辑都塞进 registerTool
在工具处理器里“先快写一版”的诱惑很大,尤其在教学示例里。但它很快会变成一锅粥:校验、数据库访问、响应格式化全混在一起。更好的做法是尽早把业务函数(如 suggestGifts、目录处理)拆到独立模块,在处理器里只做“拼装”。

错误二:死绑某个具体的 MCP JSON 方法名。
有些同学会开始写 if (method === "tools/list") 并手动解析 JSON。这不需要:这正是 SDK 的工作。MCP 规格与方法名可能演进,而 SDK 会处理这些变化。请使用 registerToolregisterResourceregisterPrompt,把“JSON‑RPC 长什么样”交给库。

错误三:不考虑传输层,试图把 ChatGPT 对接到 stdio 服务器。
Stdio 传输非常适合本地客户端(如桌面环境)把服务器作为子进程来启动。但 ChatGPT 通过 HTTPS 通信,它需要 HTTP/流式端点。试图“用隧道把 stdio 搞过去”只会徒增痛苦。面对 ChatGPT App,直接做 HTTP 传输(Streamable HTTP)即可。

错误四:忽视 MIME 类型与资源结构。
对资源而言,重要的不仅是内容,还有类型(mimeType)与 URI。如果处处都用 text/plain 并随意丢 JSON 字符串,客户端(与 Inspector)会更难理解这些数据是什么。请尽量提供正确的 MIME 类型(application/jsontext/html 用于 UI 模板等)与稳定的 URI。

错误五:把 MCP 服务器当作“杂乱的 HTTP API”。
有时会产生这样的冲动:“既然我已经有了 Express,我再挂个 /api/whatever,直接去调它。”不建议把 MCP 端点与任意 REST 混在一起:这会让配置、路由与安全复杂化。更清晰的做法是:/mcp 专用于 MCP,其他用途用单独路径,甚至单独服务。在生产环境,这对配置网关与授权尤为重要。不要把 MCP 服务器变成“杂乱 HTTP‑API”——一堆与 MCP 契约无关的随机 HTTP 接口。

错误六:不记录进出站的 MCP 消息。
没有日志,MCP 服务器就是个黑箱:“哪里不工作我也不知道。”从第一个服务器开始,就至少在 stderr 打印简洁结构化日志:工具方法、状态、耗时。注意不要记录敏感数据与令牌;等到讲安全时我们会单独展开。

错误七:没用 Inspector,就直接在 ChatGPT 里调试一切。
常见情形:同学写了 MCP 服务器,马上接到 ChatGPT App,结果“一切都神秘失灵”。但 Inspector 甚至一次都没启动过。于是就难以判断问题出在协议、服务器、Apps SDK,还是模型行为。正确路径是——先确保 MCP 服务器在隔离环境(通过 MCP Jam / Inspector)下工作正常,再把它接入应用。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION