跳到主内容
快讯直播
AI智模界
教程

把你的服务做成 ChatGPT App:界面、触发与上架

做完之后能得到什么

假设你有一个订单系统。做完这件事之后,用户在 ChatGPT 对话框里说一句"帮我看看 A12345 这个订单到哪了",ChatGPT 会调用你部署的 MCP 服务,把结果渲染成一张卡片:订单号、状态、预计到达时间,下面还有一个"查看物流详情"按钮。点按钮会再次触发你的后端接口,把详情展开。

用户全程没有离开对话窗口,也没有打开你的官网。你的服务变成了 ChatGPT 的一个能力。

具体来说,你会拿到三样东西:

1. 对话内界面:一段 HTML/JS 组件,由 ChatGPT 在对话流里渲染,可以展示结构化数据、接收点击。

2. 自动化触发:模型根据工具描述自动决定何时调用你的服务;组件里的按钮也能主动发起调用;服务端还可以通过轮询或事件推送的方式驱动。

3. 上架流程:从开发者模式自测,到提交表单、通过审核、被更多用户发现。

下面按顺序走一遍。

---

前置条件清单

动手之前,确认这些东西已经就位:

  • 一个能被公网 HTTPS 访问的域名。ChatGPT 不接受 http://localhost 作为连接地址。
  • 本地或服务器上的 Node.js 运行环境(Python 也可以,思路一致)。
  • 你已有业务接口的调用凭据,比如订单查询 API 的 key。
  • 一个 ChatGPT 账号,并且在设置里打开了开发者模式(Developer mode)。入口位置以官方页面为准。
  • 服务条款页和隐私政策页。这两样在提交审核时是必填项。
  • 一个用于演示的测试账号或测试数据,审核人员需要能真实跑通一次。

版本号、依赖包名、字段名这类会随时间变化的东西,一律以官方文档当前版本为准。下面代码里的写法是为了讲清结构。

---

第 1 步:起一个最小可用的 MCP 服务

ChatGPT App 的底座是 MCP(Model Context Protocol)。你的服务对外暴露两个能力:工具(tools)和资源(resources)。工具负责"做事",资源负责"渲染界面"。

先写一个只返回文本的工具,跑通链路,再考虑界面。

```javascript

// server.js

import express from "express";

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

import { z } from "zod";

const server = new McpServer({

name: "order-assistant",

version: "0.1.0",

});

server.registerTool(

"lookup_order",

{

title: "查询订单状态",

description:

"当用户想查询订单进度、物流状态、是否已发货、预计到达时间时调用。" +

"需要用户提供订单号。如果用户只说了商品名,先追问订单号,不要猜测。",

inputSchema: {

orderId: z.string().describe("订单号,通常是一个字母加若干数字"),

},

},

async ({ orderId }) => {

const order = await fetchOrderFromYourAPI(orderId);

return {

content: [

{ type: "text", text: 订单 ${order.id} 当前状态:${order.status} },

],

structuredContent: order,

};

}

);

const app = express();

app.use(express.json());

app.post("/mcp", async (req, res) => {

const transport = new StreamableHTTPServerTransport({

sessionIdGenerator: undefined, // 无状态模式,方便横向扩容

});

res.on("close", () => transport.close());

await server.connect(transport);

await transport.handleRequest(req, res, req.body);

});

app.listen(8787, () => console.log("MCP server on :8787"));

```

关于工具描述:这是整套东西里最容易被做坏的一环。description 不是给同事看的 API 文档,是给模型看的决策依据。写清楚三件事——什么情况下该调用、需要什么输入、什么情况下不要调用。上面那句"如果用户只说了商品名,先追问订单号"就是在做负向约束,能明显降低误调用。

用 cloudflared 或 ngrok 之类的隧道工具把 8787 端口暴露成 HTTPS,拿到一个公网地址,形如 https://xxxx.example.com/mcp。

然后在 ChatGPT 设置里打开开发者模式,新建一个连接器,把这个地址填进去。此时在对话里说"查一下订单 A12345",模型应该会调用你的工具并返回文本。先确认这一步通了再往下做,界面调试的干扰因素会多很多。

---

第 2 步:把结果渲染成对话内卡片

现在加一个资源,也就是组件本身。资源通过 ui:// 这样的私有协议 URI 标识,工具通过元数据声明"我的结果用哪个组件渲染"。

```javascript

// 接在 server.js 里

import fs from "node:fs";

server.registerTool(

"lookup_order",

{

title: "查询订单状态",

description: "当用户想查询订单进度、物流状态、预计到达时间时调用。",

inputSchema: { orderId: z.string().describe("订单号") },

_meta: {

"openai/outputTemplate": "ui://widget/order-card.html",

"openai/toolInvocation/invoking": "正在查询订单…",

"openai/toolInvocation/invoked": "订单信息已就绪",

"openai/widgetAccessible": true,

},

},

async ({ orderId }) => {

const order = await fetchOrderFromYourAPI(orderId);

return {

content: [{ type: "text", text: 订单 ${order.id}:${order.status} }],

structuredContent: order,

};

}

);

server.registerResource(

"order-card",

"ui://widget/order-card.html",

{},

async () => ({

contents: [

{

uri: "ui://widget/order-card.html",

mimeType: "text/html+skybridge",

text: fs.readFileSync("./widgets/order-card.html", "utf8"),

_meta: {

"openai/widgetDomain": "https://app.your-domain.example",

},

},

],

})

);

```

structuredContent 是给组件读的数据,content 是给模型读的文本。两个都返回,模型在后续对话里才能"记得"这次查询的结果。只给组件不给文本,用户追问"刚才那个订单什么时候到"时,模型会一脸茫然。

组件本身是一个自包含的 HTML 文件。样式和脚本都内联进去,不要引用外部 CDN,沙箱环境对外网请求通常有限制。

```html

<!-- widgets/order-card.html -->

<!doctype html>

<html lang="zh-CN">

<head>

<meta charset="utf-8" />

<style>

body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; }

.card { border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; }

.status { font-size: 20px; font-weight: 600; margin: 8px 0; }

button { margin-top: 12px; padding: 8px 16px; border-radius: 8px; cursor: pointer; }

</style>

</head>

<body>

<div class="card" id="root">加载中…</div>

<script>

const order = window.openai?.toolOutput;

function render(data) {

document.getElementById("root").innerHTML = `

<div>订单号:${data.id}</div>

<div class="status">${data.status}</div>

<div>预计到达:${data.eta}</div>

<button id="detail">查看物流详情</button>

`;

document.getElementById("detail").addEventListener("click", async () => {

const res = await window.openai.callTool("get_logistics_detail", {

orderId: data.id,

});

document.getElementById("root").innerHTML =

<pre>${JSON.stringify(res?.structuredContent ?? {}, null, 2)}</pre>;

});

}

if (order) render(order);

window.openai?.notifyIntrinsicHeight?.();

</script>

</body>

</html>

```

组件里能用的桥接对象是 window.openai,常见成员有读取工具输出的 toolOutput、主动调用工具的 callTool、把消息发回对话的 sendFollowUpMessage、以及持久化界面状态的 widgetState。字段名和可用范围以官方文档当前版本为准。

组件高度要主动上报。如果你的卡片内容会动态变化,渲染完成后调用一次高度通知,否则下方内容容易被裁掉。

---

第 3 步:配置自动化触发

触发有三条路径,建议按这个顺序实现。

路径一:模型自动调用。 这是默认行为,靠工具描述驱动。想让它在合适的时机被想起来,描述里要包含用户可能会说的原话。比如"当用户说'我的快递''发货了吗''什么时候到'时调用",比写"查询订单物流信息接口"有效得多。

路径二:组件内按钮触发。 上面的"查看物流详情"就是。适合把一次调用拆成两步、避免一上来就返回大量数据。

路径三:把动作回写到对话。 用户点完按钮后,如果结果值得留痕,用 sendFollowUpMessage 把摘要塞回对话:

```javascript

await window.openai.sendFollowUpMessage({

prompt: 订单 ${order.id} 的物流详情已展开,共 ${res.structuredContent.traces.length} 条记录。,

});

```

关于定时与事件驱动的自动化:ChatGPT 的定时任务、自动化规则在不同套餐下的开放程度不一样,具体能力以官方页面为准。稳妥的做法是——把"主动推送"设计成服务端的定时任务,触发条件满足时调用你已有的通知渠道;对话内的部分只负责响应式调用。这样即使平台侧能力调整,你的核心逻辑也不受影响。

如果确实要做写入类操作(取消订单、修改地址),加一步确认:先返回一张带"确认取消"按钮的卡片,用户点了才真正执行。把破坏性操作暴露给模型直接调用,迟早会有事故。

---

第 4 步:完整跑通一次真实调用

在提交审核之前,自己按用户视角走一遍:

1. 新建一个对话,确认这个连接器是启用状态。

2. 说"帮我查下订单 A12345"。观察是否弹出卡片,状态、时间字段是否正确。

3. 点"查看物流详情",确认二次调用成功且界面更新。

4. 紧接着追问"那它明天能到吗"。这一步是在验证 content 文本有没有正确喂给模型——如果模型答不上来,说明你的工具返回里缺了文本兜底。

5. 故意输入一个不存在的订单号,看错误提示是否友好、是否泄漏了内部堆栈。

6. 换一个账号或退出登录,确认鉴权链路正确。

第 4 步和第 5 步是审核人员大概率会测的,自己先测出来比被拒了再改省事。

---

第 5 步:走上架流程

上架的基本顺序是:开发者模式自测通过 → 在平台提交应用 → 填写元数据 → 等待审核 → 发布。

提交时需要准备的材料通常包括:

  • 应用名称、图标、一句话简介、详细描述。简介里说清楚"能帮用户做什么",不要写技术栈。
  • 服务端点的正式地址,必须 HTTPS,且不能依赖隧道工具。
  • 隐私政策页面和服务条款页面,两个独立可访问的链接。
  • 审核用的测试账号,或者一份可以复现的测试数据说明。
  • 一段演示录屏,覆盖主要工具的正常路径和至少一个错误路径。

审核常见关注点:应用名称与实际功能是否一致、是否在收集与功能无关的用户数据、错误场景是否有兜底提示、组件在窄屏下是否还能用、有没有外部跳转导致用户离开对话。

具体表单字段和审核标准会调整,动手前先读一遍官方页面上的最新要求,别照着旧教程填。

---

常见坑与排错

连接器加不上,报网络错误。 检查地址是不是 HTTPS、证书链是否完整、路径有没有写全(很多框架的挂载点是 /mcp 而不是根路径)。用 curl -X POST 手动打一次 tools/list,能快速区分是服务端问题还是平台侧问题。

工具总是调不到。 九成是描述的问题。把 description 改成用户口吻的场景描述,加上"什么时候不要调用"的负向说明。工具数量超过十来个之后,模型的选择准确率会下降,考虑把相关工具合并成一个带 action 参数的入口。

组件白屏。 先检查有没有引用外部资源。再把 structuredContent 打印到页面上确认数据到了。组件里的报错不会冒泡到对话里,需要自己在渲染逻辑外层包 try/catch 并把错误显示出来。

数据不更新。 组件是无状态的,每次工具调用会重新渲染。如果用户在组件里点了按钮改了状态,下次重新渲染会丢。用 widgetState 之类的机制持久化,或者干脆把状态放回服务端。

鉴权返工。 早期用 API key 硬编码能跑通,但上架要求走 OAuth 之类的标准流程。一开始就把鉴权层抽象出来,后面替换成本低很多。传输上只接受 HTTPS,token 不要写进组件代码里。

审核被拒。 多数是隐私政策缺失、测试账号失效、或者错误信息暴露了内部细节。把后端抛出的原始异常包装成人话再返回。

---

下一步建议

跑通之后,先别急着加功能,做三件事:

一是看调用数据。统计哪些工具被高频调用、哪些从来没被触发过、哪些触发了但用户马上又问了一遍(说明结果没用)。这些信号比主观判断可靠。

二是补错误路径。正常路径用户不会反馈,错误路径每一条都会被投诉。把超时、限流、参数非法、下游服务挂了这几种情况分别给出可读的提示。

三是重新审视工具切分。一个"帮我处理订单"的大工具,和一个"查订单 + 改地址 + 取消订单"的小工具组合,在不同场景下各有优势。大工具对模型更友好,小工具对权限控制更友好。根据你的业务风险等级选。

最后,工具描述、组件文案、审核材料这三份东西,建议集中放在一个文档里维护。它们改动的频率比代码高,散落在各处时改起来容易漏。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。