jiang 75d8d526f9 feat(agent): add external MCP client support
Reuse OpenCode 2 HTTP/stdio clients with per-session read-only grants,
native approval and sanitized owner-scoped MCP status. Narrow the
environment inherited by trusted local MCP processes.

Validated in an isolated checkout with bun run check and 20 focused tests.
2026-10-08 18:36:07 +08:00

TJWater 供水 Agent 实验服务

基于 OpenCode 的实验性供水 Agent 服务,用于编排 TJWater 供水管网前端交互。排水数据、排水分析流程和排水前端交互不在本仓库范围内。

本仓库与生产环境中的 TJWaterAgent 历史代码相互独立。浏览器身份和项目范围由 TJWater Server 验证,业务访问使用类型化工具。

当前边界

  • 生产请求必须携带 Authorization: Bearer ... 和 X-Project-Id,Server 验证身份及项目权限。开发环境允许无凭据的本地对话,但不能访问 Server 业务工具。
  • 不包含 cli/、tjwater_cli OpenCode 工具或自由形式的 CLI 命令桥接。
  • Agent 的结构化界面输出使用 agent-ui@1 UIEnvelope 事件;受控浏览器操作使用 frontend-action@1 前端动作。
  • Server 访问通过有限类型化工具;web_search 和 geocode 使用现有 Server 搜索、地理编码接口。Shell 仅在经过审批的会话 OS 沙箱中执行本地文件计算。

主要 API

GET    /health
GET    /api/v1/agent/chat/models
GET    /api/v1/agent/chat/ui-registry
GET    /api/v1/agent/chat/frontend-action-registry
POST   /api/v1/agent/chat/frontend-actions/:action_id/result
POST   /api/v1/agent/chat/session
POST   /api/v1/agent/chat/task-executions
GET    /api/v1/agent/chat/sessions
GET    /api/v1/agent/chat/session/:session_id
GET    /api/v1/agent/chat/session/:session_id/mcp
GET    /api/v1/agent/chat/session/:session_id/stream
POST   /api/v1/agent/chat/stream
POST   /api/v1/agent/chat/abort

工况 worker 通过 POST /api/v1/agent/chat/task-executions 提交 Server 已持久化的 task_id、run_id 和 execution_request_id。三者缺一即拒绝;Agent 不从进程内 Map 推断当前执行代次。Agent claim 成功后每 20 秒对该 run 心跳,正常流结束显式调用 complete,失败或中止显式调用 fail。计划、报告和完成命令均携带同一 run 身份,Server 是活动 run 与租约状态的唯一权威。

聊天 UIMessage 流与工作台业务事件流相互独立;业务状态由 Server 的 /api/v1/workbench/events 推送,不能混入 token SSE。

POST /api/v1/agent/chat/stream 通过 SSE 返回事件流。主要事件如下:

事件 用途
progress Agent 规划和工具执行进度
token 助手回复的增量文本
ui_envelope 供可信前端渲染的结构化界面描述
frontend_action 请求可信前端执行受控动作
tool_call 用于兼容和调试的旧版工具调用事件
permission_request 运行时权限请求
question_request 运行时向用户发起的澄清请求
todo_update Agent 待办事项更新
session_title 会话标题更新
done 本次运行完成
error 本次运行失败或被中止

本地上下文

服务通过 src/context/localContext.ts 提供本地上下文,默认值如下:

AGENT_LOCAL_USER_ID=local-user
AGENT_LOCAL_PROJECT_ID=local-project
AGENT_LOCAL_NETWORK=local-network

供水业务数据只来自用户输入、经过校验的事件上下文或已启用的受控工具。web_search、geocode 和 query_project_data 已开放,通过当前会话的可信凭据访问 Server;不接受模型指定的 URL、鉴权头或项目范围。get_project_context、list_scada_assets 和 query_scada_readings 仍未开放。

实验期间可以通过以下请求头覆盖本地上下文:

X-Agent-Local-User
X-Agent-Local-Project
X-Agent-Local-Network
X-Trace-Id

UIEnvelope 与前端动作

UIEnvelope 注册表通过以下接口提供:

GET /api/v1/agent/chat/ui-registry

该接口用于兼容 agent-ui@1 协议。目前没有注册图表语法、界面组件或 UIEnvelope 动作。

前端动作使用独立的注册表:

GET /api/v1/agent/chat/frontend-action-registry

当前提供 render_scada_analysis 和 clear_scada_analysis 两个地图叠加层动作。浏览器需要在流式请求中声明 frontend-action@1 能力,执行动作后再通过以下接口回传结果:

POST /api/v1/agent/chat/frontend-actions/:action_id/result

协议详情参见 Agent UI 协议。

本地开发

bun install
bun run dev
bun run check
bun test tests/**/*.test.ts

bun run check 会检查服务代码和 .opencode 工具的类型。

工况任务工具通过本机内部桥访问 Agent,再由 Agent 使用当前请求的短期产品凭据调用 TJWater Server。embedded 模式会把启动时生成的内部密钥传给子进程;client 模式必须在 Agent 与外部 OpenCode 服务中配置相同的 AGENT_INTERNAL_TOKEN 后分别重启。缺少共享密钥时 Agent 会拒绝启动,工具桥也不会退化为仅凭 session ID 访问。

独立管理 Agent 与 OpenCode 时必须显式配置稳定的 AGENT_INTERNAL_TOKEN;如果 bun --watch 使用自动生成的密钥重启 Agent,仍在运行的 OpenCode 子进程会保留旧密钥,内部工具桥将拒绝请求。需要保留现有开发服务做隔离联调时,可运行 bun run start:integration:isolated。它默认使用 Agent 18787、OpenCode 14096,从当前仓库目录加载 opencode.json、.opencode/ 与现有 provider 环境,并把 Agent Session、memory、result ref、Skill 及 OpenCode XDG 运行数据写入 data/integration-18787/,不会复用默认运行实例的数据目录。可用 INTEGRATION_AGENT_PORT、INTEGRATION_OPENCODE_PORT、INTEGRATION_DATA_DIR 覆盖这些隔离值;启动器不会输出内部密钥。

CI/CD

Gitea Actions 通过 .gitea/workflows/package.yml 复用 OrgTJWater/ci-templates 的容器发布流程。手动触发只构建镜像并在 Dev 服务器运行候选验证;从 main 创建并推送 v* tag 后,候选验证通过才会 提升为正式 agent 服务。

仓库需要配置 REGISTRY_USERNAME、REGISTRY_PASSWORD 和 DEV_DEPLOY_SSH_KEY 三个 Gitea Secrets。.env、持久化数据和日志不会写入镜像;它们由 Dev 服务器通过环境文件和卷挂载。

OpenCode 2 接入

运行时使用 @opencode/client@2.0.24,私有进程默认启动 opencode2 serve。 Ubuntu 安装:bun add -g --trust @opencode/cli@2.0.24,然后检查 opencode2 --version。 OPENCODE_COMMAND 可指定绝对路径。Client 模式除共享 AGENT_INTERNAL_TOKEN 外,还需 用 OPENCODE_CLIENT_PASSWORD 提供外部 V2 Server 的密码;嵌入模式自动捕获密码,不写日志。

业务工具由 .opencode/plugins/tjwater-tools.ts 的 V2 插件显式注册,直接提供给模型, 保留业务输入验证及前端动作的原生 call ID。启动前检查版本、模型、工具完整性;缺失即失败。 @opencode-ai/sdk 仅为现有产品 SSE 契约提供开发期类型,不参与 V2 请求或进程启动。

工具变化:bash → shell,task → subagent,apply_patch → patch。 V2 内置目录不含 todo、todoread、todowrite;本项目插件恢复会话内持久化的 todoread、todowrite,并将更新继续转换为 todo_update。问答改用 Session Form API, 权限回复使用 session ID;文本、推理、工具与错误事件在运行时转换为现有前端 SSE。 已移除 V2 忽略的 server 和 experimental.continue_loop_on_deny 配置,权限转换为 有序 permissions 规则,保留敏感路径静态拒绝、shell/edit 审批和禁用子代理。

官方依据:迁移指南、 插件、工具、 客户端。旧 dev.opencode.ai beta 文档及 @opencode-ai/*@next 标签不可作为当前稳定版安装依据。

旧 Agent 的 P0/P1 能力迁移

  • memory_manager 和 skill_manager 经内部令牌保护的 HTTP 桥接调用主服务,共用主服务的会话上下文和存储。身份与项目范围不由模型传入;新增记忆仍须先读取同 scope。
  • 交互会话收到 credential_refresh_required 后,前端续期并向 POST /api/v1/agent/chat/session/:session_id/credentials 提交 request_id;令牌只在认证 header 中传递,不写入会话文件。工具请求最多重试一次,403 不触发续期。续期超时或新凭据再次被拒绝时停止本次执行。
  • 后台 worker 使用已有 OAuth provider,依据 Server 的 claimed/running 运行快照向上述接口更新凭据,body 为 {}。后台运行不等待浏览器。若凭据已失效而无法写回失败,Server 的租约恢复仍负责终结失效运行。
  • 普通会话通过 query_project_data 读取有限数据源:供水元素属性、SCADA 设备与历史、元素历史、分析运行及分页结果。历史窗口最长24小时;历史查询接口按 scada.view 只读权限校验,数据清洗仍需 scada.clean;SCADA 字段只接受 monitored_value/cleaned_value,物理指标和单位以设备元数据为准。任务会话仍使用任务专属查询工具。
  • locate_features、zoom_to_map、show_chart、view_history 使用 frontend-action@1,等待实际浏览器回执。地图缺失资产返回 missing_ids;历史缺数不生成假曲线。图表仅接收有限、对齐的数值序列。历史展示当前最多100个非空样本,不代表完整高频历史。
  • 业务阶段以 progress 快照保存,保留并行操作和错误;任务中的分支、等待和证据仍由 execution-plan@2 表达。final_answer 用于最终答复呈现,在运行成功结束后发布;不强制结束模型循环,也不代表工单或业务任务完成。任务运行仍由 Server 的 complete/fail 接口确认。

Shell 已迁移为 Landlock + seccomp 内核沙箱,替换 OpenCode 2 自带的 shell 工具;其他宿主 Shell 创建入口关闭。Code Mode 嵌套调用也不能恢复原执行器。此边界覆盖 Shell 与子进程,OpenCode 服务和类型化工具仍在可信服务进程运行。参考 OpenCode V2 插件接口 和 权限文档。

  • Ubuntu/Linux 必须支持 Landlock ABI >= 4,并安装 Python 3、libseccomp2。当前 WSL Ubuntu 实测 ABI 7。启动时实际执行一次限制网络的探测;失败则不监听 HTTP,不回退到宿主 Shell。/health 的 sandbox 显示探测结果。Docker 已加入所需库和执行脚本,宿主内核也必须支持 Landlock。
  • CONVERSATION_WORKSPACE_ROOT 默认 ./.agent-workspaces,与受保护的 data/、logs/ 分开。每个会话使用唯一 conversation-* 目录,Native Session Location 保存绑定;恢复后继续使用原目录,旧的 checkout 会话在继续执行前迁入新目录。新分支使用独立空目录,不复制源码、凭据或其他会话文件。
  • 容器部署时须将 /app/.agent-workspaces 挂载到持久卷,并保留 OpenCode 的会话数据库卷。仅挂载 /app/data 不能保留新会话文件,例如 Compose 增加 ./agent-workspaces:/app/.agent-workspaces。此次迁移未修改本地忽略的 Compose 配置或远端部署。
  • 根目录只读,可写文件放在 files/、tmp/、.cache/,避免模型生成的插件或配置被 OpenCode 自动加载。原生文件工具也检查规范路径、符号链接和会话范围,保留 .env、data、logs 静态拒绝与编辑审批。
  • root 容器中的 Shell 降为 UID/GID 10001;普通 Ubuntu 用户运行时保留本身 UID,由 Landlock/seccomp 提供限制。环境变量只保留明确的基础运行白名单,模型、用户和内部服务凭据不进入沙箱。系统程序、Python 环境和 Skill 目录只读,网络 socket、进程检查/发信号及脱离进程组被拒绝。
  • Shell 的 timeout 单位是秒,默认 120,最大 1800。stdout 默认限 1 MiB(SANDBOX_MAX_OUTPUT_BYTES),stderr 最多 256 KiB,单文件写入最多 128 MiB。较长 stdout/stderr 保存在本会话 files/,响应返回 3000 字节预览和 stdout_file/stderr_file 路径,避免落到无法读取的 OpenCode 全局截断文件。超时、取消、输出超限及命令结束均清理该进程组,不提供持久后台进程。会话文件保留在配置根目录,当前未增加自动文件保留期限或附件下载接口。
  • Shell 和文件修改即使在 approval_mode=always 也会请求用户确认;常规业务工具沿用自动批准。配置中的 deny 继续生效。

web_search 对接 POST /api/v1/web-searches(Bocha),支持 query、五种 freshness、summary、1–50 条结果及域名 include/exclude。geocode 对接 POST /api/v1/geocoding-requests(天地图),校验成功状态和坐标范围,并返回 center=[lon,lat]、source_crs=EPSG:4326。坐标可直接用于 zoom_to_map。两个工具使用现有凭据续期机制,403 不触发续期;服务密钥只配置在 Server 的 BOCHA_API_KEY 和 TIANDITU_GEOCODER_TOKEN 中。

client 模式的外部 OpenCode 需要使用 serviceOpencodeConfig 加载可信插件包 .opencode/tjwater-plugin/ 和 Agent 指令,并设置与 Agent 相同的绝对 TJWATER_AGENT_WORKSPACE_ROOT(等于 CONVERSATION_WORKSPACE_ROOT)、内部桥地址和密钥。隔离联调启动器已完成这些设置。2.0.24 的显式插件配置要求目录包;冷 Location 的模型发现必须等插件初始化完成。

P2 的大结果文件中转、供水瓶颈和服务区算法未迁入。

外部 MCP 客户端

供水 Agent 复用 OpenCode 2.0.24 的 MCP 客户端,支持远程 Streamable HTTP、管理员配置的本地 stdio、原生 OAuth、工具目录更新和取消调用。当前 MCP 正式协议为 2026-07-28,配置依据见 OpenCode MCP 文档。本次接入不发布 TJWater MCP 服务,也不增加 MCP Apps 或 Tasks 的前端界面。

仓库中的 mcp.servers 默认是 {}。管理员在服务的 opencode.json 或 OPENCODE_CONFIG_CONTENT 中配置连接;模型和浏览器不能提交 MCP 地址、进程命令或认证头。所有服务名必须使用 mcp_ 前缀,其余字符限英文字母、数字、下划线和连字符。下面是配置片段,示例默认禁用;替换为实际地址/程序后再启用:

{
  "mcp": {
    "timeout": { "startup": 10000, "catalog": 10000, "execution": 60000 },
    "servers": {
      "mcp_docs": {
        "type": "remote",
        "url": "https://mcp.example.com/mcp",
        "protocol": "auto",
        "oauth": false,
        "headers": { "Authorization": "Bearer {env:MCP_DOCS_TOKEN}" },
        "disabled": true
      },
      "mcp_company": {
        "type": "local",
        "command": ["node", "/opt/company-mcp/server.js"],
        "cwd": "/opt/company-mcp",
        "environment": { "MCP_API_KEY": "{env:COMPANY_MCP_API_KEY}" },
        "protocol": "legacy",
        "disabled": true
      }
    }
  }
}

远程默认 protocol: "auto",探测新协议并兼容旧协议;本地默认 legacy,避免对旧 stdio 程序额外启动探测进程。只服务新协议时可显式设为 "2026-07-28"。codemode 沿用原生默认值 true,设为 false 可直接暴露工具;两种入口执行相同的会话策略。超时单位为毫秒,全局默认启动/目录各 10 秒、执行 60 秒,已有显式全局值和每个服务的覆盖值优先。

只有管理员核实为只读的工具才自动放行:在已有 permissions 数组的 mcp_* 审批规则后追加精确工具名的 allow,保留原有敏感路径拒绝规则。例如:

{ "action": "mcp_docs_search", "resource": "*", "effect": "allow" }

工具的有效名称为 <server>_<tool>,不支持的字符由 OpenCode 规范为 _,以状态接口实际返回的名称为准。宽泛的 mcp_* allow、服务自报的 readOnlyHint、历史“始终允许”都不构成只读白名单。后续匹配的拒绝规则继续生效。普通聊天和任务协作中,白名单外调用进入现有 permission_request 审批,即使 approval_mode=always 也不会自动批准;后台 task_execution 只能调用白名单工具,学习复盘 review 禁止 MCP 调用,不等待浏览器审批。原生通用 MCP 资源列举/读取入口也受限制:交互会话审批,后台和复盘会话禁止,不能绕过逐个工具的白名单。外部结果是检索资料,不能直接替代 Server 已保存的业务证据或改变调用权限。

GET /api/v1/agent/chat/session/:session_id/mcp 沿用聊天接口的 Bearer、X-Project-Id 与会话归属校验。它返回 servers,每项仅含 name、type、status、configured_protocol、tools。状态为 connected/pending/disabled/failed/needs_auth;configured_protocol 是配置模式,不是协商后的版本。响应不包含连接地址、命令、认证头、OAuth 凭据或原始错误;查询不会创建或迁移会话目录。插件通过内部令牌保护的 /internal/mcp/session-policy 获取主服务已有的会话策略,不能凭请求 body 伪造用户或项目。

需要 OAuth 的服务应省略 oauth: false。管理员用同一服务账号、配置、环境和 XDG 数据目录执行 opencode2 mcp auth mcp_docs,认证流程、PKCE 与令牌续期由 OpenCode 管理;本项目不向前端暴露 OAuth 凭据,也不把 TJWater 的 Bearer 或项目头自动转交给外部 MCP。原生客户端发送的 OpenCode session ID 仅供调用关联,不是授权凭据。

本地 stdio 是管理员信任的宿主进程,不在会话 Shell 的 Landlock/seccomp 沙箱中。插件将继承环境中除基础运行变量以外的值置空,只添加该服务明确配置的环境变量,内部 Agent/OpenCode 凭据变量始终置空;它仍具有服务账号的宿主文件和网络权限,因此环境收敛不等于 OS 沙箱。

配置结构错误会使启动失败;可选服务的连接失败或 needs_auth 不使核心 Agent 的 /health 变为未就绪。client 模式须同步更新可信插件和服务配置,缺少新版状态 RPC 将不能通过 warmup。禁用或移除服务并重启运行时即可回退,无需数据迁移。

本次定向验证:bun run check、bun test tests/runtime/mcpConfig.test.ts tests/opencode/mcpPolicy.test.ts tests/routes/mcpRoutes.test.ts tests/runtime/opencode.test.ts tests/frontendAction/callIdPlugin.test.ts。可选的本地原生联调为 bun run tests/integration/mcp-smoke.ts:使用临时 XDG 目录和样例 HTTP/stdio 服务,结束后清理自己的进程与数据,不请求模型。

S
Description
No description provided
Readme
1.2 MiB
Languages
TypeScript 98.8%
Python 0.9%
Dockerfile 0.3%