NLWeb 接入 ChatGPT:基于 MCP Server 的 Apps SDK 集成架构与实战指南

原创2026-10-10 01:08:5565 阅读
文章标签:AI 应用MCP 服务AI Agent后端前端

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 网关

从源码 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 转换层

源码级要点(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 等多种实现)

三、完整数据流:一次查询的生命周期

查询流

原始文档给出了端到端的查询步骤,这里结合源码补齐每一跳的细节:

  1. 用户 → ChatGPT:"Find spicy snacks on seriouseats site"
  2. ChatGPT → MCP Server:模型发起工具调用
    {
      "name": "nlweb-list",
      "arguments": {
        "query": "spicy snacks",
        "site": "seriouseats",
        "mode": "list"
      }
    }
    
  3. MCP Server → AppSDK Adapter:HTTP GET 到 /ask
    GET /ask?query=spicy%20snacks&site=seriouseats&mode=list&streaming=false
    
    对应源码 server.ts 的 callNLWebAsk:固定追加 streaming=false,按需追加 site 与 mode,并用 AbortController 实现 REQUEST_TIMEOUT 超时控制。
  4. AppSDK Adapter → NLWeb Core:代理转发请求
  5. NLWeb Core:处理查询 → 从数据源检索 → 生成可视化 → 返回结果
  6. AppSDK Adapter:转换为 AppSDK 格式
  7. MCP Server:附加 UI 模板元数据(openai/outputTemplate 等)
  8. 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 同时兼容两类数据:

  1. Schema.org 结构化数据(传统搜索结果:餐厅、文章、地点等)
  2. 交互式可视化(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

可视化不渲染的排查顺序

  1. 确认四层服务全部运行,且 URL 协议正确:Adapter 应为 http://localhost:8100 而非 https://。
  2. 查看浏览器控制台:应出现 🎨 NLWeb List Widget 日志且 hasVisualizations: true,以及 ✅ Injecting HTML for visualization 消息。
  3. 查看 MCP Server 日志:Widget Selection Debug 中 hasVisualization: true,结果带 html / script / visualizationType 字段。
  4. 在 DevTools → Network 中确认 datacommons.js 从 datacommons.org 正常加载。
  5. 常见坑: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 侧:

NLWeb Core 侧:

登录后查看全文
NLWeb