NLWeb 接入 ChatGPT:基于 MCP Server 的 Apps SDK 集成架构与实战指南
NLWeb 接入 ChatGPT:基于 MCP Server 的 Apps SDK 集成架构与实战指南
本指南以仓库文档 docs/nlweb-chatgpt-integration.md 为核心骨架,深入剖析 NLWeb 如何通过 MCP(Model Context Protocol)Server 接入 OpenAI ChatGPT:从五层架构链路、双端数据流与响应格式,到本地快速联调、生产 CDN 部署与排障手段。读完本文,你将掌握整套集成栈的组件职责、关键源码调用链,并能在本地把
nlweb-search工具和nlweb-list结果 Widget 跑通于 ChatGPT 桌面端。
NLWeb 是采用 Python 实现的参考级自然语言搜索与问答系统(仓库主目录见 README.md),其背后连接 Schema.org 结构化网页、Milvus/Elasticsearch/OpenSearch 等向量数据库与自定义检索源。本文要解决的场景是:如何让 ChatGPT 桌面应用通过标准 MCP 协议调用 NLWeb 的能力,并把结构化的搜索结果(或 Data Commons 可视化图表)以富交互 Widget 的形式直接渲染在对话流中。整条链路由 ChatGPT 桌面端 → NLWeb MCP Server(Node.js)→ NLWeb AppSDK Adapter(Python aiohttp)→ NLWeb Core Server(Python)→ 数据源 五层构成。
一、集成全景:五层架构链路
原始文档给出了完整的集成架构图,它清楚地标明了每一层的职责与位置:
┌─────────────────────────────────────────────────────────────────┐
│ ChatGPT Desktop App │
│ (OpenAI AppSDK Client) │
└────────────────────┬────────────────────────────────────────────┘
│ MCP Protocol (HTTP/SSE or stdio)
│ Tool: nlweb-list
│ Resource: ui://widget/nlweb-list.html
▼
┌─────────────────────────────────────────────────────────────────┐
│ NLWeb MCP Server │
│ (Node.js/TypeScript) │
│ Location: /openai-apps-sdk-integration/nlweb_server_node/ │
└────────────────────┬────────────────────────────────────────────┘
│ HTTP GET/POST to /ask
│ JSON Request/Response
▼
┌─────────────────────────────────────────────────────────────────┐
│ NLWeb AppSDK Adapter │
│ (Python aiohttp Server) │
│ Location: AskAgent/python/webserver/appsdk_adapter_server.py │
└────────────────────┬────────────────────────────────────────────┘
│ Proxies to NLWeb Core
│ Transforms response format
▼
┌─────────────────────────────────────────────────────────────────┐
│ NLWeb Core Server │
│ (Python aiohttp Server) │
│ Location: AskAgent/python/webserver/ │
└────────────────────┬────────────────────────────────────────────┘
│ Queries and processes
▼
┌─────────────────────────────────────────────────────────────────┐
│ Data Sources │
│ • Schema.org websites │
│ • Vector databases (Milvus, Elasticsearch, OpenSearch) │
│ • Custom retrieval providers │
└─────────────────────────────────────────────────────────────────┘
对照源码可以确认每一层的落地位置:
| 层级 | 实现语言/框架 | 仓库位置 | 核心职责 |
|---|---|---|---|
| ChatGPT Desktop App | OpenAI AppSDK 客户端 | — | 调用 MCP 工具、渲染 Widget |
| NLWeb MCP Server | Node.js/TypeScript(官方 MCP SDK) | openai-apps-sdk-integration/nlweb_server_node/ | 注册工具与资源、转发查询、附加 Widget 元数据 |
| NLWeb AppSDK Adapter | Python aiohttp | AskAgent/python/webserver/appsdk_adapter_server.py | 代理 /ask、转换响应为 AppSDK 格式 |
| NLWeb Core Server | Python aiohttp | AskAgent/python/webserver/ | 处理自然语言查询、检索、生成结构化结果 |
| 数据源 | — | — | Schema.org 网站、向量库、自定义检索 Provider |
主目录 openai-apps-sdk-integration/README.md 对 MCP 与 Apps SDK 的配合做了权威说明:MCP 是连接 LLM 客户端与外部工具/数据/用户界面的开放规范,AppSDK 利用响应中的额外元数据(如内联 HTML)在助手消息旁渲染富 UI 组件。一个最小化的 Apps SDK MCP 集成只需实现三种能力:List tools(广告工具及其 JSON Schema 输入输出契约)、Call tools(执行 call_tool 并返回结构化内容)、Return widgets(在响应元数据中内嵌资源,供客户端内联渲染)。
二、核心组件逐个拆解
1. ChatGPT Desktop App:AppSDK 客户端
ChatGPT 桌面端扮演 AppSDK 客户端角色:用户在对话框中输入自然语言,模型根据工具契约决定调用 nlweb-list(文档与测试脚本中的工具名,下文会说明与源码注册名的差异),随后将返回的 Widget 资源渲染为可视化结果列表。
2. NLWeb MCP Server:Node.js/TypeScript 网关
- 位置:openai-apps-sdk-integration/nlweb_server_node/
- 协议:MCP over HTTP/SSE(也支持 stdio)
- 职责:向 ChatGPT 注册工具、提供 UI Widget 资源、把查询转发给 AppSDK Adapter
从源码 nlweb_server_node/src/server.ts 可以看到实现细节:
- 服务默认监听 HTTP 端口 8000(
PORT环境变量可改,见 L416-L417),暴露两个端点:SSE 流GET /mcp与消息回传POST /mcp/messages?sessionId=...(L354-L355),并自带 CORS 预检处理。 - 注册两个 Widget 资源(L217-L247):
ui://widget/nlweb-list.html(Schema.org 常规结果)与ui://widget/nlweb-visualization.html(图表/地图等可视化),MIME 类型均为text/html+skybridge。 - 动态 Widget 选择(
selectWidget,L67-L92):遍历structuredContent.results,只要任一结果带有visualizationType/html/script字段就切换到可视化 Widget,否则使用列表 Widget——这就是后文要讲的“混合 Widget”设计在服务端的体现。 - 工具调用(L292-L342):解析参数 →
callNLWebAsk发起 GET 请求 → 返回content+structuredContent+_meta(内嵌openai.com/widget资源),错误时返回isError: true的 AppSDK 兼容错误包。
需要特别指出一个命名差异:原始文档的数据流示例与测试脚本 test-server.mjs 中调用的工具名是 nlweb-list,而当前 server.ts 实际注册的工具名为 nlweb-search。两者输入参数结构完全一致,联调时请以实际注册名为准。
3. NLWeb AppSDK Adapter:Python aiohttp 转换层
- 位置:AskAgent/python/webserver/appsdk_adapter_server.py
- 职责:
- 代理请求到 NLWeb Core 的
/ask端点 - 将 NLWeb 响应转换为 AppSDK 格式
- 同时支持流式(streaming)与非流式响应
- 过滤空消息、包装 AppSDK 信封
- 代理请求到 NLWeb Core 的
源码级要点(appsdk_adapter_server.py):
- 环境变量(L40-L43):
APPSDK_ADAPTER_HOST(默认0.0.0.0)、APPSDK_ADAPTER_PORT(默认 8100)、NLWEB_BASE_URL(默认http://localhost:8000,即 NLWeb Core 地址),最终拼接出上游{base_url}/ask。 - 同时注册
GET /ask与POST /ask(L50-L51);POST 支持application/json与application/x-www-form-urlencoded两种请求体。 - 流式开关解析(
_resolve_streaming_choice,L122-L140):优先取 URL 参数streaming,其次取 JSON/form 请求体,默认true;解析时把"1"/"true"/"yes"/"on"视为真。 - 非流式转换(
_transform_non_streaming,L170-L199):若上游已是 AppSDK 结构(含structuredContent与content)则原样透传;否则提取 messages 并调用 core/utils/appsdk_adapter.py 的convert_messages_to_appsdk_response完成转换,同时把原始负载放入structuredContent.legacyResponse。 - 流式消费(
_consume_stream,L201-L255):逐行读取 SSE 文本,跳过注释行,识别data:前缀与[DONE]结束符,聚合所有消息后统一转换;若流提前结束则附加partial_warning。
转换层的核心逻辑在 AskAgent/python/core/utils/appsdk_adapter.py 的 convert_messages_to_appsdk_response(L92-L204)中:聚合所有 message_type == "result" 消息的条目为 results,提取 nlws / answer / GeneratedAnswer 类型消息为 generatedAnswers 与文本片段,并在无合成答案时生成 "Found N results for '<query>'." 之类的兜底文本。
AppSDK 响应格式(继承自原始文档):
{
"structuredContent": {
"query": "...",
"results": [...],
"messages": [...],
"metadata": {...}
},
"content": [
{"type": "text", "text": "..."}
]
}
4. NLWeb Core Server:Python 查询引擎
处理自然语言查询、从配置的数据源检索、返回结构化结果。其 REST 语义参见 docs/nlweb-rest-api.md 与 docs/nlweb-control-flow.md,一次查询从消息进入 NLWeb 到产出结构化结果的完整生命周期见 docs/life-of-a-chat-query.md。
5. 数据源
- Schema.org 标记的网站(结构化数据检索的主要对象)
- 向量数据库:Milvus、Elasticsearch、OpenSearch
- 自定义检索 Provider(仓库 AskAgent/python/retrieval_providers/ 下有 azure_search、bing、milvus、qdrant、snowflake 等多种实现)
三、完整数据流:一次查询的生命周期
查询流
原始文档给出了端到端的查询步骤,这里结合源码补齐每一跳的细节:
- 用户 → ChatGPT:"Find spicy snacks on seriouseats site"
- ChatGPT → MCP Server:模型发起工具调用
{ "name": "nlweb-list", "arguments": { "query": "spicy snacks", "site": "seriouseats", "mode": "list" } } - MCP Server → AppSDK Adapter:HTTP GET 到
/ask
对应源码 server.ts 的GET /ask?query=spicy%20snacks&site=seriouseats&mode=list&streaming=falsecallNLWebAsk:固定追加streaming=false,按需追加site与mode,并用AbortController实现REQUEST_TIMEOUT超时控制。 - AppSDK Adapter → NLWeb Core:代理转发请求
- NLWeb Core:处理查询 → 从数据源检索 → 生成可视化 → 返回结果
- AppSDK Adapter:转换为 AppSDK 格式
- MCP Server:附加 UI 模板元数据(
openai/outputTemplate等) - ChatGPT:使用 Widget 渲染可视化结果
响应流
三个关键阶段的响应体必须完整理解,它们分别来自原始文档:
① NLWeb Core 输出(原始 messages 结构):
{
"messages": [
{
"message_type": "result",
"content": [
{
"@type": "Recipe",
"name": "Spicy Buffalo Cauliflower Wings",
"description": "Crispy baked cauliflower with spicy buffalo sauce...",
"image": "https://example.com/image.jpg",
"url": "https://seriouseats.com/spicy-buffalo-wings"
}
]
}
]
}
② AppSDK Adapter 输出(转换后):
{
"structuredContent": {
"query": "spicy snacks",
"results": [
{
"name": "Spicy Buffalo Cauliflower Wings",
"description": "Crispy baked cauliflower with spicy buffalo sauce...",
"schema_object": {
"@type": "Recipe",
"image": "https://example.com/image.jpg",
"url": "https://seriouseats.com/spicy-buffalo-wings"
},
"score": 0.92
}
],
"messages": [...],
"metadata": {...}
},
"content": [
{"type": "text", "text": "Found 5 results for 'spicy snacks' on seriouseats.com"}
]
}
③ MCP Server 输出(附加 Widget 元数据后):
{
"content": [
{"type": "text", "text": "Found 5 results for 'spicy snacks'."}
],
"structuredContent": {
"query": "spicy snacks",
"results": [...],
"messages": [...],
"metadata": {...}
},
"_meta": {
"openai/outputTemplate": "ui://widget/nlweb-list.html",
"openai/toolInvocation/invoking": "Searching NLWeb",
"openai/toolInvocation/invoked": "Found results",
"openai/widgetAccessible": true,
"openai/resultCanProduceWidget": true,
"openai.com/widget": {
"type": "resource",
"resource": {
"uri": "ui://widget/nlweb-list.html",
"mimeType": "text/html+skybridge",
"text": "<div id=\"nlweb-list-root\"></div>..."
}
}
}
}
对照源码 server.ts 可见 _meta 中的 openai.com/widget 内嵌资源正是由 widgetResource 注入的,openai/outputTemplate 等键则由 widgetMeta 辅助函数(L28-L36)统一生成。
四、快速上手:从安装到在 ChatGPT 中提问
完整安装指引见 openai-apps-sdk-integration/README.md 与 openai-apps-sdk-integration/nlweb_server_node/README.md。此处给出快速摘要:
前置条件
- Node.js 18+
- npm 或 pnpm
- 运行中的 NLWeb 后端(默认
localhost:8100,即 AppSDK Adapter)
安装依赖并构建 Widget
# 在仓库根目录执行
cd openai-apps-sdk-integration
npm install # 或 pnpm install
# 构建 Widget 静态资源
npm run build # 产出 assets/ 下带版本哈希的 .html/.js/.css
构建由 build-all.mts 编排:它会扫描 src/**/index.{tsx,jsx} 入口,分别打包 nlweb-list 与 nlweb-datacommons 两个 Widget,然后用 package.json 版本号计算 4 位 SHA-256 哈希重命名产物(如 nlweb-list-2d2b.js),并生成自包含的内联 HTML。
本地三终端联调
按主 README 的 Quick Start,本地联调需要三个终端:
终端 1 —— 提供 Widget 静态资源:
cd openai-apps-sdk-integration
npm run serve # 在 http://localhost:4444 提供服务,已开启 CORS
终端 2 —— 启动 MCP Server:
cd openai-apps-sdk-integration/nlweb_server_node
npm start # 默认运行在端口 8000
终端 3 —— 用 ngrok 暴露到公网:
ngrok http 8000
拿到形如 https://<custom_endpoint>.ngrok-free.app/mcp 的公网地址后,在 ChatGPT 中 Settings → Connectors 添加即可。ChatGPT 桌面端需要先开启 Developer Mode(Settings → Developer Mode),再在 Connectors 中把 ngrok 地址加入。
启动前建议核对各服务端口:NLWeb Core 默认 8000、AppSDK Adapter 默认 8100、Widget Server 4444、MCP Server 默认也是 8000(见主 README 的 Service Startup Order 与 Troubleshooting 章节)。当 NLWeb Core 与 MCP Server 同机运行且都占用 8000 时,请通过 PORT 环境变量错开 MCP Server 端口,并用 NLWEB_APPSDK_BASE_URL 指到 8100 的 Adapter。
验证联调
# 测试 MCP Server(先启动 MCP Server 再测试)
cd openai-apps-sdk-integration/nlweb_server_node
npm run test
# 测试 AppSDK Adapter(托管端点或 localhost:8100)
curl "https://localhost:8100/ask?query=test&mode=list&streaming=false"
npm run test 执行 test-server.mjs:建立 SSE 连接 → 读取 endpoint 事件中的 sessionId → 依次调用 tools/list、resources/list、resources/read(读取 ui://widget/nlweb-list.html)→ tools/call(用 spicy snacks + seriouseats 实测工具调用),并逐项打印断言结果。注意该脚本调用的是 nlweb-list 工具名,若服务端注册的是 nlweb-search,请相应调整。
五、UI Widget 与混合渲染架构
MCP Server 内置一个基于 React 的交互式 Widget,用于在 ChatGPT 中展示 NLWeb 搜索结果,支持:
- 图片、评分与可展开的描述
- Schema.org 类型支持(Product、Place、Article 等)
- 基于 Tailwind CSS 的响应式设计
仓库采用混合 Widget 架构(详见 openai-apps-sdk-integration/README.md 的 Widget Architecture 章节):由于 ChatGPT 会根据工具定义缓存要加载的 Widget,动态切换 Widget 可能被客户端忽略,因此 nlweb-list 这一个 Widget 同时兼容两类数据:
- Schema.org 结构化数据(传统搜索结果:餐厅、文章、地点等)
- 交互式可视化(Data Commons 图表、地图、排行榜、嵌入组件)
客户端判定逻辑在 src/nlweb-list/index.jsx 的 App 组件中(L174、L218-L237):只要某条 result 带 visualizationType / html / script 字段就走 VisualizationBlock(位于 src/nlweb-datacommons/VisualizationBlock.jsx)渲染图表地图,否则走常规 ResultItem(图片、标题、描述、评分、Schema.org 类型标注)。可视化所需的 <script> 会被动态注入 <head>(仅注入一次),脚本执行后由 web component 自初始化。
两类结果的数据格式示例:
Schema.org 结果:
{
"@type": "Restaurant",
"name": "Joe's Pizza",
"description": "Best pizza in town",
"image": "https://...",
"rating": 4.5,
"url": "https://..."
}
可视化结果:
{
"@type": "StatisticalResult",
"visualizationType": "map",
"html": "<datacommons-map header='...' ...></datacommons-map>",
"script": "<script src='https://datacommons.org/datacommons.js'></script>",
"places": ["geoId/06"],
"variables": ["Percent_Person_WithDiabetes"]
}
服务端的选择逻辑(selectWidget)与客户端判定保持一致,两侧共用 visualizationType || html || script 三个判别字段,从而保证"服务端选 Widget、客户端渲染数据"的一致行为。Widget 定制流程:修改 src/nlweb-list/index.jsx → npm run build → 记录新哈希 → 更新 server.ts 中引用的哈希文件名 → 重启服务。
六、配置参数与环境变量
MCP Server 侧
| 环境变量 | 默认值 | 说明 |
|---|---|---|
NLWEB_APPSDK_BASE_URL |
http://localhost:8100(文档示例曾写作 https://localhost:8100,以源码 server.ts 为准) |
NLWeb AppSDK Adapter 地址 |
REQUEST_TIMEOUT |
30000 |
请求 NLWeb 的超时时间(毫秒) |
PORT |
8000 |
HTTP 模式下的服务端口 |
完整配置示例(来自 nlweb_server_node/README.md):
export NLWEB_APPSDK_BASE_URL="http://localhost:8100"
export REQUEST_TIMEOUT="60000"
export PORT="8000"
工具输入参数(JSON Schema)
nlweb-search 工具的输入参数:
| 参数 | 必填 | 说明 |
|---|---|---|
query |
是 | 搜索问题或查询语句 |
site |
否 | 限定站点(如 seriouseats,不带域名后缀) |
mode |
否 | 响应模式:list(默认)、summarize、generate |
prev |
否 | 此前对话轮次数组,用于上下文 |
AppSDK Adapter 侧
| 环境变量 | 默认值 | 说明 |
|---|---|---|
APPSDK_ADAPTER_HOST |
0.0.0.0 |
监听地址 |
APPSDK_ADAPTER_PORT |
8100 |
监听端口 |
NLWEB_BASE_URL |
http://localhost:8000 |
NLWeb Core 上游地址 |
七、生产部署
生产部署分为“托管 Widget 静态资源”与“部署 MCP Server”两件事,完整步骤见 openai-apps-sdk-integration/DEPLOYMENT.md。
1. 构建并托管 Widget 资源
cd openai-apps-sdk-integration
npm run build
ls -la assets/ | grep nlweb-list # 确认哈希,如 nlweb-list-2d2b.js
将 assets/ 下的产物上传到 CDN(任选其一,均需开启 CORS):
- CloudFlare R2 / Pages:
wrangler r2 object put nlweb-assets/nlweb-list-2d2b.js --file=assets/nlweb-list-2d2b.js - AWS S3 + CloudFront:
aws s3 cp assets/nlweb-list-2d2b.js s3://your-bucket/widgets/,并配置允许GET、AllowedOrigins: ["*"]的 CORS 规则 - Azure Blob Storage + CDN:
az storage blob upload --account-name youraccount --container-name widgets --name nlweb-list-2d2b.js --file assets/nlweb-list-2d2b.js --content-type application/javascript
2. 更新 MCP Server 中的资源地址
编辑 nlweb_server_node/src/server.ts,把 Widget 的 html 模板中的 CSS/JS 指向 CDN:
const nlwebWidget: NLWebWidget = {
id: "nlweb-list",
title: "NLWeb Results",
templateUri: "ui://widget/nlweb-list.html",
invoking: "Searching NLWeb",
invoked: "Found results",
html: `
<div id="nlweb-list-root"></div>
<link rel="stylesheet" href="https://YOUR-CDN-URL/nlweb-list-2d2b.css">
<script type="module" src="https://YOUR-CDN-URL/nlweb-list-2d2b.js"></script>
`.trim(),
responseText: "Rendered a NLWeb result list!"
};
3. 部署 MCP Server
- Azure App Service:
az webapp create --runtime "NODE:18-lts"→az webapp config appsettings set配置NLWEB_APPSDK_BASE_URL/REQUEST_TIMEOUT/PORT→az webapp deployment source config-zip上传打包文件。 - AWS(EC2 / App Runner)+ PM2:安装 Node 18 → 设置环境变量 →
pm2 start npm --name "nlweb-mcp" -- run start:http→ 用 nginx 反向代理/mcp,注意 SSE 需关闭缓冲(proxy_buffering off; proxy_cache off;)。 - ngrok 快速测试:
npm run start:http后ngrok http 8000。
4. 在 ChatGPT 中接入生产端点
ChatGPT 要求 HTTPS 端点。在 Settings → Developer Mode 开启开发者模式后,Settings → Connectors 添加 Connector,填入 https://your-domain.com/mcp(或 ngrok 地址),随后新建对话提问"Search for spicy snacks on seriouseats.com"验证 Widget 渲染。
生产环境还需注意安全事项(来自 DEPLOYMENT.md):HTTPS 必选、CORS 最小化放行、服务端限流、按需为 NLWeb 后端调用加认证、为 Widget 资源配置 CSP 头。更新 Widget 的完整流程是:重新构建 → 上传带新哈希的资源 → 更新 server.ts 中的 URL → 重新部署。
八、测试与排障
测试清单
# 1. 检查 MCP Server 是否运行(应返回 SSE 流)
curl -N https://your-domain.com/mcp
# 2. 运行自动化测试套件
cd openai-apps-sdk-integration/nlweb_server_node
npm run test
# 3. 验证 Widget 资源可访问
curl https://your-cdn-url/nlweb-list-2d2b.css
curl https://your-cdn-url/nlweb-list-2d2b.js
# 4. 全栈连通性(NLWeb Core / Adapter / Widget Server)
curl http://localhost:8000/ask?query=test
curl http://localhost:8100/ask?query=test
curl http://localhost:4444/nlweb-list-2d2b.js
可视化不渲染的排查顺序
- 确认四层服务全部运行,且 URL 协议正确:Adapter 应为
http://localhost:8100而非https://。 - 查看浏览器控制台:应出现
🎨 NLWeb List Widget日志且hasVisualizations: true,以及✅ Injecting HTML for visualization消息。 - 查看 MCP Server 日志:
Widget Selection Debug中hasVisualization: true,结果带html/script/visualizationType字段。 - 在 DevTools → Network 中确认
datacommons.js从datacommons.org正常加载。 - 常见坑:Adapter 未启动(用
python -m webserver.appsdk_adapter_server启动);结果不在structuredContent.results中(检查 Adapter 转换);Widget 缓存(在 ChatGPT 中硬刷新Cmd+Shift+R/Ctrl+Shift+R)。
服务启动顺序
按 openai-apps-sdk-integration/README.md 的建议顺序启动:① NLWeb Core(8000 或托管)→ ② AppSDK Adapter(8100,负责转换响应)→ ③ Widget Server(4444,提供静态资源)→ ④ MCP Server(连接 Adapter)。
九、相关文档导航
MCP Server 侧:
- Main README —— 安装、构建与本地联调
- Server README —— 服务用法、测试与排障
- Deployment Guide —— 生产部署
NLWeb Core 侧:
- NLWeb REST API ——
/ask接口语义 - AppSDK Adapter —— 转换层设计
- Control Flow —— 控制流与组件协作
- Life of a Chat Query —— 一条聊天查询的完整生命周期