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.
This commit is contained in:
2026-10-19 18:46:58 +08:00
parent 2b6fc5aa52
commit be6317eb48
117 changed files with 12703 additions and 638 deletions
@@ -0,0 +1,116 @@
# 调查进展与实时展示
调查执行中可持续发布正文和完整 Mermaid 图,尚未形成结论时也能发布。
Server 将阅读进展、业务报告和浏览器展示请求分别保存。运行结束仍要求同轮最终报告,
进展中的节点状态不参与调度、审批或结束判断。
## 进展快照
`POST /api/v1/operational-tasks/{task_id}/agent-artifacts` 新增可选 `progress`:
```json
{
"request_id": "<uuid>",
"run_id": "<current-run-uuid>",
"source_session_id": "<claimed-session-id>",
"progress": {
"summary": "正在核对投诉时段的监测记录,尚未形成结论。",
"diagram": {
"title": "当前调查进展",
"mermaid": "flowchart LR\nread[核查监测记录] --> judge[判断证据范围]",
"node_refs": [{
"node_id": "read",
"label": "核查监测记录",
"status": "running",
"evidence_refs": [],
"action_refs": [],
"map_targets": [{"source_id": "pipes", "feature_id": "<known-pipe-id>"}]
}]
}
}
}
```
示例中的编号由实际任务和资产替换。正文最长 20,000 字符,`diagram` 可空。
图示沿用 [调查报告契约](investigation-report-contract.md) 的 Mermaid 安全限制。
节点状态可空,或为 `pending/running/completed/waiting_human/failed/skipped/cancelled`。
`map_targets` 只接受 `junctions/pipes/valves/pumps/tanks/reservoirs/scada` 七类来源和要素编号。
图中节点引用在本次发布内唯一,证据和动作引用必须属于该任务。跨修订可以修改节点编号、
分支和文字,Server 不验证依赖执行顺序或节点完工。
每次传入 `progress` 都完整替换该 run 的快照,传入 `diagram: null` 清除上一版图示。
省略 `progress` 保留原快照。同一个请求编号重试只发布一次。
`workbench.operational_task_progress` 每个任务和 run 保存最新一份快照,独立于七天事件清理。
任务详情和 Agent 上下文返回 `latest_progress`,包含 `revision/run_id/execution_generation/`
`published_at/source_session_id/progress`。只读取当前执行代次,新轮次尚未发布时返回空。
快照更新发出 `operational.progress.changed`,前端据此读取最新内容。
## 浏览器实时联动
`POST /api/v1/operational-tasks/{task_id}/presentation-events` 接收
`request_id/run_id/source_session_id/command`,要求任务执行权限。
Server 校验当前 run、绑定会话、有效租约和幂等身份,记录 `operational.presentation.requested`。
响应为 `recorded/event_seq/run_id/execution_generation`,表示展示请求已保存,
不表示浏览器已显示或现场动作已执行。
| command.action | 参数 |
| --- | --- |
| highlight_features | layer、feature_ids、effect:steady 或 pulse |
| clear_highlights | 无 |
| open_chart | chart_id、chart、非空 evidence_refs |
| close_chart | chart_id |
| set_overlay | overlay_id、title、mode:geometry 或 heatmap、evidence_id |
| clear_overlay | overlay_id |
图表限定折线和柱形,包含 `title/chart_type/x_data/series`。最多 100 个横轴值和 8 组有限数值序列,
序列长度与横轴对齐,引用证据必须属于当前任务。地图高亮来源沿用上述七类。
覆盖层输入不接受模型提供的 `data`。Server 从证据的不可变数据快照提取现有 GeoJSON,
或使用已记录的 `longitude/latitude` 点位,将 `FeatureCollection` 加入事件中的 `command.data`。
热力图只接受记录中的点,权重为有限非负数,未记录权重时取 1。
没有坐标的地址、空数据、非法几何、模型自绘区域均不能生成覆盖层。该接口不做地址解析、
范围推算或污染传播计算。展示事件总大小上限为 512 KiB,超限返回 422。
`GET /api/v1/operational-tasks/{task_id}/presentation-cursor` 在同一事务中读取
`task_id/execution_generation/active_run/event_cursor`,其中活跃 run 仅返回 `run_id/generation/status`。
浏览器打开 Dev 联动、切换任务或断线重连时,以此建立新基线,只消费之后的展示事件。
跟随开关只影响当前浏览器的临时展示,正文和图示更新始终可读,调查仍由后台继续执行。
关闭 Dev 面板不改变跟随开关。关闭跟随开关、切换任务或断线时清理临时效果,
执行结束停止执行态动效,已显示的图表与覆盖层保留供阅读。不回放历史请求,不重新执行调查。
## 人工反馈续调
外围同步器根据动作回执找到关联子工单。样板创建固定为 `revision=1`,首次创建回执不作为
新的现场反馈入队。后续版本可唤醒已结束调查,旧报告和证据保留,生成新的执行代次。
现场备注从工单 `activity.kind=feedback` 的记录进入反馈证据,仍标明样板合成来源。
重复请求或同一源版本重放不增加执行代次,取消调查拒绝新反馈。
同步器先送达各父调查反馈,再推进预约工单投影的源版本。定向源事件和动作回执补查保留,
处理源更新早于回执保存的情况。全量对账只为源版本变化补查已结束调查,本次不回填历史反馈。
原投诉收到新版本后同步推进其已有来源修订号。数据不足仍正常结束本轮,
只有新的可信业务输入进入后续轮次,不依赖图示节点制造人工等待。
## 部署与验证
新增 [023 迁移](../resources/sql/023_operational_task_progress.sql) 仅适用于 `tjwater_v2`,
增加进展表和两个事件 topic 的 CHECK 取值。2026-10-10 已在目标库完成全库备份、
迁移事务回滚演练及正式应用,其他项目和旧任务未改动。
实际 worker 曾成功投递指定调查,模型发布进展和报告,但因 service 账号的任务权限过滤
导致 `approved_data_scopes` 为空,误作数据不足结束。这不是业务数据确实不足。
用户确认开放规则后,任务 API 已移除该额外过滤,沿用账号已有的项目读取权限。
在线上下文返回七类来源,同一身份实际读取 SCADA 设备和外围合成工单成功;没有新增
角色权限或放宽跨项目、审批和设备控制边界。旧委托路径保留兼容,已有权限无需委托凭据。
用户级 `tjwater-operational-worker.service` 已重新启用。旧报告保留,未自动重新调查,
不能把旧报告视为修复后的业务验收。任务创建时间下界和并发配置见
[执行 worker](operational-task-workers.md),未回填旧任务。
进展读接口在表尚未部署时返回空,写入口须在迁移与 Server 更新后启用。
按现有数据库发布流程应用迁移,再更新 Server、Agent 和前端,运行中的 worker 需要加载更新。
聚焦检查:
```bash
conda run -n server python -m pytest tests/unit/test_operational_tasks.py tests/unit/test_operational_presentation.py tests/unit/test_peripheral_task_sync.py tests/unit/test_peripheral_push_sync.py tests/api/test_operational_task_dynamic_flow.py tests/api/test_openapi_contract.py -q
git diff --check
```
这些检查覆盖隔离 HTTP 与状态机,不代替共享数据库迁移、真实模型运行或现场复测验收。
+4
View File
@@ -149,6 +149,10 @@ failure_code 为 ARTIFACT_INCOMPLETE;调用方须读取返回的业务状态
## 发布、阅读与协作
执行中尚未形成结论的正文和 Mermaid 使用独立的 `progress` 快照持续更新,
见 [调查进展与实时展示](investigation-progress-and-presentation.md)。
报告的 interim/final 与 concluded/insufficient_data 结果契约保持不变。
每次发布保留 `operational.report.published` 同步事件。首次 final、阶段报告转 final、
新轮次 final 或判断/边界/依据/建议/缺口实质变化时,生成 `report_updated` 通知,
其中 `report_reference = {execution_generation, revision}`。纯图示修订不生成额外成果提醒。
+71
View File
@@ -0,0 +1,71 @@
# Agent 业务分析 MCP
Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共35项:25项查询、下表10项分析;合并6项宿主查询后共41项。工具参数来自Server实际MCP schema,不维护第二套参数契约。
| 工具名(不含前缀) | 能力与边界 | 必要权限 |
| --- | --- | --- |
| `run_simulation` | 在线模拟:按水力步长直接计算业务库,不复制数据库;更新模型条件和实时基线,返回指定节点/管段,不创建方案或正式分析运行 | `simulation.run` + `scada.view` |
| `run_burst_detection` | 指定时刻的15天压力异常侦测,至少5个完整监测节点;不定位 | `burst.run` + `scada.view` |
| `run_burst_location` | 异常/正常压力、可选流量及漏水量假设的候选管段排序 | `burst.run` + 对应数据的 `scada.view` 或 `simulation.view` |
| `analyze_valve_isolation` | 拓扑隔离边界、候选关阀及影响节点;不是水力计算或真实关阀 | `simulation.run` |
| `run_leakage_identification` | SCADA压力约束下的DMA漏损分配估计;需总漏损量依据 | `burst.run` + `scada.view` |
| `run_contaminant_simulation` | 已假设污染源的正向浓度传播;不是源头反演 | `simulation.run` + `scada.view` |
| `analyze_valve_closure` | 给定关阀组合的水力影响 | `simulation.run` + `scada.view` |
| `analyze_flushing` | 指定排放节点和流量的冲洗水力影响;不证明消毒合格 | `simulation.run` + `scada.view` |
| `analyze_pressure_regulation` | 给定调泵序列、水箱初始水位的压力影响;不自动优化调度 | `simulation.run` + `scada.view` |
| `optimize_sensor_placement` | 灵敏度或K-means压力监测点建议;不保存正式布点方案 | `optimization.run`,项目成员角色 |
## 执行与证据
标准水力模拟示例(只展示参数,不执行):
```json
{"start_time":"2026-10-08T12:00:00+08:00","duration":3600,"output_node_ids":["<已核实节点ID>"],"output_link_ids":["540672"]}
```
工具完整 ID 为 `mcp_tjwater_run_simulation`。开始时间必须带时区,时长为60至86400秒,
默认900秒;至少指定一个节点,节点和管段各最多20个。项目与身份由宿主绑定。
输出沿用场景结果信封,包含 `node_results`、`link_results`、单位、时间和覆盖范围。
它复用 REST `/api/v1/simulation-runs` 的在线计算服务:REST 时长仍为分钟,MCP 时长为秒。
每个水力步长执行一次 `realtime` 快照,更新在线模型条件和该时刻的实时基线;不复制模型数据库。
结果中的 `times.sample_times` 对齐采样点,窗口不包含结束时刻;例如一步长15分钟、时长3600秒时返回4个采样点。
`persisted_baseline=true` 表示在线基线已重算,`persisted_scheme=false` 表示没有创建方案。
该工具的 MCP annotation 为 `readOnlyHint=false`、`destructiveHint=true`,因为重算会替换对应时刻的基线。
它不会创建 `analysis.run_id`,不能将本次计算冒充已有爆管事件运行;调查调用仍可保存证据。
计算读取现有模型和对应SCADA条件,不从摘要虚构观测,也不自动补齐数据或执行现场操作。
爆管定位 `run_burst_location` 默认使用 `data_source="monitoring"`。`pressure_scada_ids` 可省略,由后端从当前项目SCADA配置自动获取压力测点对应的模型节点;显式指定时仍限制为1至20个不同节点。正常对照时间窗 `scada_normal_start` / `scada_normal_end` 可成对省略,沿用后端默认的前一天同时间段;返回结果中的 `pressure_scada_ids` 和 `scada_window` 说明实际测点和时间窗。默认对照不等于已确认该时段正常。正常窗无有效压力数据或两窗没有共同有效测点时计算失败,不补造数据、不自动换窗。正常窗只有部分测点缺数时,沿用服务逻辑使用两窗共同有效测点,结合返回测点范围解释结果。
仅提供异常时段和有依据的漏水量即可使用上述默认值,例如:
```json
{"scada_burst_start":"2026-10-08T12:00:00+08:00","scada_burst_end":"2026-10-08T13:00:00+08:00","burst_leakage":0.0274}
```
`data_source="simulation"` 仍须提供已有 `simulation_run_id`,使用同窗模型基线作正常对照,不填写正常时间窗;在线 `run_simulation` 不创建该运行UUID,不能替代已有分析运行。
- 身份、项目路由、任务和运行由宿主绑定;模型不能提供network、username、凭据、文件路径、原始观测数组或方案保存名称。
- 依赖时序数据的8项计算通过权限校验后,先从项目元数据解析并激活 BizDB 与 TimescaleDB 完整路由,再启动计算线程;时序库未配置时在修改模型前失败。普通业务查询、纯拓扑隔离及监测点选址不强制要求时序库路由。
- 普通项目会话、任务协作和调查主Agent可调用;个人会话、学习复盘和只读reader不能调用分析。Server每次同时校验当前权限与绑定时的权限上限;任务数据读取scope不能替代计算权限。管理员显式deny继续生效。
- 复用现有业务函数。在线模拟直接调用业务库;定位关闭共享候选CSV输出;识别不保存方案;选址使用不持久化模式。其余四项场景模拟复用项目专用`<业务库>_template`及唯一临时模型库,校验模板同步就绪,沿用临时库并发限制和清理;不是复制在线业务库。模板复制仍会短暂停止模板连接/订阅工作进程,不能称为完全没有基础设施副作用。
- 场景`persist_results=False`仅允许隔离临时模型,跳过正式analysis运行、Timescale结果和基线写入;既有REST默认保存行为不变。仅返回请求的最多20个节点及20个管段,包含单位、时间和缺失对象;不能用局部结果断言全网无影响。
- 数据源和计算假设必须有依据。定位漏水量和DMA总漏损约束使用m³/s;冲洗使用服务实际接收的m³/h,不沿用旧REST误标的L/s。工频泵序列0/1,变频泵序列为Hz;每项对应模型水力步长。冲洗当前限LPS/CMH模型。选址当前推荐压力点,不冒充污染溯源优化。
- 调查结果经现有快照/证据/回执链保存,只有返回evidence_id后才引用为系统证据;普通聊天不自动保存调查证据。模拟结果始终是analysis而非实测。已知模拟事件源管段是输入背景,不是算法定位准确性的证明。
## 资源边界与发布
10项计算工具均提供 `timeout_seconds`,由Agent根据模型规模、模拟步数及已有耗时按次选择;它表示实际等待预算,与 `duration` 表示的模拟时间不同。未填写默认120秒。服务端上限由 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS` 控制,默认3600秒,管理员可设120至86400秒;`tools/list` schema 发布实际有效上限,超限在计算前拒绝。
例如 `duration=3600, timeout_seconds=900` 表示模拟1小时、允许等待15分钟。Agent的上游HTTP/SDK预算为所选预算加5秒,整个宿主调用另留60秒处理认证、上下文绑定和清理;OpenCode连接上限从工具目录计算并写入宿主连接文件。本地MCP请求关闭Bun空闲断连,仍受上述调用截止时间控制。普通查询继续使用原30秒Server预算与35秒宿主预算。
计算在线程执行,不阻塞事件循环,单Server进程最多两项计算。超时不能杀死底层原生计算,槽位一直保留到计算真正退出;超时结果不会补写为调查成功结果,不自动反复重试。本次未增加后台计算作业队列。普通聊天大结果仍受现有12KB内联限制,调查结果可经证据分页读取。
在线重算逐个时刻写入基线,超时或中途失败可能已有部分时刻更新,不能把工具失败解释为整个窗口已回滚;不要自动重试整个窗口。
无需新增数据库迁移或模型配置。须同步发布Server、Agent和前端证据枚举;先更新Server目录,再重启Agent,client模式还需重载原生连接。保持既有OAuth、MCP开关及数据库路由配置,不自动更改权限或重启调查。
本次验证限schema、权限、协议目录和mock业务函数,不运行真实模拟、优化或付费模型。真实项目的模板同步状态、运算耗时、SCADA覆盖和结果准确性仍需另行验收。
2026-10-19 修复:EPANET 2 导出省略阀门默认 `ACTIVE` 状态行并保留数值设置,EPANET 3 导出保留原表达;覆盖文件和文本导出。MCP 在线模拟改为共用在线计算服务,方案模拟继续隔离。回归验证包含原生 EPANET 输入校验及 mock 在线基线写入,不重算真实项目历史数据。
标准水力模拟接入已通过后端schema/权限/隔离适配测试、Agent目录与权限测试、前端证据解析测试;Agent和前端类型检查、前端改动文件ESLint通过。隔离HTTP smoke使用真实两端SDK发现34项Server工具及40项合并工具,并核对 `run_simulation` 输入schema和上下文绑定/查询;身份服务及数据执行为fixture,不等同生产验收。测试启动器剔除宿主MCP环境配置,避免私有issuer覆盖测试的loopback设置。运行中的服务仍需同步重载后确认新目录,未启动真实模拟。
+88
View File
@@ -0,0 +1,88 @@
# MCP 接入的 Keycloak 管理员配置清单
这里创建的是 OAuth 认证配置。OpenCode 到 Agent、Agent 到 Server 的 MCP 协议客户端已实现,无需再开发或安装 MCP 客户端程序。
以下使用已创建的请求方客户端 `agent-mcp`,MCP 资源的 audience 使用 `tjwater-mcp`。它们位于现有业务登录使用的同一个 realm。
## 1. 请求方客户端 agent-mcp
在 Keycloak 的 Clients 中打开 `agent-mcp`:
| 设置 | 值 |
| --- | --- |
| Client type / Protocol | OpenID Connect |
| Client authentication | On;这是 confidential 客户端 |
| Service accounts roles | On;用于启动时读取 MCP 工具目录 |
| Standard token exchange | On;用于按当前用户或 worker 的身份换取 MCP 令牌 |
| Standard flow | Off;该客户端不负责浏览器登录 |
| Direct access grants | Off |
| Access token lifespan | 建议300秒,不超过900秒 |
| 签名算法 | RS256 |
保存后,在 Credentials 中取得 Client Secret。密钥只写入 Agent 的忽略文件 `.local.env`:
```dotenv
TJWATER_MCP_CLIENT_ID='agent-mcp'
TJWATER_MCP_CLIENT_SECRET='<Credentials 中的 Client Secret>'
```
不要把密钥放入聊天、前端配置、OpenCode 配置或 Git。Server 只配置 `TJWATER_MCP_HOST_CLIENT_ID='agent-mcp'`,不需要保存这个客户端的密钥。
## 2. 资源客户端及 scopes
创建或复用表示 MCP 服务的 OIDC 客户端 `tjwater-mcp`;关闭浏览器登录、直接密码登录和服务账户。这个 ID 是目标 audience,不是另一个需要部署的服务。
创建以下 OIDC Client scopes,并作为 Optional scopes 分配给 `agent-mcp`;启用 Include in token scope:
- `mcp.catalog.read`
- `mcp.query`
- `mcp.context.bind`
在这些 scopes 中添加 Audience mapper,使对应 token 的 `aud` 包含目标客户端 `tjwater-mcp`。应用按实际 grant 请求 scope:服务账户请求只有 `mcp.catalog.read`;用户/worker 的交换请求才包含查询和上下文 scope。不要给服务账户额外分配项目成员、管理或设备控制权限。
## 3. 允许当前业务身份交换
在原有前端登录客户端和 worker 客户端的专用 scope 中添加 Audience mapper,使源 token 的 `aud` 包含请求方 `agent-mcp`。保留原业务 API 所需的 audience;不要给源 token 添加 MCP 目标 `tjwater-mcp`。
交换后必须保持用户主体 `sub` 不变,`azp=agent-mcp`,目标 `aud` 包含 `tjwater-mcp`。Keycloak 的 `audience` 参数只筛选已有映射的 audience,不能代替 Audience mapper。
版本和界面名称以当前 Keycloak 为准。配置依据:[Keycloak Standard Token Exchange](https://www.keycloak.org/securing-apps/token-exchange)。仅在公开发现中看到 token exchange,并不能证明具体客户端已启用或映射正确。
## 4. 地址、数据库和启用顺序
正式环境提供该 realm 的 HTTPS issuer。本地开发可显式允许一个 RFC1918 IPv4 HTTP issuer:Agent 设置 `NODE_ENV=development`,Server 设置 `ENVIRONMENT=development`,两端 `TJWATER_MCP_DEV_HTTP_ISSUER_URI` 均填写与各自 `TJWATER_MCP_ISSUER_URI` 完全相同的地址(含 realm)。此例外不放行远程 HTTP MCP resource;Agent 只允许该 issuer 同源的远程 HTTP 发现及令牌端点,继续拒绝重定向。正式环境必须清空此项并使用 HTTPS 域名。配置值必须与发现文档和令牌 `iss` 一致,不能仅替换访问地址。
例如,同机开发时 Server `.env` 可配置为(内网地址按实际认证服务替换):
```dotenv
ENVIRONMENT=development
TJWATER_MCP_ENABLED=true
TJWATER_MCP_ISSUER_URI=http://192.168.1.114:8088/realms/tjwater
TJWATER_MCP_DEV_HTTP_ISSUER_URI=http://192.168.1.114:8088/realms/tjwater
TJWATER_MCP_RESOURCE_URI=http://127.0.0.1:8000/mcp
```
Agent `.local.env` 使用相同的三个 URI,并设置 `NODE_ENV=development`、
`TJWATER_MCP_ENABLED=true`。开发模式不跳过签名、issuer、audience、scope 或项目权限校验,
也不自动接受另一种 issuer。切换 Server 环境还会开启开发 API 文档页面。
修改环境文件后需重启相应进程;已有进程不一定自动重读 `.env`,系统环境变量也可能覆盖文件。
应先确认 Server 的 `/.well-known/oauth-protected-resource/mcp` 公布的新 issuer,
再启动 Agent 检查工具目录。切回生产时,两端恢复相同 HTTPS issuer 并删除 HTTP 白名单。
业务身份也必须使用同一 issuer:供水前端只在被 Git 忽略的
`.env.development.local` 中将 `TJWATER_KEYCLOAK_ISSUER` 设为该内网地址,
保留 `.env.local` 的 HTTPS 配置供正常 production 构建使用。
Server 本机 `.env` 可额外设置 `OPERATIONAL_WORKER_DEV_TOKEN_URL` 为同一内网 issuer
发现文档中的 `token_endpoint`;该值仅在 `ENVIRONMENT=development` 时生效,
不替换原来的 `OPERATIONAL_WORKER_TOKEN_URL`。重启开发前端和 worker,并在浏览器重新登录。
旧 HTTPS 令牌不能用于内网 issuer 的交换;工具目录读取成功不等于业务身份交换成功。
接入人员随后执行:
1. 安装 MCP SDK 2 对应的 Server 依赖。
2. 对选定项目业务库备份并应用 [025_mcp_query_contexts.sql](../resources/sql/025_mcp_query_contexts.sql)。
3. 在 Agent 和 Server 的私有环境中配置相同的 issuer、resource、audience;resource 必须指向实际 Server `/mcp`,请求方客户端 ID 均使用 `agent-mcp`。
4. 验证带标准 `resource` 的目录 grant 和用户/worker token exchange 都成功;若身份平台拒绝,先解决兼容性,不移除参数重试。
5. 在受控测试实例中开启两端 `TJWATER_MCP_ENABLED` 并重载;通过测试项目查询、任务证据和越权拒绝验收后,再按部署流程启用正式环境。
完整环境变量与验收要求见 [MCP 实施文档](mcp-query-implementation.md)。在以上步骤完成前保持 MCP 开关关闭。
+280
View File
@@ -0,0 +1,280 @@
# 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`;未显式指定时宿主保留协作会话绑定的报告修订号,避免将历史报告替换为最新报告。
+108
View File
@@ -0,0 +1,108 @@
# 产品 MCP 查询:本地实施与启用
实现使用 MCP 2026-07-28、Python mcp 2.3.0、TypeScript @modelcontextprotocol/client 与 @modelcontextprotocol/server 2.3.1。Server 提供 `/mcp` 和35项工具(25项查询、10项受控分析);Agent 将其投影合并到本地 MCP,由 OpenCode 原生发现为 `mcp_tjwater_*`,普通项目会话、调查主 Agent 和已验证的直接只读子 Agent 共享25项查询;10项分析仅向非reader会话开放。个人会话不能获取项目凭据。旧 CLI 只作为能力清单,没有运行时 CLI 桥接。
`run_simulation` 是在线重算,复用 REST 在线计算服务而不复制数据库;按水力步长更新模型条件和实时基线,不创建正式方案。MCP 时长为秒,原 REST 时长仍为分钟。其 annotation 为 `readOnlyHint=false`、`destructiveHint=true`;只读 reader 不能调用,其他方案模拟继续使用隔离临时模型。详细边界见 [MCP 分析工具说明](mcp-analysis-tools.md)。
计算工具支持Agent按次指定 `timeout_seconds`,默认120秒;计算上限默认1小时,由 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS` 配置并在工具schema公布。Agent、OpenCode和本地HTTP调用预算随目录同步,普通查询仍保持原有短超时。等待超时不意味着底层线程已停止或在线基线已回滚,禁止自动重试整个写入窗口。
## 认证与数据边界
Agent 根据401 challenge 或路径/根 well-known 发现受保护资源元数据,只选择配置中预注册的固定 issuer,再按规范顺序发现 OAuth/OIDC 元数据并严格核对 issuer。token endpoint 从已校验元数据读取,支持 client_secret_post 和 client_secret_basic。公开发现不带产品 Bearer 或 secret,文档最多64KiB、总预算10秒,拒绝重定向和远程明文地址;元数据缓存最多5分钟,MCP 401 后重新发现。产品 Bearer 只发给该 issuer 声明的 token endpoint,采用 Standard Token Exchange V2 取得 MCP 专用短期 Bearer,检查主体不变、issuer、audience、azp。Server 独立用缓存 JWKS 核验签名、issuer、audience、scope 和最长15分钟寿命。普通 REST 额外拒绝 MCP audience 或宿主 azp 的令牌,防止 audience 配置重叠时混用两类令牌。
上下文由专用宿主申请:绑定主体、client、项目、原生会话、用途、工具目录、权限上限和已批准的七类任务数据 scope;调查另绑定 task/run/execution_request/父会话。每次查询重新核验成员和权限;调查必须仍由父会话持有有效租约。资源读取仅限句柄绑定的任务。模型输入没有项目、network、URL、header、Bearer 或 context_handle。
调查数据返回前,在短事务内追加现有任务证据并保存调用回执。同一主体、会话、任务、run 和原生 call ID 的重试复用证据;不同参数或失效范围明确报错。七类原有访问申请 scope 保持原契约,MCP 的新增来源只扩充快照来源枚举。没有新增报告发布、审批、控制、Shell 或递归委派权限。
普通查询默认20条;目录类最多500条,时序最多10000条、20个标识、24小时;offset 最大10000。Agent 首次优先单对象、必要的短时间窗和小页,仅在证据不足时按需扩大,不自动遍历全网。SQL 有 limit/offset 和 statement_timeout。历史对比在同一条SQL中按序列、时间对数据点分页,返回 page.has_more;非零offset也标明覆盖不完整。历史曲线展示调用初始每页500点,`RESULT_TOO_LARGE` 时原位减半重试,最多读取1000条、发出16次请求,共用120秒超时(单次普通查询仍为30秒),图表最多100个有效点;这不改变模型首次查询默认值或时序接口上限。
单份结果超过8MiB报错;超过12KB的调查工具消息只返回证据资源引用,完整快照仍保存,Server 的 `resources/read` 可以读取它。Agent 本地 MCP 只提供工具,将上游资源引用转为 `evidence_read` 中的 `mcp_tjwater_get_task_evidence` 参数,不向 OpenCode 泄露不可在本地读取的链接。`get_task_evidence` 在数据库中按 data_path、limit、offset 选页,嵌套集合和超过256字符的字符串返回路径描述;字符串按 Unicode 字符分页。页和完整资源均在数据库返回前检查8MiB上限,保留原证据编号和合成来源;普通查询要求缩小范围。其他结果仍保留执行后的整体8MiB检查,不能把它理解为所有查询均有数据库端字节限制。原始/清洗实测、模型基线、分析结果、比较和 synthetic 均明确标记。非有限数值转为 null 并记录数据缺口。
`get_task_context` 默认 section=summary,只查固定元数据、160字符任务标题及200字符结论预览,并返回 available_sections;preview_only 和 truncated 明确说明不是完整报告。按 section=task/report/evidence/actions/progress/access_requests/plan/run 读取详情,report_revision 保留选择旧报告的语义。data_path 选择嵌套值,limit 默认20、最多500,offset 最大10000;证据、动作和授权申请集合以稳定 ID 为键,按 ID 字典序分页。字符串按字符分页。详情在数据库返回前检查8KB预算,超限缩页或选更深路径。缺少可选进度或授权申请表时返回 metadata.available=false,不冒充空事实。绑定权限范围只读取七类既有 scope,不再加载整个任务上下文。不增加工具数量、数据库表或迁移,也不改变原 REST 完整上下文契约。
外围 MCP 直接读取所选项目 BizDB 的样板事实和既有时序。它不执行外围样板 HTTP 的故障场景注入;混有合成记录的结果按 synthetic 保守标记。DEMO-* 观测在数据库端拒绝超过8MiB的完整样本,预算内仍需读全样本、核验摘要后筛选并保留 synthetic;此限制不能通过缩小查询窗口解决。监测点列表沿用设备目录,不把未登记的合成点冒充实测设备。
## 启用步骤
### Server 分发业务处置 Skill
业务流程源文件位于 `resources/work-order-skills/*/SKILL.md`,随 Server 镜像分发。`get_task_context` 摘要提供 `handling_skill`(适用 workflow、名称、版本和 SHA-256)及 `display_time_zone=Asia/Shanghai`;使用 `section="skill"` 读取完整正文。流程由服务端持久化任务的 workflow 选择,调用方不能传入文件路径或其他任务类型;未知 workflow 返回 `available=false`,不自动套用其他流程。
当前覆盖爆管上报、水质投诉与预约跟进。业务 Skill 描述风险核查、隔离停水、用户通知、现场处置和恢复条件;Agent 本地 Skill 维护通用执行方法。权限、审批和执行仍由 Server 代码控制。流程说明没有 evidence_id,不记录成事件证据。具体草稿由现有 `propose_task_action` 保存并等待人工审批;人工通知等步骤通过关联预约工单承接,不能当作通知已发送或现场工作已完成。
接口复用现有任务绑定、工具权限和授权上下文,不新增 MCP 工具、REST API 或数据库迁移。更新后需让 Server 加载新代码,并重启 Agent 重新获取工具 schema 和指令;不自动重跑既有调查或改写已发布报告。后续单独修改流程正文由 Server 从打包资源读取,版本随正文一起维护,SHA-256 可区分同版本内容变化。
### 2026-10-15 实际运行环境
当前 `conda server` 已升级至 MCP 2.3.0、Pydantic 2.14.1、AnyIO 4.15.1,移除未使用且与 SDK 2 冲突的旧 fastmcp,并将 httpcore 更新至 requirements 中的 1.0.9;`pip check` 通过。旧环境保留为 `server-before-mcp-20261015`,配置、项目库和原生会话数据库的备份保存在私有 `data/mcp-rollout/`。
五个启用项目的业务库均已完成完整备份、归档目录可读检查、025 迁移回滚演练和正式应用。新项目仍须通过项目数据库迁移流程准备 MCP 表,不会在查询请求中自动创建。
后端通过 [tjwater-server.service](../infra/systemd/tjwater-server.service) 运行于 127.0.0.1:8000;Agent 运行于 8787,保留原生会话数据库和用户会话文件。两端使用 `https://keycloak.waternetwork.cn/realms/tjwater`,请求方为 `agent-mcp`,目标 audience 为 `tjwater-mcp`。worker 取令牌地址和前端登录 issuer 也已统一为该域名,避免 HTTP issuer 签发的令牌在 HTTPS 端点交换失败。
真实 Keycloak 及项目验证已覆盖 24 项后端目录、30 项 Agent 原生目录、管网分页、SCADA 设备与读数、分析运行和模型时间选项。读数请求成功但当前验证窗口为空,不代表已有完整实测数据。正式就绪与目录检查不调用模型推理。
以下为新环境的接入步骤。
本地 HTTP 身份服务的显式开发白名单见 [Keycloak 地址配置](mcp-keycloak-setup.md#4-地址数据库和启用顺序)。正式配置使用 HTTPS 域名;开发例外只适用于指定内网 issuer,不放行远程 HTTP MCP resource。
Keycloak 管理员可先按 [MCP 认证客户端配置清单](mcp-keycloak-setup.md) 操作。已有请求方客户端 `agent-mcp` 时直接使用该 ID,并同步 Agent 的 `TJWATER_MCP_CLIENT_ID` 与 Server 的 `TJWATER_MCP_HOST_CLIENT_ID`;下方默认示例中的 `tjwater-agent-mcp` 应相应替换。
1. 为 Server 安装更新后的 requirements。SDK 2 需要新的 Pydantic/AnyIO;升级现有环境前保留完整回退环境,移除没有业务引用且与 SDK 2 冲突的旧 fastmcp 依赖,并执行 `pip check`。不要依赖旧环境生成新版 OpenAPI。
2. 按项目 BizDB 迁移流程执行 `resources/sql/025_mcp_query_contexts.sql`。先备份并检查 workbench schema 已存在;脚本只新增上下文/回执表和索引。每次建立句柄最多清理100个本项目已失效句柄;证据/回执保留遵循任务归档策略。
3. 配置 Keycloak:专用 confidential requester `tjwater-agent-mcp`,启用 Standard Token Exchange V2,设置目标 audience `tjwater-mcp`,配置 `mcp.catalog.read`、`mcp.query`、`mcp.context.bind`。产品前端和 worker 的源 token 需要包含 requester audience;源 token 不应包含目标 MCP audience。交换令牌须保持源主体,azp 为 requester,RS256,寿命至多900秒,建议300秒。目录 client_credentials 只申请 catalog scope,也须带目标 audience;目录服务身份不因此获得项目业务权限。
4. 在 Server 受保护环境设置:
```dotenv
TJWATER_MCP_ENABLED=true
TJWATER_MCP_ISSUER_URI=https://identity.example/realms/water
TJWATER_MCP_RESOURCE_URI=https://server.example/mcp
TJWATER_MCP_AUDIENCE=tjwater-mcp
TJWATER_MCP_HOST_CLIENT_ID=tjwater-agent-mcp
TJWATER_MCP_ALLOWED_HOSTS=["server.example","localhost:*","127.0.0.1:*"]
TJWATER_MCP_ALLOWED_ORIGINS=[]
```
5. 在 Agent 宿主受保护环境设置:
```dotenv
TJWATER_MCP_ENABLED=true
TJWATER_MCP_ISSUER_URI=https://identity.example/realms/water
TJWATER_MCP_RESOURCE_URI=https://server.example/mcp
TJWATER_MCP_AUDIENCE=tjwater-mcp
TJWATER_MCP_CLIENT_ID=tjwater-agent-mcp
TJWATER_MCP_CLIENT_SECRET=<由身份平台注入>
```
安装 Agent 更新的 Bun 依赖。本地 MCP 使用官方 Server SDK,独立监听127.0.0.1临时端口,先于原生工具发现就绪。宿主只把公开端点写入 `SESSION_METADATA_STORAGE_DIR` 的兄弟目录 `mcp-query/connection.json`,目录0700、文件0600;不保存令牌或 client secret。embedded 自动注入连接路径,OpenCode 环境仍剔除全部 `TJWATER_MCP_*`。client 模式须同网络空间、同版本可信插件、同一内部密钥,并共享 `TJWATER_AGENT_MCP_CONNECTION_PATH` 指向的连接文件,Agent 重启后同步重载原生连接。默认路径 `data/mcp-query/connection.json`。不要把 OAuth 配置注入外部 OpenCode。
项目及宿主只读工具统一由 MCP 提供,插件只配置原生连接与权限;额外六项是地理编码、会话搜索、调查任务列表与详情、消息读取、待办读取,合并后共41项。公开网页搜索使用 OpenCode 原生 `websearch`,搜索服务及凭据由 OpenCode 管理;Server 的 Bocha 接口、配置和 MCP 搜索入口已移除。`memory_manager` 和 `skill_manager` 保留完整读写插件,写入、展示工具保持插件形式。旧项目和任务查询工具及其内部 HTTP 查询桥已移除;未启用产品 MCP 时项目数据不可用,不回退。
原生调用的 `_meta["ai.opencode/sessionID"]` 仅用于关联宿主会话,本地服务先核验内部密钥和本机来源,再核验所有权及直接父子关系。原生工具发现、权限、元数据传递与待办 RPC 已由 `bun run tests/integration/query-mcp-smoke.ts` 验证,不调用模型。任务上下文保留可选 `report_revision`,缺省使用协作会话绑定修订号。隔离启动器显式设置自己的 `TJWATER_AGENT_MCP_CONNECTION_PATH`,两端共同读取该文件,不继承其他实例的连接文件。
6. 先用测试项目分别验证普通项目会话、worker 调查和直接 reader 子会话,再开启生产。Agent 启动先发现 Server 目录并与宿主35项白名单对齐,再验证 OpenCode 的真实工具 ID;失败不进入 ready。切换开关需重启 Agent。Server 未迁移项目的绑定接口返回503。
client_credentials 与 token exchange 均发送标准 `resource=TJWATER_MCP_RESOURCE_URI`;exchange 同时保留配置的 audience。部署前必须验证身份平台接受该参数,且两种 grant 都将规范资源映射到 Server 要求的专用 audience。若所用 Keycloak 拒绝该参数,会报告配置不兼容,需升级或提供兼容适配;代码不会移除 resource 后重试。目录的服务 grant 与用户/worker 的 exchange 均不会退回共享管理员 Bearer或直接转发产品 Bearer给MCP。[Keycloak 官方说明](https://www.keycloak.org/securing-apps/token-exchange)。外部交互客户端的授权码流程和第三方客户端绑定不包含在本次交付中。
HTTP 版本校验先核对头体一致性,再报告不支持的版本:不一致返回400/-32020;双方一致且版本不支持才返回400/-32022及支持列表。
## 验证
Server:
```bash
python -m pytest tests/unit/test_mcp_query.py tests/unit/test_timeseries_history.py tests/unit/test_timeseries_history_schema.py tests/api/test_workbench_runtime_endpoints.py tests/api/test_history_query_permissions.py tests/unit/test_operational_data.py tests/auth -q
python scripts/check_openapi.py
```
Agent:
```bash
bun run check
bun test tests/product/mcpAuthDiscovery.test.ts tests/product/mcpQueryClient.test.ts tests/product/credentialRefresh.test.ts tests/runtime/task-agent-policy.test.ts tests/runtime/opencodeConfig.test.ts tests/opencode/mcpPolicy.test.ts tests/opencode/productMcp.test.ts
MCP_TEST_PYTHON=<隔离Server环境Python> MCP_TEST_SERVER_ROOT=<Server仓库绝对路径> bun tests/integration/mcp-query-smoke.ts
```
HTTP smoke 只启动自己的 loopback fixture,使用真实两端 SDK,无生产数据库、Keycloak或模型推理。它验证新版发现、真实资源元数据 HTTP 读取、授权服务器元数据及带 resource 的两种 grant(后两者为桩)、35项工具、主机绑定、查询及撤销;真实环境验收记录见上方实际运行环境一节,不能用 fixture 结果代替。前端严格解析已接受这些 MCP 快照来源,未加入浏览器 MCP 凭据或新 UI。
查询限制回归使用 `python -m pytest tests/unit/test_mcp_query_limits.py tests/unit/test_mcp_query.py tests/unit/test_timeseries_history.py tests/unit/test_timeseries_history_schema.py -q`。实际 PostgreSQL SQL 验证可设置 `MCP_QUERY_TEST_DSN` 后运行 `python -m pytest tests/integration/test_mcp_query_limits_postgres.py -q`;该 DSN 必须指向空的、可丢弃的独立数据库,测试创建临时业务同名 schema 并在事务结束时回滚。它验证数据点跨序列翻页、证据路径/Unicode分页、任务摘要与指定报告、章节稳定路径、项目/任务隔离、既有scope边界、字节门槛和合成摘要,不代替真实项目验收。
+66 -1
View File
@@ -4,6 +4,17 @@
## 调查任务队列
第二轮及后续调查的触发条件、待执行轮次合并、旧运行替代,以及协作与消息的边界,见 [调查轮次与协作边界](operational-tasks.md#调查轮次与协作边界)。
worker 始终只处理 `event_investigation`,外部预约工单不占用模型并发。
执行列表支持可选 `task_type`、`task_created_from` 查询参数;租约恢复请求支持同名字段,
均在数据库分页之前过滤。省略字段保持原接口行为。时间下界使用任务创建时间,
旧任务产生新 run 不会越过边界,不删除历史或修改旧任务状态来缩小执行范围。
本机常驻模板位于 `infra/systemd/tjwater-operational-worker.service`,使用已有 `.env`
凭据与配置。安装到用户 systemd 后启用,避免同时启动第二个 worker。
用户服务未启用 linger 时,不保证退出登录后持续运行。
当前产品分为定时任务、调查任务、预约任务。定时任务保留前端 mock 数据,不调用后端计划创建或到期调度;调查任务读取数据库并进入本工作队列;预约任务读取数据库中的外围工单投影,以外部同步进度为准。已有后端 schedule API 暂不在当前产品主路径使用。
从后端仓库运行:
@@ -22,13 +33,31 @@ conda run -n server python scripts/run_operational_worker.py
| `OPERATIONAL_WORKER_AGENT_URL` | Agent 地址,默认本机 8787 |
| `OPERATIONAL_WORKER_RECONCILE_SECONDS` | SSE 中断或漏通知时的对账间隔,默认 30 秒 |
| `OPERATIONAL_WORKER_MAX_CONCURRENCY` | 同时投递的 run 上限,默认 4,可设置 1 至 32 |
| `OPERATIONAL_WORKER_TASK_CREATED_FROM` | 可选、带时区的任务创建时间下界(包含边界),限制排队、并发计数、凭据刷新和租约恢复;不是 run 创建时间 |
| `OPERATIONAL_WORKER_BEARER` | 已具有该项目权限的产品身份,仅用于受控开发或短期运行 |
| `OPERATIONAL_WORKER_TOKEN_URL` | 可选 OAuth 客户端凭据取令牌地址 |
| `OPERATIONAL_WORKER_DEV_TOKEN_URL` | 仅 `ENVIRONMENT=development` 且非空时覆盖取令牌地址;其他环境忽略,保留正式地址 |
| `OPERATIONAL_WORKER_CLIENT_ID`、`OPERATIONAL_WORKER_CLIENT_SECRET` | 长期运行身份;不配置固定 Bearer 时使用,并按有效期刷新令牌 |
| `OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS` | OAuth 缓存上限,默认 300 秒,可设置 30 至 600 秒;同时受令牌 expires_in 限制,应短于后端允许的最大令牌年龄 |
工作身份必须能通过现有产品身份与项目成员权限校验。配置令牌获取不等于自动创建产品用户、项目成员或授权。权限错误会保留任务用于后续重试,不会绕过认证。
调查数据读取沿用账号已有的项目权限,不再因 `service` 身份额外移除 `scada.view` 和
`simulation.view`。上下文的 `approved_data_scopes` 与数据查询采用同一权限集合;已有读取
权限无需逐任务数据审批或委托凭据。此规则不新增角色权限、不放开跨项目读取,也不改变
工单审批和设备控制权限。旧的显式批准委托路径保留兼容。
工作进程每轮从 Server 的 claimed/running 运行快照读取 agent_session_id,并使用当前 OAuth 凭据向 Agent 的 `POST /api/v1/session/{session_id}/credentials` 更新正在执行的会话凭据。令牌只在 header 中传递,body 为 `{}`,不会进入任务、报告或会话持久化文件。Agent 再次校验会话所属用户和项目;工作进程重启后从 Server 快照恢复关联,不依赖浏览器续期。固定 Bearer 仍受自身有效期限制。
Agent API 统一使用 `/api/v1` 前缀:任务投递为 `POST /api/v1/task-executions`,消息投递为 `POST /api/v1/task-messages/deliver`,凭据续期使用上述会话接口。旧 `/api/v1/agent/chat` 前缀已移除。更新脚本后须重新启动常驻 worker,现有 Python 进程不会自动加载文件变化。本机用户服务使用:
```bash
systemctl --user restart tjwater-operational-worker.service
systemctl --user status tjwater-operational-worker.service --no-pager
```
重启后检查日志中的实际请求路径;HTTP 404 属于不可自动重试的投递失败,已失败的 run 不会因重启自动恢复,需通过原有重新调查流程显式创建新一轮。
长期 OAuth 身份由身份管理员创建支持 `client_credentials` 的专用机密客户端;服务账号令牌应具有后端配置要求的 audience、UUID `sub` 和 `preferred_username`。再由产品管理员把该 `sub` 绑定到启用的产品用户及目标项目成员关系。只赋予本次调查所需项目权限,不授予系统管理员或设备命令权限。当前项目成员策略同时包含工单审批/执行权限,不能将其误称为已经细分的专用最小权限服务角色。
本机联调配置示意(密钥由管理员通过安全配置填入,禁止粘贴到聊天):
@@ -36,7 +65,7 @@ conda run -n server python scripts/run_operational_worker.py
```dotenv
OPERATIONAL_WORKER_PROJECT_ID=324ff7ee-c2d4-5184-a84b-48fb14a2933e
OPERATIONAL_WORKER_SERVER_URL=http://127.0.0.1:8000
OPERATIONAL_WORKER_AGENT_URL=http://127.0.0.1:18787
OPERATIONAL_WORKER_AGENT_URL=http://127.0.0.1:8787
OPERATIONAL_WORKER_TOKEN_URL=https://<identity-host>/realms/<realm>/protocol/openid-connect/token
OPERATIONAL_WORKER_CLIENT_ID=<dedicated-service-client>
OPERATIONAL_WORKER_CLIENT_SECRET=<inject-securely>
@@ -50,8 +79,44 @@ OPERATIONAL_DATA_PERIPHERAL_URL=http://127.0.0.1:8011
Agent 正常结束必须调用 run `complete`。同一执行代次缺少最终计划或报告时,Server 将 run 以 `ARTIFACT_INCOMPLETE` 明确失败,不依赖租约超时猜测完成状态。心跳只增加 run revision,不改变任务业务 revision。
最终报告未提供 Mermaid 图(包括 `diagram=null`)时,Server 将同轮、同会话且不晚于报告发布时刻的最新有效进度图保存到报告中;图的证据引用必须已在报告范围内,节点状态保留记录原值,不把“记录时执行中”改成“已完成”。进度快照显式清空图后,不再从更旧的进度恢复。既有无图最终报告在详情、指定版本和历史列表读取时使用相同兼容规则,不改写历史数据库记录,也不借用新轮次的图。
前端默认跟随最新轮次的进展,新报告发布后自动显示新报告;没有本轮报告时保留并标注上一轮报告。本轮最终报告图优先于过程图。主动选择历史报告或展开阅读报告详情时保留阅读版本,通过“返回实时内容”或“查看最新内容”切换。
## 事件日志与清理
### 普通对话与调查运行的私有消息
启用消息接口前,需要按现有项目数据库发布规则应用
`resources/sql/024_agent_messages.sql`;本次代码修改不自动执行迁移或重启服务。
该表也是调查启动前收信、运行完成/失败和租约恢复的必需依赖,即使没有协作消息,
也必须先迁移再启用调查执行。Server 在领取运行、修改状态前检查消息表;缺失时返回
`503 DEPENDENCY_UNAVAILABLE`,提示具体迁移文件,不将排队运行改成“调查中”。
若旧运行已因缺表卡住,补齐迁移后现有 worker 会按租约恢复规则创建下一代运行;
不要手工改状态或重复提交投诉。应用迁移前须确认允许自动恢复及后续模型调用。
迁移保留每条消息的项目、会话归属、明确的 `task_id`/`run_id`、幂等请求 ID
及固定原生消息 ID。普通对话只能向排队或有效租约内的调查运行发送消息,
调查回复只能沿已存在的消息关系返回原对话。终态运行拒绝新消息,不创建新的运行代次。
`POST /api/v1/agent-messages` 与 `/{message_id}/reply` 要求项目任务写权限;
`GET /api/v1/agent-messages?session_id=...` 按认证用户的对话归属过滤,支持
`cursor`、`limit` 与 `pending_only`。Agent 仍须验证实际会话归属,Server
不把客户端提交的会话 ID 当成会话所有权证明。服务身份读取任务会话消息时,
Server 校验该会话属于当前活动运行。跨用户投递查询与任务回执要求项目工作服务身份。
原对话用户可以确认收到的回复,但不能改写目标会话或原生消息 ID。
既有 worker 每轮最多对账四页、每页 50 条私有待投递消息,即使调查并发已满仍继续对账,
不增加进程或配置项。临时网络错误保留消息等待重试;权限拒绝、不存在等确定性
拒绝保存失败结果。消息正文、发送人和会话归属不写入项目事件日志。
待投递接口按创建时间与消息 ID 返回 keyset 游标,worker 跨轮保留扫描位置;
扫到末页后下一轮从头重试。长期空闲对话的待收消息不会一直占住首屏、阻止后续调查投递。
`pending` 表示等待投递,`enqueued` 表示运行时已接受,`delivered` 表示消息已进入
模型上下文;这三者都不能作为业务事项已经处理完成的证明。Agent 应在确认进入
上下文后保存回执。同一运行仍有 `pending`/`enqueued` 入站消息时,`complete`
返回结构化 `PENDING_AGENT_MESSAGES` 409,Agent 需接收后重试完成。取消、租约
超时、失败或被新代次替代会将旧运行未投递的入站消息明确标记为失败。
`resources/sql/015_workbench_events_and_task_runs.sql` 在项目 BizDB 中增加 `domain_event_log` 和 `operational_task_runs`。后续迁移默认只应用到 `tjwater_v2`,扩展规则见 [`workbench-database-rollout.md`](workbench-database-rollout.md)。事件与业务投影在同一事务提交,`LISTEN/NOTIFY` 只负责唤醒;浏览器与 worker 始终按持久游标补读。每个 API 进程对活跃项目最多保留一条 LISTEN 连接,无订阅者 60 秒后关闭。
事件默认保留 7 天。清理脚本会从 Metadata DB 解析项目路由,并在发现多个项目共享同一 BizDB 时拒绝运行。先确认 `OPERATIONAL_WORKER_PROJECT_ID` 对应 `tjwater_v2`,然后运行定向 dry-run:
+54 -1
View File
@@ -12,7 +12,7 @@
调查计划由 Agent 按本次工单或人工请求动态生成。新计划使用 `execution-plan@2`,节点声明执行步骤、条件判断、并行分叉/汇合和审核点;判断节点记录所选路线,审核点引用持久化申请或动作。重新分析追加计划修订和新节点,不使用环形连线。旧 `execution-plan@1` 快照保留原样读取。字段、约束与示例见 [`dynamic-investigation-plan.md`](dynamic-investigation-plan.md)。
数据读取申请通过 `POST /operational-tasks/{task_id}/access-requests` 创建,`POST .../access-requests/{access_request_id}/decision` 由具备项目审核权限及对应数据源读取权限的用户决定。申请、批准范围和审核身份保存在任务库中,批准或拒绝后重新排队分析。调查接口不会将服务身份继承的项目数据读取权限直接授予 Agent;批准的范围也只在该任务的有效执行期可查询。需配置 `OPERATIONAL_DATA_DELEGATION_BEARER` 为具备对应来源读取权限及该项目成员身份的专用产品凭证。未配置时返回 503,不以普通 Agent 会话的工具许可代替任务授权。
调查数据读取沿用账号已有的项目权限,已有来源读取权限不需要逐任务审批或委托凭据。显式数据审批路径保留兼容:通过 `POST /operational-tasks/{task_id}/access-requests` 创建申请,`POST .../access-requests/{access_request_id}/decision` 由具备项目审核权限及对应数据源读取权限的用户决定。申请、批准范围和审核身份保存在任务库中,批准或拒绝后重新排队分析。兼容委托路径使用 `OPERATIONAL_DATA_DELEGATION_BEARER`,不能用普通 Agent 会话的工具许可代替任务授权。账号权限、委托和工作身份边界见 [工作进程文档](operational-task-workers.md)。
工单草稿可通过 `PATCH .../actions/{action_id}/draft` 修改并增加审计版本;`POST .../actions/{action_id}/confirm-dispatch` 使用一次人工操作批准当前版本并幂等提交,结果不明时继续使用回执核对。其他动作类型维持既有审批接口,不在调查页伪装为已支持的工单编辑动作。
@@ -26,6 +26,7 @@
| `GET /operational-tasks` | 按类型、状态分页读取任务 |
| `GET /operational-tasks/{task_id}` | 读取当前计划、报告、证据摘要、审批动作和当前用户权限 |
| `GET /operational-tasks/{task_id}/agent-context` | 读取 Agent 任务上下文及当前身份允许的数据源 |
| `POST /operational-tasks/{task_id}/retry` | 无活动运行时,为原任务创建新一轮执行 |
| `POST /operational-tasks/{task_id}/runs/{run_id}/claim` | 使用当前执行请求号领取 queued run |
| `POST /operational-tasks/{task_id}/runs/{run_id}/heartbeat` | 续期当前 Agent run,不递增任务业务 revision |
| `POST /operational-tasks/{task_id}/runs/{run_id}/complete` | 校验同代最终产物并显式结束 run |
@@ -46,6 +47,58 @@
报告采用不可变修订,正文只保存当前结论、结论边界、建议、证据引用和待确认事项。详细的 Agent 工具轨迹和模型内部推理不进入报告。系统数据只能通过受控 `data-query` 形成证据,外围反馈和人工补充也有各自入口;Agent 不能自行声明这三类证据。合成演练证据始终标记为 `synthetic`。
## 调查轮次与协作边界
本节按 2026-10-13 工作区代码核对,区分已实现行为和待接入功能,不代表运行环境已完成部署。
### 第二轮调查的触发条件
下列业务入口会要求重新分析。只有服务端接受并保存请求后才改变任务,聊天文字本身不构成触发条件。
| 入口 | 触发条件 | 当前处理 |
| --- | --- | --- |
| 人工补充证据 | 协作 Agent 调用 `add_task_human_evidence`,通过 `human-evidence` 保存用户明确提供或确认的事实 | 保存 `human_input` 证据并重新排队;假设、查询要求和 Agent 推断不能当作现场事实 |
| 外围工单反馈 | `external-feedback` 接受原事件的新版本,或符合关联校验的现场工单新反馈 | 重新排队;重复版本不重复触发,子工单创建回执不作为已结束调查的新现场反馈 |
| 动作审核 | `decide_action` 接受人工驳回 | 重新排队,让调查重新判断处置方案;仅批准不直接触发 |
| 动作执行与回执核对 | `submit_action` 或 `reconcile_action` 得到 `succeeded`、`failed`、`unsupported` | 重新排队;`submitted`、`unknown` 不走这一终态触发分支,后续由回执或外部反馈推进 |
| 数据授权审批 | 兼容的显式审批路径接受批准或拒绝 | 按可用范围重新排队;已有项目数据读取权限不需要逐任务审批,见工作进程文档 |
| 显式重新调查 | 调用 `POST /operational-tasks/{task_id}/retry`,且不存在活动运行 | 为原任务创建下一轮;后端接口已实现,主 Agent 的受控工具入口尚未接入 |
| 租约超时恢复 | 工作进程发现执行租约过期 | 将旧运行记为 `timed_out`,创建下一轮;连续三轮超时后任务失败,停止自动重试 |
业务重新排队统一经过 `OperationalTaskService._requeue_task`,显式重试和租约恢复分别由 `retry_task`、`recover_expired_leases` 处理。具体实现见 [任务服务](../app/services/operational_tasks.py)。
### 轮次合并、替代与执行
- 原任务的 `task_id` 保持不变,历史报告修订保留。创建下一轮时,`execution_generation` 加一,并分配新的 `run_id` 和对应的 `execution_request_id`;报告版本号不是调查轮次。
- 业务重新排队时,如果已有 `queued` 运行,补充信息合并到该待执行轮次,不增加轮次或另建运行。
- 如果已有领取中或执行中的运行,业务重新排队会将其标记为 `cancelled`,失败码为 `SUPERSEDED_BY_REQUEUE`,再创建下一轮。旧轮次未投递消息会失败,不自动转投新轮次;旧执行也不能覆盖新轮次结果。
- 显式 `retry` 与上述合并逻辑不同:存在活动运行时返回 `ACTIVE_RUN_EXISTS`,不会替代或再建一轮。
- 同一幂等请求的重放不重复创建轮次。外部反馈还按来源版本去重,不能把重复提交或重复通知当作新反馈。
- 保存为 `queued` 只表示已排队。后台工作进程领取并交给 Agent 执行后才算开始调查,浏览器不负责启动或维持执行。新一轮结束时追加报告,不改写旧报告。
### 协作对话和运行消息
任务页的“Agent 协作”打开或复用独立的 `task_collaboration` 会话。它不是后台 `task_execution` 会话,也不等于主对话向调查运行发送消息。已结束的任务仍可打开协作,讨论报告和证据,不因执行结束而禁用入口。
点击按钮只打开对话、显示引用并进入编辑状态,保留已有草稿,不自动发送。普通问题读取授权任务上下文及所选报告后回答,不改变轮次。查看报告、刷新页面、更新 Mermaid 或报告修订本身也不会创建下一轮。
主 Agent 的 `send_task_message` 必须指定核实过的 `task_id` 和 `run_id`,只接受 `queued` 或具有有效租约的 `claimed`、`running` 运行。新消息发往已结束轮次时返回 `409 RUN_TERMINAL`,不会唤醒旧执行、创建新轮次或改投最新轮次。消息属于讨论输入,不自动保存为人工证据。投递与回执规则见 [工作进程的私有消息说明](operational-task-workers.md#普通对话与调查运行的私有消息)。
### 前端引用与说明
任务页保留单一“Agent 协作”入口,不直接展示 `task_id`、`run_id`,但底层仍保留任务和会话绑定。对话引用区只显示任务名称、所选报告的轮次和版本,以及证据或缺口引用说明。轮次取自所选报告快照,旧报告缺少轮次时省略,不拿任务当前轮次补齐。
引用区不显示实时执行状态,不以缓存的“调查中”或“已结束”决定消息能否投递。发送时由后端重新校验目标和状态,历史报告引用不随任务进入新轮次而改变。
- 按钮提示:打开调查协作对话,引用此任务,不会直接发送。
- 输入框提示:可询问调查进展、讨论报告或补充信息。
### 待接入功能
目前保存人工证据会直接重新排队,没有单独的重跑确认步骤。后续拟在保存前明确提示“保存此信息将启动新一轮调查”;若已有待执行轮次,则应说明会合并到该轮次,不能把确认步骤描述为已经实现。
主 Agent 尚不能通过普通消息重启已结束任务。后续受控的重新调查工具应在用户明确要求后读取最新任务,校验项目权限和活动运行,再为原任务创建新轮次并关联补充要求。目标是保留原 `task_id`、返回新 `run_id` 并等待后台领取,不复活旧运行,不把普通提问隐式转换成重跑。
## 人工确认与执行真实性
动作分为 `create_work_order` 和 `issue_command`,审批状态与执行状态分别保存。每次状态变化都会写入不可变动作版本;首次提交生成固定 `execution_request_id`,后续回执核对不改变它。