Files
jiang be6317eb48 feat(server): integrate MCP queries and investigation messaging
Add project-bound OAuth MCP queries and controlled analysis with scoped evidence,
limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs.
Default nearby searches to 200 meters and five results.

Integrate investigation messaging/presentation, health/readiness and typed history;
remove duplicate web search and fix online simulation and compressed baseline writes.
Include project migrations 023-025; keep local credentials and demo seeding excluded.

Validation: 319 focused pytest checks; real PostGIS temporary-table regression;
read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
2026-10-19 18:46:58 +08:00

281 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# NextTJWater MCP 查询与认证设计
日期:2026-10-15。状态:查询与认证代码已在本地实现,认证发现、resource 参数和版本头体校验已补齐,默认关闭,未部署;真实 Keycloak 与项目数据库验收待完成。
本设计将现有后端查询能力通过 MCP 开放给普通项目 Agent、调查主 Agent 和调查只读子 Agent。采用 MCP 2026-07-28、Streamable HTTP、现有 Python Server 内挂载服务、可信 Agent 宿主按请求代理认证。旧 CLI 用于查找功能和接口线索,业务实现继续由当前 Server 负责。
第一版完成查询、权限、取消、分页、调查证据和凭据续期的完整闭环。已有报告发布、工单审批、设备控制、调查运行生命周期继续使用当前受控工具;不通过 MCP 自动开放旧 CLI 中的分析执行命令。
## 当前系统与功能核对
Agent 的独立只读查询现统一通过原生 `mcp_tjwater` 连接调用。重复的项目查询插件与自定义 HTTP 查询桥已移除;`memory_manager`、`skill_manager` 保留完整读写插件,发布、动作、消息发送和界面展示也保留插件形式。普通会话凭据保存在宿主的 `src/product/credential-refresh.ts`;调查由 worker 以服务身份投递并续期。调查子 Agent 通过父任务绑定访问数据,不持有独立产品凭据。
实施前从 `app.main.app.openapi()` 核对了 266 路径的契约,其路径集合及各 HTTP operation 与工作区 `contracts/server-v1.openapi.json` 一致。公开 API 经 `app/api/v1/rest_router.py` 适配,项目字段注入和分页须看最终注册契约,不能只看底层 handler 签名。旧 CLI 的 41 条业务命令中,38 条能匹配当前 HTTP 方法与路径:29 条读命令、9 条执行命令。另有 3 条需要替换或不开放,详见附表。路径匹配不代表参数兼容、数据可用或算法已完成业务验收。
实施前 `requirements.txt` 和 conda 环境均为 `mcp==1.9.4`、`fastmcp==2.9.2`,当时没有 Server MCP 注册实现。Agent 使用的 OpenCode 2.0.27 配置类型支持 `legacy`、`auto`、`2026-07-28`,宿主认证代理现已补充,见实施说明。
## 协议选择
官方 `specification/latest` 指向 [MCP 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28)。新实现按该正式版本建设;产品内部连接固定要求这个版本,版本不支持时返回明确错误。第一版不提供旧 `initialize` 握手兼容层。
| 协议要求 | 本系统设计 |
| --- | --- |
| 无状态请求与逐请求能力声明 | 每次请求发送标准 `_meta` 中的版本、客户端信息和能力;不以连接识别用户或调查轮次 |
| `server/discover` | 返回版本、服务信息和实际支持能力;不能用自报服务名建立信任 |
| HTTP 请求镜像头 | 由 SDK发送并校验 `MCP-Protocol-Version`、`Mcp-Method`、适用时的 `Mcp-Name`;头与正文不一致拒绝 |
| 普通结果 | 返回 `resultType=complete`、`structuredContent`、匹配的 `outputSchema`,以及简短文字说明 |
| 列表和资源缓存提示 | 返回 `ttlMs`、`cacheScope`;缓存不作为权限证明 |
| 取消与断线 | 取消传到 SDK、数据库和外部请求;断开的流不按旧 SSE 游标恢复 |
这些改变来自[正式版变更说明](https://modelcontextprotocol.io/specification/2026-07-28/changelog)和 [Streamable HTTP 规范](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http)。协议支持情况以实际发现与调用验收为准,不以软件版本字符串代替。
第一版声明 tools 和 resources;不声明 prompts、subscriptions、MRTR、Tasks、MCP Apps、Sampling、Roots 或 Logging 能力。查询不要求模型补填身份、密码或令牌。现有产品调查任务不会替换为 MCP Tasks。
Schema 使用 JSON Schema 2020-12,模型输入对象拒绝未声明字段;只使用服务自带的本地定义,不在校验时从任意外部 URL 获取 `$ref`。宿主校验模型投影,Server 再校验完整线协议参数。工具发现、结构化结果、状态句柄和执行错误遵循[正式工具规范](https://modelcontextprotocol.io/specification/2026-07-28/server/tools)。
| 失败类型 | 响应与宿主处理 |
| --- | --- |
| 未认证或过期 | HTTP 401 和 Bearer challenge;宿主最多续期一次 |
| scope 或产品权限不足 | HTTP 403;scope 不足时带 insufficient_scope,不自动扩大产品权限 |
| 请求头与正文不一致 | HTTP 400、JSON-RPC -32020 |
| 协议版本不支持 | HTTP 400、JSON-RPC -32022,列出支持版本 |
| RPC 方法不存在 | HTTP 404、JSON-RPC -32601 |
| 工具不存在或 RPC 参数结构无效 | JSON-RPC -32602 |
| 业务参数、数据源或上下文失败 | resultType=complete、isError=true;返回稳定业务错误码和可操作说明 |
旧客户端 GET/DELETE 到 `/mcp` 返回 405;旧 Mcp-Session-Id 和 Last-Event-ID 不建立或恢复身份。产品上下文删除使用独立 REST 控制路径,不混用协议会话删除。
`server/discover` 和固定查询目录按工具名稳定排序,缓存提示取 `ttlMs=300000`、`cacheScope=public`,正文仅包含通用定义。业务结果资源取 `ttlMs=0`、`cacheScope=private`;宿主按令牌、主体、项目和上下文分别保存缓存,不跨授权上下文复用。[缓存规范](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching)
## 服务与调用结构
在现有 FastAPI Server 挂载规范路径 `/mcp`,通过官方 Python SDK v2 承担协议处理。ASGI 的生命周期纳入现有应用启动和关闭;不得用根路径挂载遮蔽 REST 路由。官方 SDK 支持[在现有 ASGI 应用中挂载](https://py.sdk.modelcontextprotocol.io/run/asgi/),其[协议版本说明](https://py.sdk.modelcontextprotocol.io/protocol-versions/)覆盖 2026-07-28。
查询适配器复用现有 service、repository、项目路由和业务权限,不从 MCP 向自己的 REST API再发一次 HTTP 请求,不转发收到的 MCP Bearer。需要从 endpoint 提取逻辑时,提取供 REST 和 MCP 共用的 service,而不直接调用带 FastAPI Depends 默认值的 endpoint 函数。
```text
模型调用查询工具
→ OpenCode 原生 MCP(tools/call,_meta 中关联原生 sessionID)
→ Agent 本地 MCP 服务(内部密钥认证、宿主会话和直接父子关系检查)
→ 宿主专用产品 MCP 客户端
→ Keycloak(按主体获取面向 MCP 的令牌)
→ Server /mcp(标准 MCP 请求 + 宿主绑定的上下文)
→ 项目与任务权限检查
→ 共用查询 service
→ BizDB / TimescaleDB / 既有外围适配器
→ 调查证据保存和有范围的数据结果
```
OpenCode 原生发现本地 MCP 的工具目录,工具 ID 为 `mcp_tjwater_<name>`。本地 MCP 默认提供地址解析、会话搜索、调查任务列表与详情、消息读取、待办读取六项查询;启用产品 MCP 后,宿主从真实 Server `tools/list` 发现24项项目查询,投影并合并到本地目录,共30项。公开网页搜索使用 OpenCode 原生 `websearch`,Server 不提供搜索接口。可信插件只配置连接和权限策略,不注册独立查询工具或自定义执行器。待办读取经宿主 RPC 访问原插件存储,保留已有清单。
本地服务使用官方 TypeScript Server SDK 2.3.1、2026-07-28 Streamable HTTP,绑定127.0.0.1临时端口,拒绝浏览器来源和错误内部密钥。OpenCode 的 `_meta["ai.opencode/sessionID"]` 仅是关联值;通过内部通道认证后,服务再解析宿主身份或已核验的直接只读子会话。未知会话、个人项目查询和学习复盘均拒绝。URL、身份、项目、网络、产品认证头和上下文句柄不属于模型参数。产品 MCP 未启用时项目查询不可用,没有旧桥回退。取消信号贯穿整个调用链。[OpenCode MCP 会话上下文](https://dev.opencode.ai/v2/docs/mcp-servers/#context)
现有 OpenCode 共享 MCP 配置缺少逐会话 OAuth 提供器,不能把一份用户 Bearer 填进 `mcp.servers.headers`。产品内部连接由 Agent 宿主按请求认证,外部 MCP 继续保持原配置路径和现有策略,二者不能误注册为同名工具或形成两份不同凭据的业务连接。
## 认证与上下文绑定
### OAuth 资源与现有登录
Keycloak 继续作为授权服务器。MCP 是独立的受保护资源,资源标识由 `TJWATER_MCP_RESOURCE_URI` 指定为部署的规范 `/mcp` URI;令牌目标 audience 取专用客户端 `tjwater-mcp`。Agent 宿主使用预注册 confidential client `tjwater-agent-mcp`。用户继续使用当前前端标准流程和 S256 PKCE 登录,不新增产品内的第二次登录。
OAuth scope 分成 `mcp.catalog.read`、`mcp.query` 和 `mcp.context.bind`。宿主启动时以自身 client_credentials 取得仅含 catalog scope 的令牌,发现真实目录;该身份不获得项目业务读取能力。会话查询令牌必须具有 query scope,绑定接口还要求 context.bind。scope 只开放接口类别,具体数据权限仍由产品主体、项目成员关系和工具 registry 求交。
MCP 端提供 `/.well-known/oauth-protected-resource/mcp`,401 响应包含对应的 `WWW-Authenticate` 资源发现信息。验证签名、固定 issuer、audience、exp、iat、客户端来源和查询 scope;对新 MCP 使用独立验证器,不依赖当前 REST 验证器可为空的 audience,也不通过修改全局 audience 破坏现有 REST。
标准 OAuth 客户端在授权和 token 请求中携带规范资源 URI。MCP 仅接收确实面向它发行的访问令牌,令牌每次通过 Authorization 头发送,不出现在 URL、工具参数或模型文本中。[MCP 授权规范](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization)、[授权服务器发现](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery)
产品宿主先发送无凭据的标准发现请求,从401 challenge 读取资源元数据地址;无地址时按 MCP 路径、根路径依次发现 well-known。资源 URI 必须匹配已配置资源,authorization_servers 必须包含预注册凭据所属的固定 issuer。随后使用官方 SDK 按规范顺序发现 OAuth/OIDC 元数据,严格比较 issuer,从元数据取得 token endpoint;不向其他 issuer 发送原凭据。公开发现请求不带产品 Bearer 或 secret,单份文档最多64KiB,总预算10秒;拒绝重定向和远程明文地址。发现缓存最多5分钟,MCP 401 后失效重查。根据元数据使用 client_secret_post 或 client_secret_basic。
产品内部宿主基于当前产品凭据使用 Keycloak Standard Token Exchange V2 换取面向 `tjwater-mcp` 的短期访问令牌;保留原主体,限定查询 scope,避免共享一个超级服务账号访问所有普通用户数据。专用令牌只保存在 Agent 主进程内存中,刷新令牌仍由现有登录机制持有。
Keycloak V2 需要 confidential requester、启用 Standard token exchange、subject token 对 requester 的 audience,以及目标 audience/scope 配置。client_credentials 与 token exchange 均发送 `resource=规范MCP URI`;exchange 另保留产品要求的 audience。身份平台必须将该资源映射到专用 audience,并核验两种 grant 返回的令牌。若部署版本拒绝 resource,应先升级或提供兼容适配再启用;不能删除标准参数后重试。不增加第三方客户端授权码流程。[Keycloak Token Exchange](https://www.keycloak.org/securing-apps/token-exchange)
上述 realm/client 能力尚未在线核验,是部署前提,由身份平台配置负责人落实。达不到前提时停止启用内部 MCP;不以普通 API Bearer 转发、共享管理员 token 或关闭 audience 校验降级。
### 每次调用绑定项目与调查轮次
OAuth 令牌只证明主体与可用 scope,业务范围由 Server 签发的 `context_handle` 约束。它是应用上下文,不是 MCP 协议会话,也不能单独作为认证凭据。
可信 Agent 宿主通过受认证的 `POST /api/v1/mcp-query-contexts` 申请上下文。请求中的 session、purpose、项目和任务信息均由原生会话及宿主已有绑定读取;仅允许 `azp=tjwater-agent-mcp` 的宿主客户端创建任务上下文。Server 从令牌确定主体、从成员关系确定项目读取权限,验证 task、run、execution_request 和父会话占用的租约;原生父子关系由可信宿主先核验;不接受自报用户、权限或数据 scope。
Server 保存的绑定字段包括:主体、client ID、project ID、native session ID、purpose、可用查询工具、任务及运行标识、父会话、创建与过期时间、撤销状态。句柄使用至少 256 位随机值,10 分钟过期。普通上下文到期由宿主重新申请;任务上下文有效性另外取决于当前轮次和 lease,不能用续租让旧轮次重新获得访问权。
所有业务 MCP tool 的线协议 schema 具有宿主字段 `context_handle`。Agent 宿主客户端在发布给本地 MCP 的 schema 投影中移除它,并在请求 Server 时覆盖注入;它不使用 `x-mcp-header`,也不进入聊天 transcript 或普通日志。若输入包含同名字段、身份字段或保留 scope 字段,宿主直接拒绝。resources/read 由宿主在应用命名空间 `_meta` 中注入同一上下文,URI 仅标识任务证据而不携带句柄或令牌。外部客户端若将来接入,应自己调用绑定接口并在宿主侧选择项目,不从模型内容决定项目。
Server 每次同时核验 Bearer 与句柄所属的 `sub/azp`,重新校验成员关系和当前权限;任务调用还检查项目、任务、run、execution request 和 lease。句柄复制到其他用户、客户端或项目无效。切换项目新建会话,关闭/删除会话撤销绑定;调用 `DELETE /api/v1/mcp-query-contexts/{context_handle}` 时检查绑定所有者。令牌在新 scope 下续期时不会自动扩大已有上下文的工具授权。
普通会话的 `purpose=ordinary_chat`,仅访问其绑定项目。调查主 Agent 的 `purpose=task_execution`,使用 worker 的现有服务主体和本轮任务范围。只读子 Agent 的 `purpose=task_reader`,宿主根据已经验证的父子关系绑定父轮次;绑定只允许查询和系统证据追加,不授予报告发布、审批、设备控制、Shell 或再次委派权限。个人会话不创建项目查询上下文,返回现有 `PROJECT_CONTEXT_REQUIRED`。
### 续期和退出
普通项目会话复用现有 `credential_refresh_required` 回传与前端 Keycloak `updateToken`。收到更新的产品 access token 后,宿主重新 exchange 并继续原查询,用户只在现有登录本身失效时重新登录。并发续期按主体与原凭据版本合并,最终缓存按 issuer、resource、主体、scope 和原凭据版本隔离。
后台调查不依赖浏览器。worker 保持当前 client_credentials 续期;新服务 Bearer 到达后由 Agent 更新原会话产品凭据,再 exchange。MCP 声明官方 `io.modelcontextprotocol/oauth-client-credentials` 授权扩展以支持服务身份;首次范围仍由服务账号成员关系与任务授权决定。[Client Credentials 扩展](https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials)
MCP 401 清除本主体缓存并续期一次;403 不反复刷新、不自动申请更大 scope。认证服务不可用时返回认证暂不可用,不能返回空数组掩盖故障。注销时撤销产品绑定、取消调用并清除宿主缓存。JWT exchange 的源 token 撤销不必然撤销已交换 token,因此 MCP 令牌短期有效,Server 的绑定撤销和每次业务权限检查仍然必须执行。
## 查询工具目录
以下为第一版明确目录。名称为 MCP 服务端名字,OpenCode 原生发现后添加 `mcp_tjwater_` 前缀。元素枚举固定为 junction、pipe、valve、pump、tank、reservoir;时间必须带时区;身份、项目和 network 都不在模型输入中。HTTP 路径表示现有能力来源,MCP 实现调用其共用 service。
| 工具 | 业务输入 | 当前能力来源与限制 |
| --- | --- | --- |
| `get_element_properties` | element_type、element_id | 六类 `/{type}s/properties` |
| `list_elements` | element_type、limit、offset | 六类 `/{type}s`;返回一页,不暗中获取全网 |
| `query_nearby_elements` | center、可选 element_type、radius_m、limit、offset | 当前项目模型管网空间邻近查询;直接接收地理编码的 EPSG:4326 `[经度,纬度]`,默认pipe、200米、5条,最大10000米、单页100条;返回element_id、distance_m及properties,不查询原始GIS源要素 |
| `get_network_options` | kind、可选 pump_id | network-options 及 time、energy、pump-energy;泵能源必须给泵 ID |
| `get_network_schema` | kind | 固定 network-schemas 枚举,以当前注册路径为准 |
| `list_scada_devices` | limit、offset | `/scada-devices`,显式分页 |
| `get_scada_device` | device_id | `/scada-devices/{device_id}` |
| `query_scada_readings` | device_ids、start、end、field | scada-readings/fields;区分 monitored_value 和 cleaned_value |
| `query_model_baseline_series` | element_type、start、end、元素筛选 | realtime/links、realtime/nodes;标记为模型基线,不称为实测 |
| `get_model_baseline_at_time` | element_type、element_id、time | realtime/simulation-results |
| `query_model_baseline_field` | element_type、field、time | realtime/records;只允许当前字段枚举 |
| `query_analysis_series` | run_id、element_type、element_id、field、start、end | analysis/runs/{run_id}/links 或 nodes/{id} |
| `query_analysis_values` | run_id、element_type、field、time | analysis/runs/{run_id}/values |
| `list_analysis_runs` | limit、offset | `/analysis/runs` |
| `get_analysis_run` | run_id | `/analysis/runs/{run_id}` |
| `list_analysis_results` | run_id、result_type、limit、offset | `/analysis/runs/{run_id}/results`;保留 result_type |
| `list_sensor_placement_runs` | limit、offset | sensor-placement-runs;公开 REST 已包装分页,但底层 service 读取整数组,MCP 应推动共用 service 做真实有界查询 |
| `get_sensor_placement_run` | run_id | sensor-placement-runs/{run_id};REST 适配层已注入 network,MCP 共用 service 也从可信项目上下文解析 |
| `query_element_history` | elements、start、end、mode、可选 run_id | element-history/query;observed、realtime_comparison、analysis_comparison |
| `get_element_gis_properties` | element_type、element_id | elements/{type}/{id}/gis-properties |
| `get_source_gis_properties` | layer_key、source_fid | source-gis/layers/{key}/features/{id}/properties |
| `get_workbench_runtime` | 已有运行态查询参数 | workbench/runtime;只读取 |
| `get_task_context` | section、data_path、limit、offset、可选 report_revision;无模型可选 task_id | 当前绑定任务的固定摘要及分章节、路径分页详情;预览不等于完整报告 |
| `query_peripheral_data` | source、point_id、start、end、limit、offset | 固定外围 points、work_orders、observations、test_data 适配器;保留 synthetic |
| `get_task_evidence` | evidence_id、data_path、limit、offset | 当前任务的 evidence/{id}/snapshot;项目、任务及可读轮次由绑定约束 |
初版为24项查询;新增空间邻近查询后,当前为25项查询,连同10项分析共35项Server工具。旧 CLI 的 `data pipeline-health` 实际触发模型预测,不能包装成“读取已保存结果”;它作为 `predict_pipeline_health` 的后续计算候选,先核对模型资产、执行成本和计算权限。旧 CLI 的 9 条 POST 执行命令也仅列作后续候选,不加入查询目录。
空间邻近查询沿用 `webgis.view` 和宿主绑定项目,数据库连接不从工具参数选择。`app/infra/db/postgresql/spatial_queries.py` 使用既有 `gis.*` 模型视图及GiST几何索引做保守候选筛选,再转换到WGS84 geography,按点到完整几何的椭球最短距离以米筛选和排序,最后在数据库中执行LIMIT/OFFSET,不把全网几何交给Agent。跨日期变更线时拆分候选包围框,极区跳过Mercator候选筛选;空或缺失几何不参与查询。距离语义依据 [PostGIS ST_DWithin](https://postgis.net/docs/ST_DWithin.html) 和 [ST_Distance](https://postgis.net/docs/ST_Distance.html),不把EPSG:3857平面距离直接当作实际米数。
输出 `data.source=model_network`、`center_crs=EPSG:4326`、`distance_method=WGS84_spheroid`,`data.items` 包含 `element_id`、`distance_m` 和 `properties`;`page.has_more` 与 `truncated` 保留分页边界。没有命中返回空items,不自动扩大半径;空间邻近不证明拓扑连接或供水归属,道路/河流交叉位置必须由调用方先确认。查询预算仍为30秒,调查调用沿用现有证据与回执保存,不新增REST查询桥或数据库迁移。
聚焦验证:`python -m pytest tests/unit/test_mcp_spatial.py tests/unit/test_mcp_query.py -q`;设置 `MCP_SPATIAL_TEST_DSN` 后运行 `python -m pytest tests/integration/test_mcp_spatial_postgis.py -q` 可验证实际PostGIS距离与分页,测试只创建事务内TEMP表并回滚,不写业务数据。
权限保持当前真实规则:管网、GIS 和分析运行读取沿用 webgis.view,SCADA 沿用 scada.view,模型基线和分析时序沿用 simulation.view;监测点选址路由当前整体要求 optimization.run,MCP 不擅自改成 optimization.view。历史比较模式额外核对其中使用的模拟数据权限。拆分读权限需要单独的后端授权设计,不能借 MCP 接入顺便降级。
新源 registry 明确 source、所需权限、查询参数 Schema、数据语义、结果上限、任务 scope 和是否允许证据保存。原 7 个 TaskDataSource 与新源按明确规则扩展,历史证据仍可反序列化。既有已批准 scope 只迁移到与其原意等价的查询能力,不用“只读”作为放开所有源的理由。
## 分页 大结果与数据语义
集合查询默认 limit=20,最大 500;分析运行最大 100。offset 最大10000,超过时缩小时间窗或目标。常规集合在 SQL 中限制返回量。带总量的集合返回 items、total、limit、offset、has_more;没有稳定总量时 total=null,不伪造总数。Agent 首次优先单对象、必要的短时间窗和小页;只有明确问题仍缺证据且 has_more=true 时继续翻页,不自动遍历全网。
历史组合查询按稳定的序列顺序(设备ID、原始/清洗,再按类型和元素ID排列模型序列)及时间对数据点分页;在同一条 UNION ALL SQL 上应用全局 limit/offset,并多读一个点计算 has_more。证据查询按绑定项目、任务和证据ID,在数据库中选取 data_path 和页;嵌套集合返回路径描述,数组保持索引顺序,对象按 C 排序,字符串按 Unicode 字符分页。证据页和完整资源在返回数据库客户端前检查8MiB上限。此限制约束传输和应用进程,不代表数据库无需读取或解压 JSONB。
DEMO 样本仍需校验整份 RFC 8785 摘要,不能仅验证所选页。数据库先检查整份 JSON 文本大小,超过8MiB则拒绝传输;预算内读取完整样本,校验后筛选和分页。超限须由数据源拆分样本,不能通过反复缩小查询窗口消除该限制。
时序默认最多 20 个目标、24 小时,最多返回 10000 条或 8 MiB。全网模型基线接口当前没有元素筛选和结果分页,接入前必须在共用 service 增加有界筛选/游标;MCP 不直接代理当前无限制接口。批量历史采用其现有 mode 和 run_id 语义,不假定新接口完全等价于旧三类 composite 查询。
结果超过内联 12000 字节时返回摘要、数量、时间范围、truncated、继续查询参数或受保护 resource_link。第一版 resources 暴露已有任务证据快照,普通查询用分页继续读取;不新增全局 result-ref 存储或任意主机文件路径 API。资源读取同样核验令牌和上下文,不能凭 URI 得到数据。
共用输出对象包含 source、source_ref、queried_at、origin、data、window、units、page、truncated、data_gaps,以及调查时的 evidence_id。原 service 返回结构由各工具的 Pydantic 输出模型明确描述,不靠自由文本或一个无约束的 data 字段作为最终契约。第一版每主体/项目最多 4 个并发查询;超过并发返回可重试限流结果,队列不无限增长。查询执行预算沿用 Server 已配置的访问超时,认证续期等待单独计时。
普通原始查询错误返回结构化业务错误,不改成空数据。结果标明数据来源、查询时间、源时间窗口、单位、SCADA 实测或清洗、模型基线或分析运行、synthetic、缺失及截断。`truncated=true` 不能支持“完整范围未见异常”的结论。
## 调查证据与幂等
共享查询入口根据已验证 purpose 选择包装方式。普通聊天返回查询结果;调查及 reader 查询在返回前调用现有 `OperationalTaskService.record_data_snapshot`,保存范围与来源,再返回 snapshot 和 evidence_id。外部 MCP 返回内容不能通过这一入口被重新标成内部系统证据。
原生 callID 经宿主转为 application request key。Server 按主体、项目、会话、任务、run、callID 和规范参数记录查询回执;同 key、同参数重试读取已保存 snapshot,同 key、不同参数拒绝。MCP 断线重发使用新 JSON-RPC ID,但保持同一 application request key;证据存储提交失败就返回失败,不能先返回成功再异步补证据。
调查取数前后都校验本轮仍然有效。长查询期间不持有任务行锁;完成后用短事务确认轮次并保存证据。查询途中被新轮次替代时,不把晚到结果写进新轮次,也不复活旧运行。外部网络请求与数据库读写使用短事务,不跨网络等待持锁。
查询业务源是只读,但调查包装会追加证据,因此相应工具不能仅根据 GET 方法声明 `readOnlyHint=true`。固定目录内可能保存证据的工具保守标为 `readOnlyHint=false`、`destructiveHint=false`,执行策略按已审查的精确工具名允许“读源并追加系统证据”,不由 annotation 决定授权。精确 grant 不覆盖报告发布、工单或设备动作。
## 客户端策略与生命周期
内部业务查询在项目会话、调查执行和已验证 task_reader 中开放对应精确工具。reader 的精确白名单仅增加可信 `mcp_tjwater_*` 项目查询目录;现有外部 MCP 禁用规则继续执行,没有向模型开放通用 resources 桥或子 Agent 写权限。
取消传播至 OpenCode 原生 MCP、本地 MCP、产品 MCP 客户端、Server handler 和底层工作。接口断线时停止请求,宿主不会无条件后台补跑。读查询只允许一次认证重试和一次可判定未完成的传输重试;调查依 application request key 防止重复保存。
本地 MCP 采用已有内部 token 和浏览器来源拒绝规则;它只证明可信 OpenCode 到宿主的通道,不能替代 Server 的 OAuth 主体认证。日志保留主体、项目、任务、run、工具名、耗时、数量、取消与错误类别,不保存 Bearer、secret、context_handle 或原始敏感数据。
MCP 服务失败时其调用返回可重试错误,现有 REST 和原生查询仍可使用;如果管理员启用 MCP 作为必需产品能力,Agent 启动预热必须验证 server/discover、实际 tools/list、Schema、身份路径和取消,不调用付费推理,不用“连接成功”代替能力就绪。
## 实施范围与验收
这是两仓库的中等规模改动,预计超过 8 个文件;第一版挂载于已有 Server,不新增独立部署服务。前端正常情况下继续复用现有凭据刷新事件,只有验收发现契约缺口时才扩展。
| 仓库 | 实施落点 |
| --- | --- |
| Server | app/main.py 挂载和 lifespan;app/core/config.py;新增 app/mcp 的协议、工具与认证适配模块 |
| Server | app/services 的共用查询与上下文绑定服务;app/domain/schemas 的查询/输出契约;任务源与证据回执的持久存储要求 |
| Server | 现有 API service 提取、有界查询;独立 MCP 令牌验证;requirements.txt;.env.example;tests/api、tests/auth、tests/unit;OpenAPI 导出 |
| Agent | src/product 的 MCP 客户端与令牌缓存;现有 credential-refresh;新增内部 MCP call 桥 |
| Agent | .opencode/plugins/tjwater-tools.ts;MCP 和 task-reader 策略;工具 schema 投影;真实目录启动检查;package.json 与锁文件;聚焦测试 |
升级 Server 的官方 mcp SDK 到已发布 v2,按官方迁移文档核对 lifespan、认证、协议模式和结果类型后固定实际发布版本。不能仅升级 fastmcp 并假定新协议成立;已有依赖是否删除,须先确认真实使用与兼容关系。当前 Python SDK 文档示例同时包含 legacy 配置,协议行为以 2026-07-28 规范和实际验收为准。[Python SDK v2 迁移说明](https://py.sdk.modelcontextprotocol.io/migration/)
部署配置包含固定 Keycloak issuer、规范 MCP resource URI、专用 audience、Agent confidential client ID 与 secret。secret 只在 Agent 宿主环境或密钥管理中,Keycloak 签名密钥由 JWKS 获取并校验固定 issuer,不发给 OpenCode。沿用 worker 自有 client_credentials 配置,不把 worker secret 转交给 Agent。
Server 需提供上下文绑定、撤销和调查查询回执的持久存储;多进程读写须一致,不能用单进程 dict 宣称重启或横向扩容可用。应用层只保存令牌派生身份和范围,不保存 token。存储由后端按现有数据库管理流程交付,本设计不执行数据库迁移。
验收必须覆盖:
1. 实际 `server/discover`、24 工具目录、版本固定、标准 `_meta`、resultType、缓存提示;头与正文不一致、未知工具、非法 schema、旧握手的错误行为。
2. 两个用户两个项目并发,交错调用也不串凭据;句柄换用户、项目、client、task、run 或失效均拒绝;个人会话不得获得业务数据。
3. 普通登录无需再次登录 MCP;401续期一次、403不升权;并发 refresh 合并;注销、成员撤销和背景服务凭据续期。
4. worker 无浏览器完成查询;reader 只取父任务范围;不获得报告、审批、控制、Shell 或其他外部 MCP。
5. 管网属性与分页、SCADA 原值/清洗值、模型基线、分析 run、历史三种 mode、GIS 与外围 synthetic;不支持的旧功能返回明确能力缺口。
6. 24小时/目标数/分页边界、实际服务端限量、资源读取授权;超大结果不会全量进入内存或聊天。
7. 查询成功且证据已经持久化;断线重试仅一个证据回执;查询中轮次被替代、取消、DB失败和认证服务失败均不误报成功。
8. REST 与 MCP 的权限和数据结果相同;新 SDK 不改变原 API、worker 或报告读取;启动真实目录检查不调用模型推理。
使用 Server 现有 conda 环境运行新增 MCP 聚焦 tests/api、tests/auth、tests/unit;Agent 执行 bun run check 与新增凭据、MCP桥、策略、目录聚焦测试。用户登录、refresh 和两项目隔离需用现有前端做一次实际浏览器验收。设计文档本身仅进行路径检查与 git diff --check,不执行业务写请求。
交付可以分两次独立上线:先增加服务端只读 MCP、OAuth与上下文,保留现有产品调用;再接入 Agent 宿主客户端和 reader,验证后切换产品查询。任何一步失败都可以回到已有原生工具,不能在单次失败时自动切换凭据绕过权限。回滚停用 MCP 入口/宿主注册并撤销上下文,保留已经保存的调查证据与旧报告。提交、身份平台变更、数据库供给和部署另按实际实施授权执行。
## 旧 CLI 与当前后端对照
路径均为 `/api/v1` 下的当前能力;“执行候选”不加入第一版查询 MCP。此表依据当前源码生成 OpenAPI 和旧 CLI handler,而非线上算法成功证明。
| 旧 CLI 命令 | 当前路径或处理 | 第一版决定 |
| --- | --- | --- |
| network get-junction-properties | GET /junctions/properties | get_element_properties |
| network get-pipe-properties | GET /pipes/properties | get_element_properties |
| network get-all-pipes-properties | GET /pipes | list_elements 分页 |
| network get-reservoir-properties | GET /reservoirs/properties | get_element_properties |
| network get-all-reservoirs-properties | GET /reservoirs | list_elements 分页 |
| network get-tank-properties | GET /tanks/properties | get_element_properties |
| network get-all-tanks-properties | GET /tanks | list_elements 分页 |
| network get-pump-properties | GET /pumps/properties | get_element_properties |
| network get-all-pumps-properties | GET /pumps | list_elements 分页 |
| network get-valve-properties | GET /valves/properties | get_element_properties |
| network get-all-valves-properties | GET /valves | list_elements 分页 |
| component option schema | GET /network-schemas/{kind} | get_network_schema |
| component option get | GET /network-options 及 time、energy、pump-energy | get_network_options |
| simulation run | POST /simulation-runs | 执行候选,duration 为分钟 |
| analysis burst | POST /burst-analyses | 执行候选 |
| analysis valve isolation | POST /valve-isolation-analyses | 执行候选 |
| analysis flushing | POST /flushing-analyses | 执行候选 |
| analysis age | /water-age-analyses 未注册 | 不开放,不假定算法仍可运行 |
| analysis contaminant | POST /contaminant-simulations | 执行候选 |
| analysis sensor-placement run | POST /sensor-placement-runs | 执行候选 |
| analysis sensor-placement list | GET /sensor-placement-runs | list_sensor_placement_runs,有界适配 |
| analysis sensor-placement get | GET /sensor-placement-runs/{run_id} | get_sensor_placement_run,读取项目路由中的既有成果 |
| analysis runs list | GET /analysis/runs | list_analysis_runs |
| analysis runs get | GET /analysis/runs/{run_id} | get_analysis_run |
| analysis runs results | GET /analysis/runs/{run_id}/results | list_analysis_results |
| analysis leakage identify | POST /leakage-identifications | 执行候选 |
| analysis burst-detection detect | POST /burst-detections | 执行候选 |
| analysis burst-location locate | POST /burst-locations | 执行候选 |
| data timeseries realtime links | GET /timeseries/realtime/links | query_model_baseline_series,已补最多20个元素筛选及SQL限量 |
| data timeseries realtime nodes | GET /timeseries/realtime/nodes | query_model_baseline_series,已补最多20个元素筛选及SQL限量 |
| data timeseries realtime simulation-by-id-time | GET /timeseries/realtime/simulation-results | get_model_baseline_at_time |
| data timeseries realtime simulation-by-time-property | GET /timeseries/realtime/records | query_model_baseline_field |
| data timeseries analysis link-field | GET /timeseries/analysis/runs/{run_id}/links/{link_id} | query_analysis_series |
| data timeseries analysis node-field | GET /timeseries/analysis/runs/{run_id}/nodes/{node_id} | query_analysis_series |
| data timeseries analysis values | GET /timeseries/analysis/runs/{run_id}/values | query_analysis_values |
| data timeseries scada query | GET /timeseries/scada-readings 及 fields | query_scada_readings |
| data timeseries composite | 原三个 views 路径未注册;现有 POST /timeseries/views/element-history/query | 按三种 history mode 重新适配,不直接宣称等价 |
| data pipeline-health | GET /pipeline-health-predictions,实际进行预测 | 后续计算候选,先验证模型资产 |
| data scada get | 原 /scada-devices/detail 未注册;现有 GET /scada-devices/{device_id} | 修正为 get_scada_device |
| data scada list | GET /scada-devices | list_scada_devices 分页 |
| data scada schema | GET /network-schemas/scada-device | get_network_schema |
当前 Agent 的 `ProjectDataClient` 读取单个元素等能力仍较窄;其中 SCADA 设备读取仅列表,缺少单设备新路径入口。MCP 切换前以本表和当前 Schema 对齐,不能从旧 CLI 直接复制参数或依赖其全量分页行为。
任务上下文查询支持可选的正整数 `report_revision`;未显式指定时宿主保留协作会话绑定的报告修订号,避免将历史报告替换为最新报告。