diff --git a/CONTEXT.md b/CONTEXT.md index f665a70..077a3d8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -23,10 +23,10 @@ | `internal/integration/superagent` | SuperAgent Open API 出站适配 | 已实现并通过模拟 Provider 测试,默认关闭 | | `cmd/superagent-probe` | 无业务数据的显式连通性探针 | 已实现;需要项目专属测试配置 | | `cmd/postgis-probe` | 不读取业务行的 PostGIS readiness 探针 | 已实现;需要只读数据库配置 | -| `Dockerfile` / `compose.yaml` | 测试环境容器构建与单实例进程托管 | 已建立;容器内监听 8080,只发布宿主机回环端口 16587,目标服务器尚未验证 | +| `Dockerfile` / `compose.yaml` | 测试环境容器构建与单实例进程托管 | 已建立;容器内监听 8080,只发布宿主机回环端口 16587;目标机镜像已构建,容器启动和 health 待重建后验证 | | `pkg` | 可被外部 module 复用的稳定 Go API | 当前为空 | | `docs/import` | 字段/表映射、通用模板和本地数据库样例 | 样例 SQL 含受限数据并被 Git 忽略,不会执行 | -| SuperAgent | 对话理解、工具选择和答案组织 | 已按仓库内 2026-07-12 协议基线实现客户端;当前环境待联调 | +| SuperAgent | 对话理解、工具选择和答案组织 | 对话客户端按仓库内 2026-07-12 协议基线实现;同一 SuperAgent 中 `th-hotel-simple-superagent` 已稳定启用/调用,证明其兼容档案不依赖版本配置;公网消防 `/mcp` 已收到一次请求并到达 Go,但旧版本门禁返回错误,消防兼容档案待部署验证,公网消防链路尚未调用数据库工具 | | PostgreSQL/PostGIS | 消防空间业务事实的预期权威来源 | 8 表共 4,055 条记录;4,048 条非空几何已标记 EPSG:4326,严格 readiness 与本地真实工具冒烟均通过;35 条无效几何按当前策略排除并告警 | ## 3. 技术栈 @@ -40,7 +40,7 @@ - 配置:环境变量;支持 HTTP、SuperAgent、Chat、MCP 与 PostGIS 配置,并默认关闭 Chat 及两个外部方向。 - Chat API:标准库 HTTP/SSE;原生 `/api/chat` 使用静态联调 Bearer,可选 `completion` 兼容入口使用同一信任方向的 `xtoken`;默认要求 Chat 凭证至少 32 个可打印 ASCII 字符,受控迁移时可通过默认关闭的 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN` 兼容已交付短凭证;两者共享精确 Origin、严格 JSON、总超时、有界单进程会话和同会话并发冲突。兼容入口只在严格成功后发送正文。 - SuperAgent:标准库 HTTP/SSE 客户端,分离 Session 创建和消息发送,支持严格完成判定与既有 Run 断流恢复。 -- MCP:标准库 HTTP/JSON-RPC,协议基线 `2025-06-18`,同步 JSON 响应,独立 Bearer 和 7 个只读工具;地名工具只搜索现有业务记录并要求用户确认候选。 +- MCP:标准库 HTTP/JSON-RPC,服务端固定返回版本标识 `2025-06-18`,同步 JSON 响应,独立 Bearer 和 7 个只读工具;按已稳定接通的 `th-hotel-simple-superagent` 兼容档案处理,initialize 中的 `protocolVersion` 和后续 `MCP-Protocol-Version` Header 都不作为版本拒绝门禁,SuperAgent 无需也不能配置版本。固定返回该版本不表示支持任意其他版本,也不是追求最新协议。地名工具只搜索现有业务记录并要求用户确认候选。 - PostgreSQL:`github.com/jackc/pgx/v5 v5.10.0` 原生连接池;连接默认只读并设置 statement timeout。 - PostGIS:`ST_Covers`、`ST_DWithin`、`ST_Distance` 和 `ST_ClosestPoint`;只在实库确认 EPSG:4326 后启用。 - 部署:多阶段 Docker 镜像与单实例 Compose;Secret 通过未提交的 `.env` 在运行时注入,容器内 8080 只发布到宿主机 `127.0.0.1:16587`,由宿主机 Nginx 终止 TLS。Docker build 的 Go module proxy 可按环境覆盖,但默认使用官方代理、保留 checksum 校验,且所选构建代理在运行容器内强制清空。 @@ -48,7 +48,7 @@ ### 计划但尚未接入或确认 - PostgreSQL/PostGIS 的适用索引和生产查询计划验证。 -- SuperAgent 到 `/mcp` 的真实网络、TLS、Header 与 Token 联调。 +- SuperAgent 到 `/mcp` 的 proven-profile 兼容实现部署后重测,以及 TLS、网络白名单、Token 和完整工具调用链联调;版本 Header 不需要配置,也不是验收门禁。 - 任意地址/山名的外部地理编码、别名词典和大数据量地名索引。 - 用户聊天的真实身份认证、动态授权、共享/持久会话、主动取消和限流策略;首版默认关闭的静态 Bearer + 内存会话 API 已实现。legacy 短凭证仅限受控测试/迁移窗口,轮换后须关闭兼容开关。 - 最终用户鉴权、动态角色/区域或租户隔离、持久审计与完整可观测性方案。 @@ -81,7 +81,7 @@ ## 6. 当前开发方向与非目标 -当前阶段已有可运行、可测试、文档自解释的 Go 基线、SuperAgent Open API Adapter、默认关闭的原生用户对话 API、可选 DashScope 风格兼容入口,以及空间只读 MCP/PostGIS 实现。对话入口使用独立静态联调凭证和单进程内存会话;仓库已有多阶段 Docker/Compose 基线以及精确路径、无 Secret 的 Nginx HTTPS 反向代理示例,但尚未在目标机验证。MCP 默认关闭,实库严格 readiness 和全部 7 个工具的本地真实查询已通过。下一阶段在测试服务器应用这些部署资产,并使用真实消防 Profile 联调公网对话与 MCP。 +当前阶段已有可运行、可测试、文档自解释的 Go 基线、SuperAgent Open API Adapter、默认关闭的原生用户对话 API、可选 DashScope 风格兼容入口,以及空间只读 MCP/PostGIS 实现。对话入口使用独立静态联调凭证和单进程内存会话;仓库已有多阶段 Docker/Compose 基线以及精确路径、无 Secret 的 Nginx HTTPS 反向代理示例。目标机镜像构建已成功,但容器启动和公网 HTTPS 仍待重建后验证;现场截图已证明公网 `/mcp` 请求到达 Go,Bearer、Content-Type 和 JSON-RPC 前置检查通过,随后旧版本门禁返回错误,尚未发生公网 SuperAgent 数据库工具调用。同一 SuperAgent 中 `th-hotel-simple-superagent` 已稳定启用/调用,为本项目提供了 proven-profile 兼容参照,但不能替代消防 endpoint 的验收。部署后通过 `direct_success` 或 `compatibility_success` 日志分类确认实际请求,不记录客户端原始版本值。MCP 默认关闭,实库严格 readiness 和全部 7 个工具的本地真实查询已通过。下一阶段先部署 proven-profile 兼容修复,再在测试服务器使用真实消防 Profile 完成 `initialize` → `notifications/initialized` → `tools/list` → 至少一个 `tools/call` 的公网联调。 本阶段不实现: diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 2ad1144..e9cf810 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,14 +4,14 @@ | --- | --- | | 最近更新 | 2026-09-05 | | 当前分支 | `main` | -| 当前阶段 | 对话、SuperAgent、空间 MCP 与测试环境容器部署基线已完成;目标机镜像构建成功,Chat 短凭证兼容方案已完成并待部署 | -| 当前重点 | 部署默认关闭的 Chat 短凭证兼容开关,完成容器启动后再验证 health 与公网链路 | +| 当前阶段 | 对话、SuperAgent、空间 MCP 与测试环境容器部署基线已完成;公网 `/mcp` 已到达 Go,已按稳定接通的 th-hotel SuperAgent compatibility profile 完成版本兼容调整,待部署验证 | +| 当前重点 | 审查并部署 proven-profile 兼容实现,重建目标机容器后验证 health 和完整公网 MCP 工具链路 | ## 1. 当前 Checkpoint -- 名称:`fire-safety-ymd-test-server-build-network-recovery` +- 名称:`fire-safety-ymd-superagent-mcp-proven-profile-compatibility` - 状态:In Progress -- 目标:在不关闭 Go module 校验的前提下,让 Docker 构建可显式选择目标服务器可达且经认可的模块代理,并处理已交付旧 Chat 短凭证的受控兼容,完成 `/home/firee-safety-ymd` 容器启动。 +- 目标:让无法配置协议版本的 SuperAgent 按已稳定接通的 `th-hotel-simple-superagent` compatibility profile 调用 `/mcp`:版本字段和 `MCP-Protocol-Version` Header 不作为拒绝门禁,`initialize` 固定返回 `2025-06-18`,同时保持鉴权、数据范围和只读工具契约不变,并在 `/home/firee-safety-ymd` 重建后完成完整工具链路验收。 - 非目标:替用户提交或推送 Git、直接修改远程服务器、创建数据库容器、迁移生产数据、签发证书、改变 DNS/安全组、实现真实用户认证、动态授权、会话持久化或生产审计。 当前进展: @@ -22,6 +22,7 @@ - 现有 PostgreSQL/PostGIS 不进入 Compose;同宿主机数据库需要使用容器可达的宿主机地址,且仍需受 `listen_addresses`、`pg_hba.conf` 和防火墙约束。 - Nginx 示例增加 HTTP 到 HTTPS 跳转和 HTTP 429 JSON 限流响应,仍不比较、保存或注入 Chat、MCP、SuperAgent 或数据库 Secret。 - 运维手册记录 Git 前置条件、服务器目录、Secret 权限、Compose/Nginx 启停、Chat/MCP 冒烟、SuperAgent 回调、更新和回滚。 +- SuperAgent 无法在其配置中指定 MCP 协议版本;同一 SuperAgent 中 `th-hotel-simple-superagent` 已稳定启用/调用,现场实现不读取 `initialize.params.protocolVersion`,不读取或校验 `MCP-Protocol-Version` Header,并固定返回 `2025-06-18`。消防 MCP 按该 proven profile 兼容:版本字段和 Header 不作为拒绝门禁,响应固定为 `2025-06-18`。这不表示支持任意其他版本,也不是追求最新协议;日志只使用 `direct_success`/`compatibility_success`,不记录原始版本值。 已实现验收项: @@ -29,13 +30,14 @@ - Compose 配置不包含明文 Secret,且没有数据库容器、数据卷或迁移命令;现有业务数据库不会被部署动作重建。 - Nginx 上游固定为宿主机回环地址,兼容 Chat SSE 禁用缓冲和自动重试,未列出路径固定 404。 - 目标机执行步骤包含不渲染 `.env` 内容的 Compose 检查、`nginx -t` 前置门禁和可恢复的配置替换。 -- 目标服务器已开始部署:Nginx 配置语法检查通过,Docker 镜像已构建成功;容器启动日志显示 `FIRE_SAFETY_CHAT_AUTH_TOKEN` 未通过默认至少 32 字符门禁而反复重启,`16587/health` 尚未验证。 +- 目标服务器已开始部署:Nginx 配置语法检查和 Docker 镜像构建已通过;曾因 Chat 短凭证门禁发生重启,随后最新公网 `/mcp` 请求已到达 Go,说明服务已至少恢复到可处理请求的状态,但独立 `16587/health` 通过证据尚未提供。 +- SuperAgent 现场截图证明公网 `/mcp` 请求已经到达 Go 服务,Bearer、`Content-Type` 和 JSON-RPC 前置校验均已通过;随后旧版本门禁返回错误。截图没有捕获客户端版本字段是否存在、类型和值,公网 SuperAgent 尚未调用数据库工具。 ## 2. 当前优先级 -1. 由用户审查、提交并部署显式 legacy 兼容开关;仅在已交付旧 Chat 短凭证的受控测试/迁移窗口开启,并确保轮换后恢复关闭。 -2. 由用户审查本 checkpoint 变更后提交并推送 `origin/main`;服务器只部署明确提交的 revision。 -3. 在 `/home/firee-safety-ymd` recreate Compose,确认宿主机 16587 只绑定回环地址、容器内 8080 健康可达且数据库连接正常。 +1. 由用户审查本 checkpoint 变更后提交并推送 `origin/main`;服务器只部署明确提交的 revision。 +2. 在 `/home/firee-safety-ymd` 重建 Compose,确认宿主机 16587 只绑定回环地址、容器内 8080 健康可达且数据库连接正常。 +3. 使用 SuperAgent 无版本配置的实际握手重测:通过 `direct_success` 或 `compatibility_success` 日志确认请求已按 proven profile 处理,并验证 `initialize` → `notifications/initialized` → `tools/list` → 至少一个 `tools/call`;版本字段/Header 不需要配置,也不作为拒绝门禁。 4. reload 已通过语法检查的 Nginx 配置,只公开 HTTPS 兼容路径、`/mcp` 和可选 `/health`。 5. 使用项目专属测试 Key 和已发布消防 Profile 做兼容 `completion` 首轮/多轮真实冒烟,核对最终回答、用量和会话复用。 6. 为所有查询表补齐适用 GiST 索引并验证查询计划;当前小数据可做联调,但生产前必须完成索引与并发验证。 @@ -43,7 +45,7 @@ ## 3. 已确认事实 -- 仓库已初始化 Git,当前分支是 `main`,已有远程 `origin`;基线提交 `8a6c31c` 已推送到 `origin/main`。 +- 仓库已初始化 Git,当前分支是 `main`,已有远程 `origin`;当前已推送基线为 `801c0af`。 - `docs/import/字段组.xlsx` 和 `docs/import/数据表映射.xlsx` 是用户已有、已暂存的变更,本 checkpoint 未修改。 - 用户已使用项目只读 probe 成功连接数据库 `fire_safety_ymd`;PostGIS 报告版本 `3.3 USE_GEOS=1 USE_PROJ=1 USE_STATS=1`,8 表合计 4,055 条记录。 - 实库 4,048 条非空几何已在 2026-09-05 的受控事务中从 SRID 0 补齐为 SRID 4326;坐标 extent 约为经度 121.16 至 121.93、纬度 37.07 至 37.49,类型与预期一致且未发现 WGS84 数值越界。数据提供方确认 8 表源数据均为 EPSG:4326 且无坐标偏移,严格 readiness 已通过。 @@ -63,6 +65,7 @@ - 用户确认真实数据包含大量镇街,环境变量不适合枚举全量值;MCP 现支持显式数据库全范围 `all` 和默认镇街白名单 `town_allowlist` 两种服务端范围。 - 用户选择先实现简单地名能力、后续再优化;当前只查询既有森林防火记录,不调用外部地图服务,也不把候选代表点自动认定为演练点。 - 本地真实 MCP 冒烟已完成:7 个工具均成功访问实库,响应和错误边界符合契约;该结果不等于公网、SuperAgent 或生产并发已验证。 +- SuperAgent 无法配置 MCP 协议版本;已稳定接通的 `th-hotel-simple-superagent` 作为 proven profile 参照,不读取/校验 initialize 版本字段或 `MCP-Protocol-Version` Header,并固定返回 `2025-06-18`。消防服务按该 profile 处理,版本字段/Header 不作为拒绝门禁;固定返回不等于支持任意其他版本,也不是追求最新协议。 - 防火通道现有字段不能支持可靠路线规划;防火网格现有字段不能支持实时队伍位置或正式集结点。 ## 4. Known Issues 与未确认项 @@ -71,11 +74,11 @@ - 35 条无效面几何和 7 条空几何会被查询排除;尤其 4 条无效防火网格可能造成所属网格和责任中队结果缺口,27 条无效墓地面可能造成风险区域漏项。 - 除防火网格外 7 张表缺少 GiST 几何索引;当前 geography 距离表达式的生产索引方案需根据实库查询计划确认。 - 水源/设施 `syzt`、水源 `hc_datetime` 等字段的枚举、单位、时区和更新责任人尚未确认。 -- SuperAgent MCP 的公网/内网 URL、TLS、网络白名单、Header 行为、Token 注入和轮换尚未联调。 +- SuperAgent MCP 的公网 URL 已有一次请求到达 Go 的现场证据,Bearer、`Content-Type`、JSON-RPC 已通过;旧版本门禁返回错误,但现场证据未捕获客户端版本字段是否存在、类型和值。同一 SuperAgent 的 th-hotel 服务已稳定调用,为 proven profile 参照;消防服务兼容实现、TLS、网络白名单、Token 轮换和公网真实工具调用仍待联调。是否发送协议 Header 不构成兼容阻塞。 - `/api/chat` 静态 Bearer 和兼容路径 `xtoken` 只适用于受控联调,浏览器用户可以看到它;真实用户身份、动态授权、生产速率限制和滥用防护尚未实现。Chat 短凭证仅可通过默认关闭的显式 legacy 开关在受控测试/迁移窗口使用,MCP Token 仍要求至少 32 个可打印 ASCII 字符,三种凭证必须不同。 - 目标公网机器的 Docker/Compose 和 Nginx 版本、配置 include 层级、证书、DNS、安全组及 PostgreSQL 网络拓扑尚未完整验证;用户已开始远程部署,仓库资产与服务器现场配置仍需完成一致性核验。 - 目标服务器此前连续两次访问 `proxy.golang.org:443` 均在约 91 秒后超时,随后已通过可达的构建路径完成 Docker 镜像构建;该事实不代表所有外部 HTTPS 都可达。 -- 目标机当前容器因 `FIRE_SAFETY_CHAT_AUTH_TOKEN` 为已交付短凭证而未通过默认配置门禁,反复重启;`curl http://127.0.0.1:16587/health` 因服务未稳定监听而返回 connection refused。仓库已加入 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN` 显式兼容方案,待用户提交并部署;公网 HTTPS、reload 和健康检查均尚未验证。 +- 目标机曾因已交付的 Chat 短凭证未通过默认配置门禁而反复重启;`801c0af` 已包含显式兼容方案。最新公网 `/mcp` 请求已到达 Go 并通过前置校验,说明该启动阻塞已不再是当前首要问题;但独立 health、公网 Chat、MCP 完整握手和工具调用仍未形成通过证据。 - Chat 会话只在单个 Go 进程内存中保存;重启或多实例切换会丢失上下文,且当前没有历史查询、持久审计或主动取消 Provider Run。 - 当前 `all`/`town_allowlist` 都是服务账号静态范围,不是最终用户级授权;`all` 会授权当前数据库中 MCP 固定查询表内所有镇街和镇街字段为空的记录,身份提供方、角色、租户和精确位置权限尚未确定。 - 地名搜索是无索引的有界包含匹配;真实数据量下的耗时、重名率和名称字段质量尚未验证,生产优化可能需要标准地名表、别名词典或 `pg_trgm` 索引。 @@ -107,17 +110,19 @@ - Chat legacy 短凭证回归:默认仍拒绝少于 32 个字符的 Chat Token;仅在 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=true` 时接受非空、最多 4096 字节且仅含 ASCII `0x21-0x7e` 的短值;原生与兼容入口均通过,MCP 至少 32 字符和三凭证分离门禁保持不变。 - 模拟 SuperAgent Chat 端到端:原生与兼容入口均通过;应用创建 Provider Session、发送消息、解析严格完成事件,原生返回 `conversation/message/done`,兼容入口返回 `result` 且最终 `finish_reason=stop`。 - Nginx:配置已完成静态检查且未包含真实 Secret;开发机未安装 Nginx。据用户截图,目标机 `nginx -t` 已通过,reload 和 HTTPS 实际响应尚未验证。 -- Docker/Compose:部署文件已通过 YAML/静态安全断言;开发机未安装 Docker。目标机后续 Docker 镜像构建已成功,但容器因 Chat 短凭证默认门禁反复重启;`docker compose config --quiet`、稳定健康状态和公网运行容器仍待现场确认。 +- Docker/Compose:部署文件已通过 YAML/静态安全断言;开发机未安装 Docker。目标机 Docker 镜像构建已成功,曾因 Chat 短凭证默认门禁反复重启;最新公网 `/mcp` 请求证明服务随后已恢复处理请求,但 `docker compose config --quiet`、独立 health 和完整公网链路仍待现场确认。 - 构建代理回归检查:修复前静态反馈命令返回 `RED: Docker build has no configurable GOPROXY`;修复后确认 Compose build arg、Dockerfile `GOPROXY` 和运行容器清空边界,返回 GREEN。 - `CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -trimpath ./cmd/server`:通过,生成 Linux 静态服务二进制;不替代目标机真实 Docker build。 - 目标机首次 Docker 构建:失败;`go mod download` 获取 `github.com/jackc/pgpassfile@v1.0.0` 时连接 `proxy.golang.org:443` 超时,镜像/容器未生成,随后 `curl http://127.0.0.1:16587/health` 得到 connection refused,符合前置构建失败。 - 目标机后续 Docker 构建:成功;但 `docker compose logs --tail=100 api` 报 `FIRE_SAFETY_CHAT_AUTH_TOKEN must contain at least 32 printable ASCII characters`,容器因配置校验失败重启,故 `curl http://127.0.0.1:16587/health` 仍未形成通过证据。 - 目标机 Nginx:用户截图显示 `nginx -t` 配置语法检查成功;是否已 reload 以及 HTTPS 实际响应仍待确认。 +- SuperAgent MCP 公网现场:用户截图中的服务端错误证明请求已到达 Go 服务,Bearer、`Content-Type`、JSON-RPC 校验通过,随后旧版本门禁返回错误;截图未捕获客户端版本字段是否存在、类型和值。数据库 readiness 虽已在本地通过,但公网 SuperAgent 尚未调用任何数据库工具。 +- MCP proven-profile 回归:旧实现对 SuperAgent 请求返回截图中的 `MCP_PROTOCOL_VERSION_UNSUPPORTED`(RED);兼容实现对版本字段缺失、空值、非字符串、直接匹配或其他值均固定返回 `2025-06-18`,并覆盖带/不带/不同值 Header;同一 Handler 内的 `initialize` → `notifications/initialized` → `tools/list` → `tools/call` 回归通过,日志仅输出 `direct_success` 或 `compatibility_success`(GREEN)。公网完整链路仍待部署验证。 - SRID 迁移预检:通过;使用临时 `admin` 连接确认 4,048 条候选、8 表 UPDATE 权限和 7 表 ALTER 权限,未输出 DSN 或业务记录。 - SRID 数据迁移:通过;事务更新 4,048 条非空几何,7 张二维表改为 `geometry(Geometry,4326)`,防火通道保持裸 `geometry`,迁移前后几何载荷指纹与维度一致。 - 真实 PostGIS 严格 audit:通过;运行时只读账号报告 8 表 SRID 均为 4326、类型与范围门禁通过。预期保留 7 条空几何、35 条无效面几何和 7 张缺 GiST 索引表 warning。 - 本地真实 MCP 冒烟:通过;health、鉴权、Origin、方法限制、协议初始化、7 工具发现、7 tools/call、无结果、非法参数、响应一致性、字段脱敏、warning 和结构化日志均符合契约。第二轮实库查询约 0.35 至 0.99 秒。 - 数据导入:用户报告 8 份 SQL 已导入,防火通道在使用裸 `geometry` 保留混合二维/Z 后重导成功;这是现场反馈,不替代项目只读 probe 的最终验证。 -- 真实 SuperAgent 对话与 MCP 联调:对话 API 仅完成模拟 Provider 端到端测试,真实消防 Profile 尚未测试;MCP readiness 和本地真实工具冒烟已通过,公网 HTTPS 回调未配置。 +- 真实 SuperAgent 对话与 MCP 联调:对话 API 仅完成模拟 Provider 端到端测试,真实消防 Profile 尚未测试;MCP readiness 和本地真实工具冒烟已通过。公网 `/mcp` 已收到 SuperAgent initialize,但旧版本门禁返回错误,尚未完成 proven-profile 兼容部署,公网数据库工具尚未调用,不能宣称公网工具联调或部署完成。th-hotel 成功只证明同一 SuperAgent 的兼容档案可行,不替代消防 endpoint 的完整链路验收。 - 样例 SQL:未执行;含受限数据的 `*.sql` 已被 Git 忽略。 -- Git:基线提交已存在并推送;本 checkpoint 变更尚未提交,未执行自动 commit/push。 +- Git:`801c0af`(Chat 门禁兼容修复)已由用户提交并推送到 `origin/main`;本次 MCP 协商变更尚未提交,未执行自动 commit/push。 diff --git a/docs/architecture/spatial-mcp-v1.md b/docs/architecture/spatial-mcp-v1.md index 3efd2ff..772ed36 100644 --- a/docs/architecture/spatial-mcp-v1.md +++ b/docs/architecture/spatial-mcp-v1.md @@ -4,14 +4,22 @@ 首版 MCP 内嵌在现有 Go 服务中,使用标准库实现 HTTP/JSON-RPC 协议层,使用 `pgx/v5` 原生连接池访问 PostgreSQL/PostGIS。没有引入 MCP SDK、Web 框架或 ORM。 -采用这一边界是因为当前只需要 SuperAgent 已验证的 `2025-06-18` 四个方法和同步 JSON 响应;业务复杂度位于固定空间查询、权限范围和安全语义,而不是协议框架。 +采用这一边界是因为当前只需要与不能配置协议版本的 SuperAgent 客户端协作:服务端实际提供 `2025-06-18` 版本标识、四个方法和同步 JSON 响应;版本字段和 Header 按已稳定接通的 SuperAgent 兼容档案处理,不作为拒绝门禁。业务复杂度位于固定空间查询、权限范围和安全语义,而不是协议框架。 + +## SuperAgent 兼容档案 + +同一 SuperAgent 中已稳定启用的 `th-hotel-simple-superagent` 服务不会读取 `initialize.params.protocolVersion`,也不会读取或校验 `MCP-Protocol-Version` Header;它固定返回 `2025-06-18`,并正常处理 `notifications/initialized`、`tools/list` 与 `tools/call`。本项目对齐这一已验证的接入方式,目标是让 SuperAgent 能调用消防 MCP,而不是追求最新 MCP 版本。 + +`initialize.params.protocolVersion` 的缺失、空值、非字符串或其他值,不单独触发版本拒绝;可解析的 JSON-RPC initialize 始终返回 `result.protocolVersion: "2025-06-18"`。`MCP-Protocol-Version` Header 同样不作为拒绝门禁,SuperAgent 配置中无需增加版本或 Header 项。服务端并不因此实现或声明支持客户端填写的任意版本,也不把 `2025-03-26`、`2025-11-25` 等版本列为实现目标;固定返回 `2025-06-18` 是兼容响应。 + +上述兼容仅针对版本元数据。HTTP 方法、JSON-RPC 结构、独立 Bearer、`Content-Type`、非空 `Origin`、工具 schema、只读查询、服务端数据范围和结果脱敏等安全及业务边界仍由 Handler、Service 和 Repository 强制执行。initialize 结果可按实际字段记录 `direct_success`(值为 `2025-06-18`)或 `compatibility_success`(缺失或其他值被兼容处理),不得记录原始版本值。 ## 模块关系 ```mermaid flowchart TD APP["internal/app\n依赖装配与 readiness"] - H["internal/handler\nBearer、JSON-RPC、schema、限流边界"] + H["internal/handler\nBearer、JSON-RPC、握手协商、schema、限流边界"] S["internal/service\n查询边界、超时、结果语义"] D["internal/domain\n稳定空间领域对象"] R["internal/repository\npgxpool、固定参数化 PostGIS SQL"] diff --git a/docs/project/integrations/superagent-mcp-client.example.json b/docs/project/integrations/superagent-mcp-client.example.json index 4451479..60c6010 100644 --- a/docs/project/integrations/superagent-mcp-client.example.json +++ b/docs/project/integrations/superagent-mcp-client.example.json @@ -3,13 +3,12 @@ "mcpServers": { "fire-safety-ymd-spatial-readonly": { "transport": "http", - "protocolVersion": "2025-06-18", "url": "https:///mcp", "method": "POST", "headers": { "Authorization": "Bearer ${FIRE_SAFETY_MCP_AUTH_TOKEN}", "Content-Type": "application/json", - "Accept": "application/json" + "Accept": "application/json, text/event-stream" }, "tools": { "allow": [ @@ -25,6 +24,7 @@ }, "runtimeNotes": { "authTokenSource": "Use a separate per-environment high-entropy secret; never reuse the SuperAgent Open API Key.", + "protocolCompatibility": "SuperAgent cannot configure protocolVersion here. The fire-safety-ymd server follows the proven th-hotel-simple-superagent compatibility profile: it does not use initialize.params.protocolVersion or MCP-Protocol-Version as version refusal gates and always returns protocolVersion 2025-06-18. This is a compatibility response, not support for arbitrary client versions or a latest-version target. Do not add a protocol-version or protocol-header setting.", "scopeSource": "The all or town-allowlist data scope is configured on the fire-safety-ymd server and is never supplied by tool arguments.", "safety": "Results are planning evidence only. Invalid source geometries are excluded, so results may be incomplete. Availability, passability, command-post suitability, live team positions and assembly sites require field confirmation." } diff --git a/docs/project/integrations/superagent-mcp-spatial.md b/docs/project/integrations/superagent-mcp-spatial.md index 4097be0..4670e83 100644 --- a/docs/project/integrations/superagent-mcp-spatial.md +++ b/docs/project/integrations/superagent-mcp-spatial.md @@ -2,9 +2,9 @@ | 项 | 内容 | | --- | --- | -| 状态 | 代码已实现;实库严格 readiness 与本地 7 工具冒烟已通过,公网/SuperAgent 联调待执行 | +| 状态 | 代码已实现;实库严格 readiness 与本地 7 工具冒烟已通过;同一 SuperAgent 中 `th-hotel-simple-superagent` 已稳定启用/调用并作为兼容档案参照;公网 `/mcp` 已到达 Go,但旧版本门禁曾返回错误,消防服务的兼容档案仍需部署后按完整调用链重测 | | Endpoint | `POST /mcp` | -| MCP 版本 | `2025-06-18` | +| MCP 响应版本 | 固定返回 `2025-06-18` | | 传输 | 单 JSON 请求/响应;不提供服务端 SSE | | 数据源 | PostgreSQL/PostGIS,固定只读查询 | @@ -20,6 +20,18 @@ flowchart LR SuperAgent Open API Key 用于本服务调用 SuperAgent;MCP Token 用于 SuperAgent 回调本服务。两者方向、权限和生命周期不同,必须使用不同 Secret。 +## SuperAgent 兼容档案 + +SuperAgent 当前不能在 MCP 服务配置中填写或固定 `protocolVersion`;配置只需要 URL、HTTP 方法和独立 Bearer。已稳定接通的 `th-hotel-simple-superagent` 不读取 `initialize.params.protocolVersion`,也不读取或校验 `MCP-Protocol-Version` Header,而是固定返回 `2025-06-18`,并正常执行 `notifications/initialized`、`tools/list` 和 `tools/call`;工具结果同时提供 `structuredContent`。消防 MCP 按同一已验证档案接入,目标是打通工具调用,不是追求最新协议。 + +本服务端固定返回 `2025-06-18`: + +- 可解析的 JSON-RPC `initialize` 中,`params.protocolVersion` 的缺失、空值、非字符串或其他值,都不单独触发版本拒绝,响应中的 `result.protocolVersion` 始终为 `2025-06-18`。 +- `MCP-Protocol-Version` Header 的缺失或值同样不作为版本拒绝门禁;SuperAgent 配置中无需增加该 Header 或任何版本字段。 +- 固定返回 `2025-06-18` 不表示服务实现或声明支持客户端填写的任意版本,也不把 `2025-03-26`、`2025-11-25` 列为实现目标;这是为了兼容已稳定接通的客户端。 + +上述兼容只放宽版本元数据,不放宽 HTTP 方法、JSON-RPC 结构、独立 Bearer、`Content-Type`、非空 `Origin`、工具 schema、只读 SQL、服务端数据范围或字段脱敏边界。日志仅记录 `direct_success`(客户端值恰为 `2025-06-18`)或 `compatibility_success`(版本字段缺失或其他值被兼容处理),不记录客户端原始版本值。 + ## 首版工具 | 工具 | 数据表 | 能回答 | 不能回答 | @@ -119,11 +131,27 @@ FIRE_SAFETY_MCP_ENABLED=true curl -sS http://127.0.0.1:8080/mcp \ -H "Authorization: Bearer ${FIRE_SAFETY_MCP_AUTH_TOKEN}" \ -H 'Content-Type: application/json' \ - -H 'Accept: application/json' \ + -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"manual-probe","version":"1"}}}' ``` -随后发送 `notifications/initialized`、`tools/list` 和受控测试地名/坐标的 `tools/call`。联调不得使用真实火情或未授权精确坐标。 +初始化响应中的 `result.protocolVersion` 应固定为 `2025-06-18`。后续请求不需要携带协议版本 Header;如果客户端自行携带,服务端也不以其值作为版本拒绝条件: + +```bash +curl -sS http://127.0.0.1:8080/mcp \ + -H "Authorization: Bearer ${FIRE_SAFETY_MCP_AUTH_TOKEN}" \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' + +curl -sS http://127.0.0.1:8080/mcp \ + -H "Authorization: Bearer ${FIRE_SAFETY_MCP_AUTH_TOKEN}" \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' +``` + +再发送受控测试地名/坐标的 `tools/call`,同样无需配置或携带版本 Header。SuperAgent 的真正验收顺序是 `initialize` → `notifications/initialized`(HTTP 202)→ `tools/list`(7 个固定工具)→ 至少一个受控只读 `tools/call`;只看到 initialize 成功不能宣称 MCP 已接通。联调不得使用真实火情或未授权精确坐标。 SuperAgent 侧配置模板见 [`superagent-mcp-client.example.json`](superagent-mcp-client.example.json)。 @@ -140,7 +168,8 @@ SuperAgent 侧配置模板见 [`superagent-mcp-client.example.json`](superagent- ## 当前联调门禁 - 样例 SQL 不可执行,也不可提交;其中包含破坏性 DDL 和受限联系人数据。 -- 2026-09-05 已完成受控 SRID 元数据迁移和严格 audit:8 表共 4,055 条记录,4,048 条非空几何均为 SRID 4326,SRID/类型/范围硬门禁通过。7 条空几何、35 条无效面几何以及 7 张缺 GiST 索引表仍按预期告警;真实 `tools/call` 尚未执行,因此当前仍不能宣称 MCP 的业务结果已经联调验证。 +- 2026-09-05 已完成受控 SRID 元数据迁移和严格 audit:8 表共 4,055 条记录,4,048 条非空几何均为 SRID 4326,SRID/类型/范围硬门禁通过。7 条空几何、35 条无效面几何以及 7 张缺 GiST 索引表仍按预期告警;本地实库 7 个 `tools/call` 已执行并通过,但公网 SuperAgent `tools/call` 尚未执行,因此当前仍不能宣称公网 MCP 业务联调完成。 +- 用户提供的 SuperAgent 现场截图证明公网 `/mcp` 请求已经到达 Go 服务,Bearer、`Content-Type` 和 JSON-RPC 前置校验通过;随后旧版本门禁返回错误。该历史截图没有捕获客户端版本字段是否存在、类型和值。同一 SuperAgent 中 `th-hotel-simple-superagent` 已稳定调用是兼容档案的参照,但不是消防 MCP 的成功证据;部署后仍需观察 `direct_success` 或 `compatibility_success`,并完成 initialize → initialized → tools/list → 至少一个 tools/call。 - 缺少适用于距离表达式的 GiST 索引时可做小数据开发联调,但生产前必须补齐并验证查询计划。 - 地名包含匹配当前没有专用名称索引;真实数据量下先验证查询耗时,后续再决定标准地名表、别名词典或 `pg_trgm` 索引。 - 用户身份、动态区域授权和持久审计仍是后续 checkpoint;当前一个 MCP Token 只对应一个静态数据库全范围或镇街白名单。 @@ -150,7 +179,7 @@ SuperAgent 侧配置模板见 [`superagent-mcp-client.example.json`](superagent- 服务仅监听 `127.0.0.1:18080`,使用运行时只读 PostGIS 账号和显式 `all` 数据范围完成测试;未输出联系人、完整业务记录或测试坐标。 - `GET /health` 返回 200;无 Token 的 `POST /mcp` 返回 401。 -- `initialize` 协商 MCP `2025-06-18`;initialized notification 返回 202;`tools/list` 返回全部 7 个工具。 +- `initialize` 固定返回 MCP `2025-06-18`;版本字段/Header 不作为拒绝门禁,initialized notification 返回 202;`tools/list` 返回全部 7 个工具,并至少完成一个受控 `tools/call`。该记录仍需目标机和真实消防 Profile 提供公网证据。 - 使用“观水镇”得到 10 个地点候选,并选取一个精确匹配的 `recorded_point` 蓄水池记录,仅作为获授权测试坐标。 - 网格上下文返回 1 条;水源、指挥部候选和通道各返回 10 条;责任中队返回 1 条;风险区域返回 9 条。 - 7 个响应的 `structuredContent` 与文本 JSON 投影一致,空间参考均为 EPSG:4326,计数与数据数组一致,不包含已禁止的联系人类字段键。 diff --git a/docs/project/operations/docker-test-deployment.md b/docs/project/operations/docker-test-deployment.md index 0798958..00ecc8b 100644 --- a/docs/project/operations/docker-test-deployment.md +++ b/docs/project/operations/docker-test-deployment.md @@ -6,7 +6,7 @@ | 运行方式 | 宿主机 Nginx 终止 HTTPS,Docker Compose 运行单个 Go 服务 | | 应用监听边界 | 容器内监听 8080,只发布到宿主机 127.0.0.1:16587 | | 数据库 | 使用现有 PostgreSQL/PostGIS;本手册不创建数据库容器、不导入 SQL | -| 状态 | 测试环境部署基线;目标服务器、公网 DNS/TLS 和 SuperAgent 回调仍需现场验证 | +| 状态 | 测试环境部署基线;目标机镜像构建和 Nginx 语法检查已由现场截图证明通过,但容器稳定运行、公网 TLS 和 SuperAgent MCP 握手/工具回调仍待重测 | 本手册不包含真实 Token、API Key、数据库密码或旧 Nginx 配置内容。命令中的尖括号是服务器上需要替换的占位符;不要把 Secret 写进命令行、Nginx 文件、镜像构建参数或 Git。 @@ -305,9 +305,28 @@ Method: POST Authorization: Bearer ~~~ -该 Bearer 必须是 .env 中独立的 FIRE_SAFETY_MCP_AUTH_TOKEN,不要填 Chat xtoken 或 SuperAgent Open API Key。SuperAgent 还可能需要公网来源 IP 白名单、TLS CA 或自定义 Header,按实际平台和网络策略确认。 +该 Bearer 必须是 .env 中独立的 FIRE_SAFETY_MCP_AUTH_TOKEN,不要填 Chat xtoken 或 SuperAgent Open API Key。SuperAgent 当前不能在配置中指定 `protocolVersion`;不要把它或 `MCP-Protocol-Version` 作为客户端配置项加入。已稳定接通的 `th-hotel-simple-superagent` 不读取或校验这些版本元数据,而是固定返回 `2025-06-18`;消防 MCP 按这一已验证兼容档案接入,目标是打通工具调用,不是追求最新协议。SuperAgent 还可能需要公网来源 IP 白名单、TLS CA 或其他平台级 Header,按实际平台和网络策略确认。 -在服务器或受控终端用独立变量测试初始化: +服务端兼容规则如下:对可解析的 JSON-RPC `initialize`,`params.protocolVersion` 的缺失、空值、非字符串或其他值都不作为版本拒绝条件,响应固定返回 `result.protocolVersion: "2025-06-18"`;`MCP-Protocol-Version` Header 的缺失或值也不作为版本拒绝条件。固定返回该版本不表示服务实现或声明支持任意客户端版本,`2025-03-26`、`2025-11-25` 等值也不改变服务端实现目标。兼容仅针对版本元数据,Bearer、`Content-Type`、非空 `Origin`、JSON-RPC 结构、只读工具、服务端数据范围和敏感字段边界仍严格执行。 + +现场截图只证明公网 `/mcp` 已到达 Go 服务:Bearer、`Content-Type` 和 JSON-RPC 前置检查已通过,随后旧版本门禁返回错误。该历史截图没有捕获客户端版本字段是否存在、类型和值;同一 SuperAgent 中 `th-hotel-simple-superagent` 已稳定调用是兼容档案参照,但不是消防 MCP 成功证据。部署后通过 `direct_success`(客户端值为 `2025-06-18`)或 `compatibility_success`(版本字段缺失或其他值被兼容处理)日志分类确认,并完成完整调用链;服务端不得记录原始版本值。公网 SuperAgent 尚未调用消防数据库工具,因此不能把该截图当成公网部署或业务联调完成。 + +如果修改了 MCP Handler 或握手协商逻辑,目标机必须拉取包含该修改的明确 revision 后重建镜像;仅 `docker compose restart` 不会更新镜像中的代码: + +~~~bash +cd /home/firee-safety-ymd +git fetch --prune origin +git switch main +git pull --ff-only origin main +docker compose config --quiet +docker compose build --pull +docker compose up -d --force-recreate --remove-orphans +docker compose ps +docker compose logs --tail=100 api +curl --fail http://127.0.0.1:16587/health +~~~ + +在服务器或受控终端用独立变量测试初始化。下面的版本字段只是普通 JSON-RPC 参数示例,SuperAgent 不需要配置它;也可以省略或使用客户端实际发送的值: ~~~bash read -rsp 'MCP bearer: ' MCP_BEARER @@ -315,23 +334,33 @@ printf '\n' curl --fail \ -H "Authorization: Bearer $MCP_BEARER" \ -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"manual-probe","version":"1"}}}' \ 'https://agent.nianxx.com/mcp' curl --fail --output /dev/null --write-out 'initialized HTTP %{http_code}\n' \ -H "Authorization: Bearer $MCP_BEARER" \ -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ --data '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ 'https://agent.nianxx.com/mcp' curl --fail \ -H "Authorization: Bearer $MCP_BEARER" \ -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ --data '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ 'https://agent.nianxx.com/mcp' + +curl --fail \ + -H "Authorization: Bearer $MCP_BEARER" \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + --data '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"fire_safety_search_place_candidates","arguments":{"place_name":"观水镇","limit":3}}}' \ + 'https://agent.nianxx.com/mcp' ~~~ -确认第一步返回 `protocolVersion: 2025-06-18`,第二步返回 HTTP 202,第三步列出 7 个固定工具;再用无敏感信息的已知地名/点位做少量只读调用,核对正确 Bearer 成功、错误/缺失 Bearer 为 401、warnings 保留、非法参数不会变成任意 SQL。查询到资源记录不代表实时可用、路线已规划或正式集结点。 +确认第一步固定返回 `result.protocolVersion: 2025-06-18`,第二步返回 HTTP 202,第三步列出 7 个固定工具,第四步至少完成一个受控只读 `tools/call`。这四步 `initialize` → `notifications/initialized` → `tools/list` → `tools/call` 才是 SuperAgent 能调用消防 MCP 的验收链路;只看到 initialize 成功不能宣称接通。版本 Header 不需要配置或继续携带。再用无敏感信息的已知地名/点位做少量只读调用,核对正确 Bearer 成功、错误/缺失 Bearer 为 401、warnings 保留、非法参数不会变成任意 SQL。查询到资源记录不代表实时可用、路线已规划或正式集结点。 完成 MCP 测试后清理当前 shell 中的临时变量: @@ -408,7 +437,7 @@ docker compose down - 目标机 nginx -t 和 reload 成功;443 证书、DNS、安全组及旧 location / 已确认不再生效。 - Chat 首轮/多轮 SSE、错误凭证、未知 app/path 的实际 HTTPS 响应。 - 若为已交付旧客户端临时开启 Chat legacy 短凭证兼容,应记录受控迁移窗口,确认启动 warning 不含 Secret,并在凭证轮换后恢复 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=false`。 -- SuperAgent -> /mcp 的独立 Bearer、TLS/网络白名单、工具发现和至少一个受控只读工具调用。 +- SuperAgent -> `/mcp` 的独立 Bearer、TLS/网络白名单、无版本配置 initialize 兼容响应、`notifications/initialized`、`tools/list` 和至少一个受控只读 `tools/call`。版本字段/Header 不作为配置或拒绝门禁;公网请求到达但没有完成这条调用链,不算完成。 - 数据库未公开 5432,运行账号保持只读,PostGIS readiness warning 和 35 条无效面几何缺口已记录。 若目标服务器无法使用 host.docker.internal、Compose 不支持 --quiet、Nginx include 目录不同、证书路径不同或 SuperAgent 对 MCP 的认证格式不同,应先记录实际环境并调整部署 Spec;不要通过放开端口、写入 Token、关闭 readiness 或恢复全路径反代规避问题。 diff --git a/docs/specs/fire-safety-ymd-superagent-mcp-spatial-readonly-v1.md b/docs/specs/fire-safety-ymd-superagent-mcp-spatial-readonly-v1.md index f8c933b..01fe7ca 100644 --- a/docs/specs/fire-safety-ymd-superagent-mcp-spatial-readonly-v1.md +++ b/docs/specs/fire-safety-ymd-superagent-mcp-spatial-readonly-v1.md @@ -2,9 +2,9 @@ | 项 | 内容 | | --- | --- | -| 状态 | Implemented;live readiness 与本地 7 tools/call 已通过,SuperAgent 回调待联调 | +| 状态 | Implemented;live readiness 与本地 7 tools/call 已通过;同一 SuperAgent 中 `th-hotel-simple-superagent` 已稳定启用/调用并作为兼容档案参照;公网 `/mcp` 已到达 Go,但旧版本门禁曾返回错误,消防服务按 proven-profile 兼容方式部署后仍需完成真实工具回调 | | 日期 | 2026-09-05 | -| Checkpoint | `fire-safety-ymd-superagent-mcp-spatial-readonly-v1` | +| Checkpoint | `fire-safety-ymd-superagent-mcp-proven-profile-compatibility`(基础空间工具契约沿用 v1) | | 需求来源 | 用户提供 8 张 PostgreSQL/PostGIS 表结构与每表 2 条样例,要求先基于现有数据建设 MCP | ## 1. 背景与已核对输入 @@ -29,11 +29,12 @@ SQL 文件仅作为结构和数据契约参考。文件包含 `DROP TABLE` 等 - 样例几何十六进制是未携带 SRID 的 WKB;坐标数值及水源表独立经纬度字段看起来符合 WGS84,但这只能作为待验证线索。 - 样例 DDL 只有防火网格明确包含 GiST 几何索引。 - 多张表含姓名、电话等受限字段;首版 MCP 不返回这些字段。 +- 用户提供的 SuperAgent 现场截图显示公网 `/mcp` 请求已经到达 Go 服务,Bearer、`Content-Type` 和 JSON-RPC 前置校验通过,随后旧版本门禁返回错误;截图没有捕获客户端版本字段是否存在、类型和值。同一 SuperAgent 中 `th-hotel-simple-superagent` 已稳定启用/调用,这是兼容档案的现场参照,但消防 MCP 尚未完成公网数据库工具调用。 ## 2. 目标 - 在现有 Go HTTP 服务内提供默认关闭的 `POST /mcp`。 -- 与已经验证的 SuperAgent 配置保持兼容,采用 MCP `2025-06-18`、JSON-RPC 2.0 和单请求 JSON 响应。 +- 与不能配置协议版本的 SuperAgent 客户端保持兼容:服务端固定返回 MCP `2025-06-18`,对齐已稳定接通的 `th-hotel-simple-superagent` compatibility profile;initialize 中的版本字段和后续协议 Header 都不作为拒绝门禁。使用 JSON-RPC 2.0 和单请求 JSON 响应,目标是打通工具调用而不是追求最新协议。 - 通过独立 Bearer Token 鉴权;不得复用 SuperAgent Open API Key。 - 只提供固定、参数化、只读、按服务端可信数据范围执行的空间查询工具;默认使用镇街白名单,数据库全范围必须显式启用。 - 支持在现有业务记录中按地名搜索有界候选,让没有坐标的用户先确认一个候选,再进入距离和包含关系查询。 @@ -61,7 +62,15 @@ SQL 文件仅作为结构和数据契约参考。文件包含 `DROP TABLE` 等 - `tools/list` - `tools/call` -服务端声明协议版本 `2025-06-18` 和 `tools` capability。首版是无会话、非流式的 Streamable HTTP 子集:`POST` 返回 `application/json`;`GET /mcp` 返回 `405 Method Not Allowed`。 +服务端固定返回协议版本标识 `2025-06-18` 和 `tools` capability。首版是无会话、非流式的 Streamable HTTP 子集:`POST` 返回 `application/json`;`GET /mcp` 返回 `405 Method Not Allowed`。 + +#### SuperAgent compatibility profile + +服务端固定返回 `result.protocolVersion=2025-06-18`,但不把客户端版本字段当作版本协商或拒绝依据:可解析的 JSON-RPC `initialize` 中,`params.protocolVersion` 可以缺失、为空、为非字符串或为其他值,均不单独触发版本错误。`MCP-Protocol-Version` Header 可以缺失或携带任意值,也不作为版本拒绝门禁;SuperAgent 配置中不需要填写版本字段或 Header。 + +固定返回 `2025-06-18` 不表示服务实现或声明支持客户端填写的任意版本,也不把 `2025-03-26`、`2025-11-25` 等值列为实现目标。该 profile 只为兼容已稳定接通的 SuperAgent 客户端,版本字段/Header 之外的 HTTP 方法、JSON-RPC 结构、鉴权、Origin、Content-Type、工具 schema、只读数据范围和敏感字段边界仍严格执行。 + +initialize 日志只记录 `direct_success`(客户端值为 `2025-06-18`)或 `compatibility_success`(版本字段缺失或其他值被兼容处理),不记录客户端原始版本值。 ### 4.2 入站控制 @@ -69,6 +78,7 @@ SQL 文件仅作为结构和数据契约参考。文件包含 `DROP TABLE` 等 - 只接受 `Content-Type: application/json`。 - 使用 `Authorization: Bearer `,常量时间比较。 - Token 至少 32 个可打印 ASCII 字符,且不能等于 SuperAgent Open API Key。 +- 初始化响应固定返回 `protocolVersion: 2025-06-18`;请求 body 中的 `protocolVersion` 以及 `MCP-Protocol-Version` Header 均为兼容元数据,不作为拒绝门禁。SuperAgent 配置不需要增加版本字段或 Header。 - 请求体默认最大 256 KiB,最大可配置 1 MiB。 - 首版只接受服务到服务请求;带非空 `Origin` 的请求拒绝,避免浏览器和 DNS rebinding 风险。 - 不接受模型提供的用户、角色、租户或授权范围。范围模式和可选镇街列表只来自服务端配置。 @@ -190,6 +200,8 @@ SQL 文件仅作为结构和数据契约参考。文件包含 `DROP TABLE` 等 - MCP 启用但 Token、合法范围、PostGIS 或显式 SRID 缺失时配置加载失败;`all` 与非空镇街列表同时出现时失败,错误不含 Secret。 - 无 Token、错误 Token、错误 Content-Type、非空 Origin、超大 body 和非法 JSON 均被稳定拒绝。 - `initialize`、initialized notification、`tools/list` 和 7 个 `tools/call` 契约通过本地测试。 +- `initialize` 对客户端版本字段缺失、空值、非字符串、`2025-06-18` 或其他值均固定返回 `2025-06-18`;版本字段和 `MCP-Protocol-Version` Header 不作为拒绝门禁。服务端不宣称支持任意其他版本,也不追求最新版本;日志按 `direct_success`/`compatibility_success` 分类且不包含原始版本值。 +- 真实 SuperAgent 验收必须在部署后依次出现 `initialize` → `notifications/initialized`(HTTP 202)→ `tools/list`(7 个固定工具)→ 至少一个受控只读 `tools/call`;只有 initialize 成功不能宣称 MCP 已接通。 - 工具 schema 限制地名长度、经纬度、半径、数量和未知字段;工具不接受授权身份参数。 - Service 测试证明可信数据库全范围/镇街白名单来自构造时配置,并覆盖互斥校验、无结果、超时和仓储失败。 - Repository 只使用参数化值,MCP 结果不包含联系人字段。 @@ -203,7 +215,8 @@ SQL 文件仅作为结构和数据契约参考。文件包含 `DROP TABLE` 等 - 实库源 CRS 已由数据提供方确认为 EPSG:4326 且无偏移;2026-09-05 已通过单独审核、显式确认和单事务迁移为 4,048 条非空几何补齐 SRID 4326,严格 readiness 已通过。重新导入无 SRID 的原始 SQL 时仍必须重新经过该流程,应用查询不得静默赋值。 - 实库 35 条无效面几何按首版决策排除;需评估由此造成的网格、责任单位和风险区域覆盖缺口是否满足生产要求。 - 各资源状态字段的枚举、更新时间含义和数据刷新责任人。 -- MCP 回调网络地址、TLS、SuperAgent 实际 Header 行为及 Token 轮换方式。 +- MCP 回调网络地址、TLS、SuperAgent Token 轮换方式及真实工具权限。 +- SuperAgent 无法配置协议版本;proven-profile 兼容响应、`notifications/initialized`、`tools/list` 和真实 `tools/call` 仍待目标机重建后确认。是否发送 `MCP-Protocol-Version` 不影响版本兼容验收。 - 最终用户身份、区域权限、精确位置权限和审计保留策略。 - 真实数据量下地名重复率、字段质量和无索引包含搜索性能;后续是否引入标准地名表、别名词典、`pg_trgm` 或经审批的外部地理编码服务。 - 路线规划需要的路网拓扑、路面/宽度/坡度/车辆限制、实时封路、火场和天气数据。 diff --git a/internal/handler/mcp.go b/internal/handler/mcp.go index ee62358..e28fd72 100644 --- a/internal/handler/mcp.go +++ b/internal/handler/mcp.go @@ -23,7 +23,7 @@ import ( const ( mcpProtocolVersion = "2025-06-18" mcpServerName = "fire-safety-ymd-spatial-readonly" - mcpServerVersion = "0.2.0" + mcpServerVersion = "0.2.2" maximumMCPBody = int64(1024 * 1024) maximumToolTimeout = 30 * time.Second @@ -118,12 +118,6 @@ func (h *MCPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.logResult(requestID, "transport", "unsupported_media_type", started) return } - if version := strings.TrimSpace(r.Header.Get("MCP-Protocol-Version")); version != "" && version != mcpProtocolVersion { - h.writeRPCError(w, http.StatusBadRequest, nil, -32602, "MCP_PROTOCOL_VERSION_UNSUPPORTED", "Unsupported MCP protocol version.") - h.logResult(requestID, "transport", "unsupported_protocol", started) - return - } - body, tooLarge, err := readBoundedBody(r.Body, h.maxBodyBytes) if err != nil { h.writeRPCError(w, http.StatusBadRequest, nil, -32700, "MCP_REQUEST_INVALID", "Unable to read MCP request.") @@ -150,11 +144,7 @@ func (h *MCPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch request.Method { case "initialize": - if h.handleInitialize(w, request) { - h.logResult(requestID, "initialize", "success", started) - } else { - h.logResult(requestID, "initialize", "unsupported_protocol", started) - } + h.logResult(requestID, "initialize", h.handleInitialize(w, request), started) case "notifications/initialized": w.WriteHeader(http.StatusAccepted) h.logResult(requestID, "notifications/initialized", "accepted", started) @@ -169,13 +159,16 @@ func (h *MCPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } -func (h *MCPHandler) handleInitialize(w http.ResponseWriter, request rpcRequest) bool { +func (h *MCPHandler) handleInitialize(w http.ResponseWriter, request rpcRequest) string { var params struct { - ProtocolVersion string `json:"protocolVersion"` + ProtocolVersion json.RawMessage `json:"protocolVersion"` } - if len(request.Params) == 0 || json.Unmarshal(request.Params, ¶ms) != nil || params.ProtocolVersion != mcpProtocolVersion { - h.writeRPCError(w, http.StatusOK, request.ID, -32602, "MCP_PROTOCOL_VERSION_UNSUPPORTED", "Client must support MCP protocol version 2025-06-18.") - return false + result := "compatibility_success" + if len(request.Params) != 0 && json.Unmarshal(request.Params, ¶ms) == nil { + var protocolVersion string + if json.Unmarshal(params.ProtocolVersion, &protocolVersion) == nil && protocolVersion == mcpProtocolVersion { + result = "direct_success" + } } h.writeRPCResult(w, request.ID, map[string]any{ "protocolVersion": mcpProtocolVersion, @@ -189,7 +182,7 @@ func (h *MCPHandler) handleInitialize(w http.ResponseWriter, request rpcRequest) }, "instructions": "Read-only planning support. Source records with invalid geometries are excluded, so results may be incomplete. Place-name matches are candidates that require user confirmation before coordinate-based analysis. Treat all records as potentially stale, verify resource availability and field safety, and never present access-line candidates as confirmed routes or responsibility records as live team locations.", }) - return true + return result } func (h *MCPHandler) handleToolCall(w http.ResponseWriter, r *http.Request, request rpcRequest, requestID string, started time.Time) { diff --git a/internal/handler/mcp_test.go b/internal/handler/mcp_test.go index c96bab9..e007a25 100644 --- a/internal/handler/mcp_test.go +++ b/internal/handler/mcp_test.go @@ -82,15 +82,6 @@ func TestMCPTransportGuards(t *testing.T) { wantStatus: http.StatusUnsupportedMediaType, wantErrorCode: "MCP_CONTENT_TYPE_INVALID", }, - { - name: "unsupported protocol header", - body: `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, - configure: func(request *http.Request) { - request.Header.Set("MCP-Protocol-Version", "1999-01-01") - }, - wantStatus: http.StatusBadRequest, - wantErrorCode: "MCP_PROTOCOL_VERSION_UNSUPPORTED", - }, { name: "invalid JSON", body: `{"jsonrpc":`, @@ -174,15 +165,252 @@ func TestMCPInitializeAndInitializedNotification(t *testing.T) { } } -func TestMCPRejectsUnsupportedInitializeVersion(t *testing.T) { - handler := newTestMCPHandler(t, &fakeMCPSpatialService{}, 4096, time.Second) - request := authenticatedMCPRequest(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}`) - response := httptest.NewRecorder() +func TestMCPProvenProfileCompletesToolCallLifecycle(t *testing.T) { + spatial := &fakeMCPSpatialService{} + handler := newTestMCPHandler(t, spatial, 4096, time.Second) + tests := []struct { + name string + body string + wantStatus int + wantBody string + }{ + { + name: "initialize without configurable protocol version", + body: `{"jsonrpc":"2.0","id":1,"method":"initialize"}`, + wantStatus: http.StatusOK, + wantBody: `"protocolVersion":"2025-06-18"`, + }, + { + name: "initialized notification", + body: `{"jsonrpc":"2.0","method":"notifications/initialized"}`, + wantStatus: http.StatusAccepted, + }, + { + name: "list tools", + body: `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`, + wantStatus: http.StatusOK, + wantBody: `"tools"`, + }, + { + name: "call a read-only tool", + body: `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"fire_safety_search_place_candidates","arguments":{"place_name":"观水镇","limit":3}}}`, + wantStatus: http.StatusOK, + wantBody: `"structuredContent"`, + }, + } - handler.ServeHTTP(response, request) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := authenticatedMCPRequest(tt.body) + request.Header.Set("MCP-Protocol-Version", "superagent-unconfigured-version") + response := httptest.NewRecorder() - if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "MCP_PROTOCOL_VERSION_UNSUPPORTED") { - t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + handler.ServeHTTP(response, request) + + if response.Code != tt.wantStatus || (tt.wantBody != "" && !strings.Contains(response.Body.String(), tt.wantBody)) { + t.Fatalf("status=%d body=%q", response.Code, response.Body.String()) + } + }) + } + if spatial.called != toolSearchPlaceCandidates { + t.Fatalf("called=%q, want %q", spatial.called, toolSearchPlaceCandidates) + } +} + +func TestMCPInitializeAcceptsSuperAgentProtocolShapes(t *testing.T) { + tests := []struct { + name string + params string + protocolHeader string + }{ + {name: "missing params"}, + {name: "null params", params: "null"}, + {name: "missing version", params: `{}`}, + {name: "empty version", params: `{"protocolVersion":""}`}, + {name: "blank version", params: `{"protocolVersion":" "}`}, + {name: "non-string version", params: `{"protocolVersion":20250618}`}, + {name: "invalid params shape", params: `[]`}, + {name: "direct", params: `{"protocolVersion":"2025-06-18"}`}, + {name: "older", params: `{"protocolVersion":"2025-03-26"}`}, + {name: "newer and mismatched header", params: `{"protocolVersion":"2025-11-25"}`, protocolHeader: "2025-03-26"}, + {name: "unknown", params: `{"protocolVersion":"superagent-private-version"}`, protocolHeader: "superagent-header-version"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := newTestMCPHandler(t, &fakeMCPSpatialService{}, 4096, time.Second) + body := `{"jsonrpc":"2.0","id":1,"method":"initialize"` + if tt.params != "" { + body += `,"params":` + tt.params + } + request := authenticatedMCPRequest(body + `}`) + if tt.protocolHeader != "" { + request.Header.Set("MCP-Protocol-Version", tt.protocolHeader) + } + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + var decoded rpcResponse + if err := json.Unmarshal(response.Body.Bytes(), &decoded); err != nil { + t.Fatalf("decode response: %v; body=%s", err, response.Body.String()) + } + if response.Code != http.StatusOK || + !strings.Contains(response.Body.String(), `"protocolVersion":"2025-06-18"`) || + decoded.Error != nil { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + }) + } +} + +func TestMCPProtocolHeaderDoesNotBlockSuperAgentRequests(t *testing.T) { + tests := []struct { + name string + header string + }{ + {name: "missing"}, + {name: "server version", header: mcpProtocolVersion}, + {name: "older", header: "2025-03-26"}, + {name: "newer", header: "2025-11-25"}, + {name: "unknown", header: "superagent-private-version"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := newTestMCPHandler(t, &fakeMCPSpatialService{}, 4096, time.Second) + request := authenticatedMCPRequest(`{"jsonrpc":"2.0","id":2,"method":"tools/list"}`) + if tt.header == "" { + request.Header.Del("MCP-Protocol-Version") + } else { + request.Header.Set("MCP-Protocol-Version", tt.header) + } + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + var decoded rpcResponse + if err := json.Unmarshal(response.Body.Bytes(), &decoded); err != nil { + t.Fatalf("decode response: %v; body=%s", err, response.Body.String()) + } + if response.Code != http.StatusOK || decoded.Error != nil || !strings.Contains(response.Body.String(), `"tools"`) { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + }) + } +} + +func TestMCPProtocolHeaderDoesNotBlockInitializedNotification(t *testing.T) { + tests := []struct { + name string + header string + }{ + {name: "missing"}, + {name: "server version", header: mcpProtocolVersion}, + {name: "older", header: "2025-03-26"}, + {name: "newer", header: "2025-11-25"}, + {name: "unknown", header: "superagent-private-version"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := newTestMCPHandler(t, &fakeMCPSpatialService{}, 4096, time.Second) + request := authenticatedMCPRequest(`{"jsonrpc":"2.0","method":"notifications/initialized"}`) + if tt.header == "" { + request.Header.Del("MCP-Protocol-Version") + } else { + request.Header.Set("MCP-Protocol-Version", tt.header) + } + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusAccepted || response.Body.Len() != 0 { + t.Fatalf("status=%d body=%q", response.Code, response.Body.String()) + } + }) + } +} + +func TestMCPProtocolHeaderDoesNotBlockToolCalls(t *testing.T) { + tests := []struct { + name string + header string + }{ + {name: "missing"}, + {name: "server version", header: mcpProtocolVersion}, + {name: "older", header: "2025-03-26"}, + {name: "newer", header: "2025-11-25"}, + {name: "unknown", header: "superagent-private-version"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spatial := &fakeMCPSpatialService{} + handler := newTestMCPHandler(t, spatial, 4096, time.Second) + request := authenticatedMCPRequest(`{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"fire_safety_resolve_incident_context","arguments":{"longitude":121.7,"latitude":37.2}}}`) + if tt.header == "" { + request.Header.Del("MCP-Protocol-Version") + } else { + request.Header.Set("MCP-Protocol-Version", tt.header) + } + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + var decoded rpcResponse + if err := json.Unmarshal(response.Body.Bytes(), &decoded); err != nil { + t.Fatalf("decode response: %v; body=%s", err, response.Body.String()) + } + if response.Code != http.StatusOK || decoded.Error != nil || spatial.called != toolResolveIncidentContext { + t.Fatalf("status=%d called=%q body=%s", response.Code, spatial.called, response.Body.String()) + } + }) + } +} + +func TestMCPInitializeLogsFiniteCompatibilityResultWithoutInput(t *testing.T) { + tests := []struct { + name string + params string + wantResult string + }{ + {name: "direct", params: `{"protocolVersion":"2025-06-18"}`, wantResult: "direct_success"}, + {name: "other", params: `{"protocolVersion":"untrusted-client-version"}`, wantResult: "compatibility_success"}, + {name: "missing", wantResult: "compatibility_success"}, + {name: "non-string", params: `{"protocolVersion":20250618,"extra":"sensitive-value"}`, wantResult: "compatibility_success"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var logs strings.Builder + handler, err := NewMCPHandler(&fakeMCPSpatialService{}, MCPOptions{ + AuthToken: testMCPToken, + MaxBodyBytes: 4096, + ToolTimeout: time.Second, + Logger: log.New(&logs, "", 0), + }) + if err != nil { + t.Fatalf("NewMCPHandler() error = %v", err) + } + body := `{"jsonrpc":"2.0","id":1,"method":"initialize"` + if tt.params != "" { + body += `,"params":` + tt.params + } + request := authenticatedMCPRequest(body + `}`) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if !strings.Contains(logs.String(), "operation=initialize result="+tt.wantResult) { + t.Fatalf("logs=%q, want result %s", logs.String(), tt.wantResult) + } + for _, sensitive := range []string{"untrusted-client-version", "sensitive-value", testMCPToken} { + if strings.Contains(logs.String(), sensitive) { + t.Fatalf("logs contain request input %q: %q", sensitive, logs.String()) + } + } + }) } }