做完之后能得到什么
假设你有一个订单系统。做完这件事之后,用户在 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 不要写进组件代码里。
审核被拒。 多数是隐私政策缺失、测试账号失效、或者错误信息暴露了内部细节。把后端抛出的原始异常包装成人话再返回。
---
下一步建议
跑通之后,先别急着加功能,做三件事:
一是看调用数据。统计哪些工具被高频调用、哪些从来没被触发过、哪些触发了但用户马上又问了一遍(说明结果没用)。这些信号比主观判断可靠。
二是补错误路径。正常路径用户不会反馈,错误路径每一条都会被投诉。把超时、限流、参数非法、下游服务挂了这几种情况分别给出可读的提示。
三是重新审视工具切分。一个"帮我处理订单"的大工具,和一个"查订单 + 改地址 + 取消订单"的小工具组合,在不同场景下各有优势。大工具对模型更友好,小工具对权限控制更友好。根据你的业务风险等级选。
最后,工具描述、组件文案、审核材料这三份东西,建议集中放在一个文档里维护。它们改动的频率比代码高,散落在各处时改起来容易漏。
