Skip to content

设置你的服务器

概述

你的MCP服务器是每个Apps SDK集成的基础。它暴露模型可以调用的工具、强制执行身份验证,并打包ChatGPT客户端inline渲染所需的结构化数据和component HTML。本指南将通过Python和TypeScript示例介绍核心构建块。

选择SDK

Apps SDK支持任何实现MCP规范的服务器,但官方SDK是最快的入门方式:

  • Python SDK(官方) – 非常适合快速原型开发,包括官方FastMCP模块。查看仓库 modelcontextprotocol/python-sdk。这与社区的"FastMCP"项目不同。
  • TypeScript SDK(官方) – 如果你的技术栈已经是Node/React,这是理想选择。使用 @modelcontextprotocol/sdk。文档: modelcontextprotocol.io

安装SDK和你喜欢的任何Web框架(FastAPI或Express是常见选择)。

描述你的工具

工具是ChatGPT和你后端之间的契约。定义清晰的机器名称、人类友好的标题和JSON schema,以便模型知道何时以及如何调用每个工具。这也是你连接每个工具元数据的地方,包括身份验证提示、状态字符串和component配置。

指向component模板

除了返回结构化数据外,MCP服务器上的每个工具还应在其描述符中引用HTML UI模板。这个HTML模板将由ChatGPT在iframe中渲染。

  1. 注册模板 – 暴露一个资源,其 mimeTypetext/html+skybridge,其主体加载你编译的JS/CSS bundle。资源URI(例如 ui://widget/kanban-board.html)成为你component的规范ID。
  2. 将工具链接到模板 – 在工具描述符内部,将 _meta["openai/outputTemplate"] 设置为相同的URI。可选的 _meta 字段允许你声明component是否可以发起工具调用或显示自定义状态文本。
  3. 谨慎版本控制 – 当你发布破坏性component更改时,注册新的资源URI并同步更新工具元数据。ChatGPT会积极缓存模板,因此唯一的URI(或缓存破坏的文件名)可以防止加载过时的资源。

模板和元数据就位后,ChatGPT使用每个工具响应中的 structuredContent 负载来hydrate iframe。

以下是示例:

ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { readFileSync } from "node:fs";

// 创建MCP服务器
const server = new McpServer({
  name: "kanban-server",
  version: "1.0.0"
});

// 加载本地构建的资源(由你的component构建产生)
const KANBAN_JS = readFileSync("web/dist/kanban.js", "utf8");
const KANBAN_CSS = (() => {
  try {
    return readFileSync("web/dist/kanban.css", "utf8");
  } catch {
    return ""; // CSS可选
  }
})();

// UI资源(无内联数据赋值;宿主将注入数据)
server.registerResource(
  "kanban-widget",
  "ui://widget/kanban-board.html",
  {},
  async () => ({
    contents: [
      {
        uri: "ui://widget/kanban-board.html",
        mimeType: "text/html+skybridge",
        text: `
<div id="kanban-root"></div>
${KANBAN_CSS ? `<style>${KANBAN_CSS}</style>` : ""}
<script type="module">${KANBAN_JS}</script>
        `.trim(),
      },
    ],
  })
);

server.registerTool(
  "kanban-board",
  {
    title: "Show Kanban Board",
    _meta: {
      "openai/outputTemplate": "ui://widget/kanban-board.html",
      "openai/toolInvocation/invoking": "Displaying the board",
      "openai/toolInvocation/invoked": "Displayed the board"
    },
    inputSchema: { tasks: z.string() }
  },
  async () => {
    return {
      content: [{ type: "text", text: "Displayed the kanban board!" }],
      structuredContent: {}
    };
  }
);

构造你的工具返回的数据

工具响应中的每个工具结果可以包含三个并列字段,这些字段决定了ChatGPT和你的component如何使用负载:

  • structuredContent – 用于hydrate你component的结构化数据,例如播放列表的曲目、房地产应用的房屋、看板应用的任务。ChatGPT将此对象注入你的iframe作为 window.openai.toolOutput,因此请将其限定为你UI需要的数据。模型会读取这些值,可能会叙述或总结它们。
  • content – 可选的自由格式文本(Markdown或纯字符串),模型会逐字接收。
  • _meta – 仅传递给component的任意JSON。用于不应影响模型推理的数据,例如支持下拉列表的完整位置集。_meta 永远不会显示给模型。

你的component接收所有三个字段,但只有 structuredContentcontent 对模型可见。如果你想控制component下方的文本,请使用 widgetDescription

继续看板示例,获取看板数据并返回三个字段,以便component hydrate时不会向模型暴露额外的上下文:

ts
async function loadKanbanBoard() {
  const tasks = [
    { id: "task-1", title: "Design empty states", assignee: "Ada", status: "todo" },
    { id: "task-2", title: "Wireframe admin panel", assignee: "Grace", status: "in-progress" },
    { id: "task-3", title: "QA onboarding flow", assignee: "Lin", status: "done" }
  ];

  return {
    columns: [
      { id: "todo", title: "To do", tasks: tasks.filter((task) => task.status === "todo") },
      { id: "in-progress", title: "In progress", tasks: tasks.filter((task) => task.status === "in-progress") },
      { id: "done", title: "Done", tasks: tasks.filter((task) => task.status === "done") }
    ],
    tasksById: Object.fromEntries(tasks.map((task) => [task.id, task])),
    lastSyncedAt: new Date().toISOString()
  };
}

server.registerTool(
  "kanban-board",
  {
    title: "Show Kanban Board",
    _meta: {
      "openai/outputTemplate": "ui://widget/kanban-board.html",
      "openai/toolInvocation/invoking": "Displaying the board",
      "openai/toolInvocation/invoked": "Displayed the board"
    },
    inputSchema: { tasks: z.string() }
  },
  async () => {
    const board = await loadKanbanBoard();

    return {
      structuredContent: {
        columns: board.columns.map((column) => ({
          id: column.id,
          title: column.title,
          tasks: column.tasks.slice(0, 5) // 为模型保持负载简洁
        }))
      },
      content: [{ type: "text", text: "Here's your latest board. Drag cards in the component to update status." }],
      _meta: {
        tasksById: board.tasksById, // 仅供component使用的完整任务映射
        lastSyncedAt: board.lastSyncedAt
      }
    };
  }
);

构建你的component

现在你已经设置好MCP服务器脚手架,请按照构建自定义UX页面上的说明来构建你的component体验。

本地运行

  1. 构建你的component bundle(参见构建自定义UX页面上的说明)。
  2. 启动MCP服务器。
  3. 将MCP Inspector指向 http://localhost:<port>/mcp,列出工具并调用它们。

Inspector会验证你的响应是否包含结构化内容和component元数据,并inline渲染component。

暴露公共端点

ChatGPT需要HTTPS。在开发过程中,你可以使用隧道服务,例如ngrok。

在单独的终端窗口中运行:

bash
ngrok http <port>
# Forwarding: https://<subdomain>.ngrok.app -> http://127.0.0.1:<port>

在开发者模式下创建connector时使用生成的URL。对于生产环境,部署到具有低冷启动延迟的HTTPS端点。

添加身份验证和存储层

一旦服务器处理匿名流量,决定是否需要用户身份或持久化。身份验证和存储指南展示了如何添加OAuth 2.1流程、Token验证和数据库集成,以便你可以跟踪用户状态和权限。

Component发起的工具访问

默认情况下,只有ChatGPT可以调用工具。如果你希望component能够直接调用工具(例如,在地图上单击标记获取详细信息),请在 _meta 中添加 openai/componentToolAccess: true:

ts
server.registerTool(
  "get-location-detail",
  {
    title: "Get Location Detail",
    _meta: {
      "openai/componentToolAccess": true,
      "openai/outputTemplate": "ui://widget/location-detail.html"
    },
    inputSchema: { locationId: z.string() }
  },
  async ({ locationId }) => {
    const detail = await fetchLocationDetail(locationId);
    return {
      structuredContent: detail,
      content: [{ type: "text", text: `Details for ${detail.name}` }]
    };
  }
);

现在你的component可以通过 window.openai.callTool 直接调用此工具。

添加component描述

如果工具返回UI component,ChatGPT仍需要文本摘要以在对话中显示并在后续轮次中引用。使用工具响应中的 widgetDescription 字段提供人类可读的一两句话总结,描述component中显示的内容:

ts
return {
  structuredContent: { columns },
  widgetDescription: "A kanban board with 3 columns and 8 total tasks across them.",
  content: [{ type: "text", text: "Here's your board." }]
};

如果省略,ChatGPT会从 structuredContentcontent 推断描述,但显式描述可以提供更好的上下文。

提供内容安全策略

默认情况下,component在iframe中运行,具有严格的内容安全策略,可防止未经授权的脚本执行和数据泄漏。如果你的component需要从特定域加载资源(例如地图tiles或字体),请在资源 _meta 中声明CSP覆盖:

ts
server.registerResource(
  "map-widget",
  "ui://widget/map.html",
  {
    _meta: {
      "openai/contentSecurityPolicy": {
        "script-src": ["'self'", "https://api.mapbox.com"],
        "img-src": ["'self'", "https://*.tile.openstreetmap.org"]
      }
    }
  },
  async () => ({ contents: [/* ... */] })
);

ChatGPT会合并你的覆盖与基本策略,因此你只需指定额外的来源。

支持本地化

如果你的应用面向多个地区,component可以通过 window.openai.locale 访问用户的首选语言。使用它来切换UI字符串或日期格式:

ts
const locale = window.openai.locale; // 例如 "en-US", "ja-JP"
const formatter = new Intl.DateTimeFormat(locale);

你的MCP服务器还可以读取 clientContext 头部,以根据区域调整工具响应。

利用客户端上下文提示

ChatGPT在每个工具请求中发送 clientContext,其中包含用户环境的提示,例如他们的时区、设备类型或显示偏好。你的服务器可以使用此上下文来调整响应:

ts
server.registerTool(
  "show-events",
  { title: "Show Events", inputSchema: {} },
  async (args, { clientContext }) => {
    const timezone = clientContext.timezone || "UTC";
    const events = await fetchEvents({ timezone });
    return {
      structuredContent: { events },
      content: [{ type: "text", text: `Events in ${timezone}` }]
    };
  }
);

后续步骤

通过运行的MCP服务器和component,你已准备好添加身份验证、存储和更多工具。浏览身份验证、存储和示例页面,深入了解每个主题。