소스 검색

feat:fwh-agent-mcp一个端口按路径加载多个工具集,写工具集挂在/mcp/ops

- -set 支持逗号分隔、省略时加载全部;每个工具集挂在 /mcp/<工具集>,只读工具集另挂 /mcp 兼容已登记的地址
- 各工具集独立的 MCPServer、并发名额、TaskRouter;令牌可用 AGENT_MCP_TOKEN_<工具集大写> 单独配置,未配置时用 AGENT_MCP_TOKEN
- systemd 单元改为 -set=readonly,ops,不再需要 8092 端口与第二个服务
- 部署文档、设计文档、配置示例同步更新;新增路由、令牌隔离与 ParseSets 测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 일 전
부모
커밋
1e56688372

+ 20 - 13
deploy/agent-assistant/SETUP.md

@@ -7,8 +7,8 @@
 | 组件 | 地址 | 说明 |
 | --- | --- | --- |
 | AgentServer | `http://127.0.0.1:8787`,后台 `/admin/` | systemd `agent-server`,用户 `agentsvc` |
-| fwh-agent-mcp(只读) | `http://127.0.0.1:8091/mcp` | systemd `fwh-agent-mcp` |
-| fwh-agent-mcp(写,二期) | `http://127.0.0.1:8092/mcp` | 可选,见第 1.4 节 |
+| fwh-agent-mcp(只读) | `http://127.0.0.1:8091/mcp/readonly` | systemd `fwh-agent-mcp`;旧地址 `/mcp` 仍可用 |
+| fwh-agent-mcp(写,二期) | `http://127.0.0.1:8091/mcp/ops` | 同一进程、同一端口,见第 1.4 节 |
 | ToolBox 后端 | `127.0.0.1:8301`,路由前缀 `/api` | systemd `fwh-toolbox-server` |
 | 对外入口 | Caddy → Apache `:8300` → `/fwh-toolbox/api` → `:8301/api` | 见第 7 节 |
 
@@ -65,18 +65,25 @@ curl -s http://127.0.0.1:8091/healthz                # ok
 journalctl -u fwh-agent-mcp -n 30 --no-pager         # 启动日志;工具调用日志在 server/logs/agent-mcp.log
 ```
 
-带令牌列出工具(应为 28 个):
+带令牌列出工具(只读 `/mcp/readonly` 应为 45 个,写 `/mcp/ops` 应为 7 个):
 
 ```bash
 T=$(grep -oP '^AGENT_MCP_TOKEN="?\K[0-9a-f]+' /var/www/fwh-toolbox/server/config.agent-mcp.env)
-curl -s -X POST http://127.0.0.1:8091/mcp -H "Authorization: Bearer $T" \
-  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | grep -o '"name":"[a-z_]*"' | wc -l
+for p in readonly ops; do
+  printf '%s: ' $p
+  curl -s -X POST http://127.0.0.1:8091/mcp/$p -H "Authorization: Bearer $T" \
+    -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
+    -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | grep -o '"name":"[a-z_]*"' | wc -l
+done
 ```
 
+`/mcp/ops` 配置了单独令牌(见第 1.4 节)时,测试它要用那个令牌。
+
 ### 1.4 写工具服务(二期,可选)
 
-复制一份 unit 为 `fwh-agent-mcp-ops.service`,启动参数改为 `-set=ops -listen=127.0.0.1:8092`。默认与只读服务共用 `config.agent-mcp.env`(令牌相同);需要单独的令牌时复制一份配置,加 `-config=./config.agent-mcp-ops.env` 指定。
+写工具集与只读工具集由同一个 `fwh-agent-mcp` 进程提供(unit 中 `-set=readonly,ops`),路径 `/mcp/ops`,不需要另外部署服务或端口。只想提供只读工具时,把 unit 改为 `-set=readonly`。
+
+默认两个路径共用 `AGENT_MCP_TOKEN`。需要写工具集单独的令牌时,在 `config.agent-mcp.env` 中加 `AGENT_MCP_TOKEN_OPS="<openssl rand -hex 32 生成>"` 后重启服务,AgentServer 中 `fwhops` 服务的请求头填这个令牌。
 
 ## 2. 在 AgentServer 登记 MCP 服务
 
@@ -87,12 +94,12 @@ curl -s -X POST http://127.0.0.1:8091/mcp -H "Authorization: Bearer $T" \
 | 传输方式 | 远程 HTTP | 远程 HTTP |
 | 显示名称 | FWH 业务数据 | FWH 业务操作 |
 | 服务名(工具名前缀) | `fwh` | `fwhops` |
-| 服务地址 | `http://127.0.0.1:8091/mcp` | `http://127.0.0.1:8092/mcp` |
+| 服务地址 | `http://127.0.0.1:8091/mcp/readonly` | `http://127.0.0.1:8091/mcp/ops` |
 | 请求头名称 | `Authorization` | `Authorization` |
-| 请求头值 | `Bearer <令牌>` | 同左 |
+| 请求头值 | `Bearer <令牌>` | 同左;配置了 `AGENT_MCP_TOKEN_OPS` 时用它 |
 
 - **服务名必须用 `fwh` / `fwhops`**:技能中的工具名是 `mcp__gateway__fwh__*`。
-- **服务地址末尾是 `/mcp`,不带斜杠**:`.../mcp/` 或不带 `/mcp` 都返回 404。
+- **服务地址末尾不带斜杠**:`.../mcp/readonly/` 或只写 `.../mcp/` 都返回 404。已经登记为 `http://127.0.0.1:8091/mcp` 的只读服务仍然可用,不必修改。
 - **请求头值必须带 `Bearer ` 前缀**(`Bearer`、一个空格、64 位十六进制令牌),不带引号。用下面的命令直接得到完整的值,整行复制:
 
   ```bash
@@ -101,7 +108,7 @@ curl -s -X POST http://127.0.0.1:8091/mcp -H "Authorization: Bearer $T" \
 
 - 编辑已保存的服务时,请求头值输入框留空表示保持原值;原值填错时要重新填写。
 
-点“测试连接”,能列出 28 个工具后保存。只读服务的工具策略保持“直接调用”;写服务保持“跟随权限模式”。连接失败见第 9 节。
+点“测试连接”,只读服务能列出 45 个工具、写服务 7 个后保存。只读服务的工具策略保持“直接调用”;写服务保持“跟随权限模式”。连接失败见第 9 节。
 
 ## 3. 同步资料库
 
@@ -283,10 +290,10 @@ AgentServer 默认单文件上限 50MB;ToolBox 后端代理与 Apache 不应
 | 报错 | 原因 | 处理 |
 | --- | --- | --- |
 | 连接失败 / `ECONNREFUSED` | fwh-agent-mcp 没有运行 | `systemctl status fwh-agent-mcp`;未安装时执行第 1.2 节;启动失败看 `journalctl -u fwh-agent-mcp -n 30` |
-| `Error POSTing to endpoint: 404 page not found` | 服务地址路径不对 | 地址必须是 `http://127.0.0.1:8091/mcp`:末尾不带 `/`,不能省略 `/mcp` |
+| `Error POSTing to endpoint: 404 page not found` | 服务地址路径不对,或该工具集没有加载 | 地址必须是 `http://127.0.0.1:8091/mcp/readonly` 或 `/mcp/ops`,末尾不带 `/`;写服务 404 时检查 unit 的 `-set` 是否包含 `ops`(启动日志“加载工具集”) |
 | `{"jsonrpc":"2.0","error":{"code":-32001,"message":"unauthorized"},"id":null}` | 请求头令牌不对 | 最常见是**值少了 `Bearer ` 前缀**;其次是复制了引号、请求头名称不是 `Authorization`、编辑时留空保留了旧的错误值。按第 2 节的命令重新生成完整的值填写 |
 
-自检:在服务器上执行第 1.3 节的 `curl` 命令。本机能列出 28 个工具而后台仍失败,问题在后台填写的地址或请求头。
+自检:在服务器上执行第 1.3 节的 `curl` 命令。本机能列出工具而后台仍失败,问题在后台填写的地址或请求头。
 
 ### 9.2 fwh-agent-mcp 启动即退出
 

+ 5 - 2
deploy/config.agent-mcp.env.example

@@ -13,9 +13,12 @@
 # ---------- fwh-agent-mcp ----------
 # TODO: [必填] Bearer 令牌,与 AgentServer 后台 MCP 服务 fwh 的请求头一致(openssl rand -hex 32)
 AGENT_MCP_TOKEN="CHANGE_ME"
-# 监听地址;命令行 -listen 优先(systemd 单元中已指定)
+# 可选:为单个工具集指定单独的令牌(AGENT_MCP_TOKEN_<工具集大写>),未配置时用 AGENT_MCP_TOKEN。
+# 例如写工具集 /mcp/ops 用单独令牌,AgentServer 中 fwhops 服务的请求头填它
+# AGENT_MCP_TOKEN_OPS="CHANGE_ME"
+# 监听地址;命令行 -listen 优先(systemd 单元中已指定)。所有工具集共用这一个端口,按路径 /mcp/<工具集> 区分
 AGENT_MCP_LISTEN="127.0.0.1:8091"
-# 全局并发上限,保护 FWH 云平台与数据库
+# 每个工具集的并发上限(各自计数),保护 FWH 云平台与数据库
 AGENT_MCP_MAX_CONCURRENCY=8
 # 日志文件;目录不可写时输出到 stderr
 AGENT_MCP_LOG_PATH="/var/www/fwh-toolbox/server/logs/agent-mcp.log"

+ 3 - 2
deploy/systemd/fwh-agent-mcp.service

@@ -1,5 +1,5 @@
 [Unit]
-Description=fwh-agent-mcp(新 AI 助手业务数据 MCP 服务,只读工具集)
+Description=fwh-agent-mcp(新 AI 助手业务数据 MCP 服务:只读 /mcp/readonly、写 /mcp/ops)
 After=network-online.target fwh-toolbox-server.service
 Wants=network-online.target
 
@@ -7,7 +7,8 @@ Wants=network-online.target
 Type=simple
 WorkingDirectory=/var/www/fwh-toolbox/server
 # 读取同目录的独立配置 config.agent-mcp.env(不读主服务的 config.env),示例见 deploy/config.agent-mcp.env.example
-ExecStart=/var/www/fwh-toolbox/server/fwh-agent-mcp -set=readonly -listen=127.0.0.1:8091
+# 一个端口加载多个工具集,各挂在 /mcp/<工具集>;只读工具集另挂 /mcp 兼容旧地址
+ExecStart=/var/www/fwh-toolbox/server/fwh-agent-mcp -set=readonly,ops -listen=127.0.0.1:8091
 Restart=on-failure
 RestartSec=5
 

+ 13 - 7
docs/ai-assistant-agentserver/agent-mcp.md

@@ -58,13 +58,19 @@ service/
   utils/pii/                     从 cmd/mcp/tools/helper 的 pii_*.go 拷贝为独立包(旧 MCP 不改)
 ```
 
-同一个二进制,用参数选择工具集:
+一个进程、一个端口加载多个工具集,按路径区分(`-set` 逗号分隔,省略时加载全部):
 
 ```
-fwh-agent-mcp -set=readonly -listen=127.0.0.1:8091   # 只读服务
-fwh-agent-mcp -set=ops      -listen=127.0.0.1:8092   # 写服务(二期)
+fwh-agent-mcp -set=readonly,ops -listen=127.0.0.1:8091
 ```
 
+| 路径 | 工具集 | AgentServer 服务名 |
+| --- | --- | --- |
+| `/mcp/readonly`(以及兼容旧地址的 `/mcp`) | 只读 | `fwh` |
+| `/mcp/ops` | 写 | `fwhops` |
+
+每个工具集有自己的 MCPServer、并发名额与令牌(`AGENT_MCP_TOKEN_<工具集大写>`,未配置时用 `AGENT_MCP_TOKEN`),只共用监听端口。哪些 Agent 能用哪个工具集由 AgentServer 的 Profile 勾选决定,不靠端口隔离。以后新增工具集只是多一条路径,不再占用端口。
+
 ### 3.2 工具定义
 
 使用 mcp-go v1.4(`go.mod` 当前版本)的结构体生成参数 schema(`mcp.WithInputSchema[T]`)和类型化处理函数(`mcp.NewTypedToolHandler`),参数校验交给 `server.WithInputSchemaValidation()`:
@@ -269,8 +275,8 @@ ADR-0021 的结果处理(`mcp-gateway/server.ts` 的 `postProcess`)目前:
 
 ### 6.1 Agent MCP
 
-- systemd 单元 `fwh-agent-mcp.service`:`fwh-agent-mcp -set=readonly -listen=127.0.0.1:8091`,工作目录与主服务相同。
-- 独立配置文件 `config.agent-mcp.env`(`-config` 可指定,示例 `deploy/config.agent-mcp.env.example`):`AGENT_MCP_TOKEN`(Bearer 令牌)、`AGENT_MCP_MAX_CONCURRENCY`、`AGENT_MCP_LOG_PATH`,以及工具用到的数据库、Redis、FWH 生产/测试 API、Plane Web。不读主服务的 `config.env` 与旧 MCP 的 `config.mcp.env`,只放自己需要的凭证;文件缺失时拒绝启动。
+- systemd 单元 `fwh-agent-mcp.service`:`fwh-agent-mcp -set=readonly,ops -listen=127.0.0.1:8091`,工作目录与主服务相同。
+- 独立配置文件 `config.agent-mcp.env`(`-config` 可指定,示例 `deploy/config.agent-mcp.env.example`):`AGENT_MCP_TOKEN`(Bearer 令牌)、`AGENT_MCP_TOKEN_<工具集大写>`(可选,单个工具集的令牌)、`AGENT_MCP_MAX_CONCURRENCY`(每个工具集各自计数)、`AGENT_MCP_LOG_PATH`,以及工具用到的数据库、Redis、FWH 生产/测试 API、Plane Web。不读主服务的 `config.env` 与旧 MCP 的 `config.mcp.env`,只放自己需要的凭证;文件缺失时拒绝启动。
 - 部署脚本:新增 `service/cmd/deploy_agent_mcp.sh`(构建、拷贝、重启),不改动旧 MCP 的 `deploy_mcp.sh`。
 - AgentServer 与 ToolBox 不在同一台机器时,监听内网地址,防火墙只放行 AgentServer 主机(见 requirements Q-01)。
 
@@ -282,7 +288,7 @@ ADR-0021 的结果处理(`mcp-gateway/server.ts` 的 `postProcess`)目前:
 | --- | --- |
 | 名称 | `fwh` |
 | 标题 | FWH 业务数据 |
-| 传输 | 远程 HTTP,`http://127.0.0.1:8091/mcp` |
+| 传输 | 远程 HTTP,`http://127.0.0.1:8091/mcp/readonly`(旧地址 `/mcp` 仍可用) |
 | 请求头 | `Authorization: Bearer <AGENT_MCP_TOKEN>` |
 | 工具策略 | 全部只读,默认“直接调用” |
 
@@ -290,7 +296,7 @@ Profile `fwh-assistant` 勾选 `fwh`。工具使用的业务说明(字段含
 
 ## 7. 写工具(二期)
 
-写工具单独部署为第二个 MCP 服务 `fwhops`(`-set=ops`),只勾选进需要它的 Profile(例如只给运维角色用的 Profile)。工具策略保持“跟随权限模式”,Profile 的权限模式为“需审批”,每次调用都弹审批卡片。
+写工具集与只读工具集在同一进程、同一端口,路径 `/mcp/ops`(3.1),在 AgentServer 中登记为第二个 MCP 服务 `fwhops`,只勾选进需要它的 Profile(例如只给运维角色用的 Profile)。工具策略保持“跟随权限模式”,Profile 的权限模式为“需审批”,每次调用都弹审批卡片。
 
 候选:
 

+ 1 - 1
docs/ai-assistant-agentserver/technical-design.md

@@ -154,7 +154,7 @@ const client = new AgentClient({
 
 | 项 | 配置 |
 | --- | --- |
-| MCP 服务 | `fwh`:远程 HTTP,`http://127.0.0.1:8091/mcp`,请求头 `Authorization: Bearer <AGENT_MCP_TOKEN>`;二期 `fwhops` |
+| MCP 服务 | `fwh`:远程 HTTP,`http://127.0.0.1:8091/mcp/readonly`,请求头 `Authorization: Bearer <AGENT_MCP_TOKEN>`;二期 `fwhops`:同一端口 `http://127.0.0.1:8091/mcp/ops` |
 | 技能 | 新写 `fwh-assistant`(第 6 节),源码在 ToolBox `deploy/agent-assistant/skills/fwh-assistant/`。AgentServer 暂无自定义技能入口,需求见 AgentServer 仓库 `docs/design/12-custom-skills.md`;实现后作为技能源导入 |
 | 资料库 | `fwh-wiki`:`/srv/agent-server/knowledge/fwh-wiki`(手动运行同步脚本从 FranklinWH-Wiki `client/docs` 拷贝,排除 `.vitepress`、`public`、`casdoor`;home 下的路径受 ProtectHome 限制,不能直接引用);`fwh-data-knowledge`:同样方式同步 `docs/knowledge` |
 | Profile `fwh-assistant` | 引擎 Claude;权限模式“需审批”;技能 `report-conventions`、`custom-skills:fwh-assistant`(AgentServer 支持后);资料库 `fwh-wiki`、`fwh-data-knowledge`(有内容后);MCP 服务 `fwh`;文件访问 restricted;按需开放 WebSearch / WebFetch |

+ 25 - 4
service/agentmcp/bootstrap.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
 
 	"github.com/hibiken/asynq"
 	"github.com/spf13/viper"
@@ -29,10 +30,11 @@ const DefaultConfigFile = "./config.agent-mcp.env"
 
 // Settings fwh-agent-mcp 自身的运行配置(AGENT_MCP_*)
 type Settings struct {
-	Token          string // AGENT_MCP_TOKEN:Bearer 令牌,必填
-	Listen         string // AGENT_MCP_LISTEN:默认 127.0.0.1:8091
-	MaxConcurrency int    // AGENT_MCP_MAX_CONCURRENCY:默认 8
-	LogPath        string // AGENT_MCP_LOG_PATH:默认 /var/log/fwh-agent-mcp/agent-mcp.log
+	Token          string            // AGENT_MCP_TOKEN:Bearer 令牌,必填
+	SetTokens      map[string]string // AGENT_MCP_TOKEN_<工具集大写>(如 AGENT_MCP_TOKEN_OPS):该工具集单独的令牌,可选,未配置时用 Token
+	Listen         string            // AGENT_MCP_LISTEN:默认 127.0.0.1:8091
+	MaxConcurrency int               // AGENT_MCP_MAX_CONCURRENCY:默认 8
+	LogPath        string            // AGENT_MCP_LOG_PATH:默认 /var/log/fwh-agent-mcp/agent-mcp.log
 }
 
 // LoadSettings 从 configFile 读取 AGENT_MCP_*,环境变量优先。
@@ -47,14 +49,33 @@ func LoadSettings(configFile string) (Settings, error) {
 		return Settings{}, fmt.Errorf("读取配置文件 %s 失败: %w", configFile, err)
 	}
 	v.AutomaticEnv()
+	setTokens := map[string]string{}
+	for _, set := range AllSets {
+		if t := strings.TrimSpace(v.GetString(setTokenKey(set))); t != "" {
+			setTokens[set] = t
+		}
+	}
 	return Settings{
 		Token:          v.GetString("AGENT_MCP_TOKEN"),
+		SetTokens:      setTokens,
 		Listen:         v.GetString("AGENT_MCP_LISTEN"),
 		MaxConcurrency: v.GetInt("AGENT_MCP_MAX_CONCURRENCY"),
 		LogPath:        v.GetString("AGENT_MCP_LOG_PATH"),
 	}, nil
 }
 
+func setTokenKey(set string) string {
+	return "AGENT_MCP_TOKEN_" + strings.ToUpper(set)
+}
+
+// TokenFor 返回工具集使用的令牌:AGENT_MCP_TOKEN_<工具集> 优先,否则 AGENT_MCP_TOKEN。
+func (s Settings) TokenFor(set string) string {
+	if t := s.SetTokens[set]; t != "" {
+		return t
+	}
+	return s.Token
+}
+
 // InitLogger 初始化日志:写入独立的日志文件;目录不可写时退回 stderr。
 // 同时设置 zap 全局 logger 与 logger 包变量(database 等模块直接引用 logger.Sugar)。
 func InitLogger(path string) *zap.Logger {

+ 24 - 8
service/agentmcp/http.go

@@ -14,15 +14,31 @@ import (
 	"fwh-toolbox-server/agentmcp/kit"
 )
 
-// Handler 返回 HTTP 处理器:/mcp(Streamable HTTP,无状态,需 Bearer 令牌)与 /healthz。
-// tasks 为持久化长任务工具:任务协议请求由 kit.TaskRouter 在 mcp-go 之前处理(任务 id 以访问令牌签名)。
-func Handler(s *server.MCPServer, token string, tasks []kit.TaskTool, log *zap.Logger) http.Handler {
-	mcpHandler := server.NewStreamableHTTPServer(s,
-		server.WithStateLess(true),
-		server.WithEndpointPath("/mcp"),
-	)
+// Endpoint 一个工具集的 MCP 端点。
+type Endpoint struct {
+	Set    string            // 工具集,决定路径 EndpointPath(Set)
+	Server *server.MCPServer // 注册了该工具集的 MCPServer
+	Tasks  []kit.TaskTool    // 其中的持久化长任务工具
+	Token  string            // 该端点的 Bearer 令牌(同时用于签名任务 id)
+}
+
+// Handler 返回 HTTP 处理器:每个工具集挂在 /mcp/<工具集>(Streamable HTTP,无状态,需 Bearer 令牌),只读工具集另挂 /mcp 兼容旧地址;另有 /healthz。
+// 各端点的 MCPServer、并发名额、令牌相互独立,只共用监听端口。
+// 任务协议请求由 kit.TaskRouter 在 mcp-go 之前处理(任务 id 以端点令牌签名)。
+func Handler(endpoints []Endpoint, log *zap.Logger) http.Handler {
 	mux := http.NewServeMux()
-	mux.Handle("/mcp", bearerAuth(token, kit.NewTaskRouter(mcpHandler, tasks, token, log)))
+	for _, ep := range endpoints {
+		path := EndpointPath(ep.Set)
+		mcpHandler := server.NewStreamableHTTPServer(ep.Server,
+			server.WithStateLess(true),
+			server.WithEndpointPath(path),
+		)
+		h := bearerAuth(ep.Token, kit.NewTaskRouter(mcpHandler, ep.Tasks, ep.Token, log))
+		mux.Handle(path, h)
+		if ep.Set == SetReadOnly {
+			mux.Handle("/mcp", h)
+		}
+	}
 	mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
 		w.WriteHeader(http.StatusOK)
 		_, _ = w.Write([]byte("ok"))

+ 124 - 0
service/agentmcp/http_test.go

@@ -0,0 +1,124 @@
+package agentmcp
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"regexp"
+	"strings"
+	"testing"
+
+	"go.uber.org/zap"
+)
+
+var toolNameRE = regexp.MustCompile(`"name":"([a-z_]+)"`)
+
+// listTools 以 tools/list 请求 path,返回状态码与工具名集合。
+func listTools(t *testing.T, h http.Handler, path, token string) (int, map[string]bool) {
+	t.Helper()
+	req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
+	req.Host = "127.0.0.1:8091"
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Accept", "application/json, text/event-stream")
+	req.Header.Set("Authorization", "Bearer "+token)
+	rec := httptest.NewRecorder()
+	h.ServeHTTP(rec, req)
+	names := map[string]bool{}
+	for _, m := range toolNameRE.FindAllStringSubmatch(rec.Body.String(), -1) {
+		names[m[1]] = true
+	}
+	return rec.Code, names
+}
+
+// 一个端口按路径区分工具集:各路径只暴露自己的工具,令牌各自校验;/mcp 兼容为只读工具集。
+func TestHandlerRoutesSetsByPath(t *testing.T) {
+	log := zap.NewNop()
+	var endpoints []Endpoint
+	tokens := map[string]string{SetReadOnly: "tok-ro", SetOps: "tok-ops"}
+	for _, set := range AllSets {
+		s, tasks, err := NewServer(set, log, 2)
+		if err != nil {
+			t.Fatal(err)
+		}
+		endpoints = append(endpoints, Endpoint{Set: set, Server: s, Tasks: tasks, Token: tokens[set]})
+	}
+	h := Handler(endpoints, log)
+
+	roCount := len(mustTools(t, SetReadOnly))
+	opsCount := len(mustTools(t, SetOps))
+
+	for _, path := range []string{"/mcp", "/mcp/readonly"} {
+		code, names := listTools(t, h, path, "tok-ro")
+		if code != http.StatusOK || len(names) != roCount || !names["site_search"] || names["site_info_refresh"] {
+			t.Errorf("%s: code=%d 工具数=%d,期望 200 与只读工具集 %d 个", path, code, len(names), roCount)
+		}
+	}
+
+	code, names := listTools(t, h, "/mcp/ops", "tok-ops")
+	if code != http.StatusOK || len(names) != opsCount || !names["site_info_refresh"] || names["site_search"] {
+		t.Errorf("/mcp/ops: code=%d 工具数=%d,期望 200 与写工具集 %d 个", code, len(names), opsCount)
+	}
+
+	if code, _ := listTools(t, h, "/mcp/ops", "tok-ro"); code != http.StatusUnauthorized {
+		t.Errorf("/mcp/ops 用只读令牌应返回 401,实际 %d", code)
+	}
+	if code, _ := listTools(t, h, "/mcp/readonly", "tok-ops"); code != http.StatusUnauthorized {
+		t.Errorf("/mcp/readonly 用写令牌应返回 401,实际 %d", code)
+	}
+	if code, _ := listTools(t, h, "/mcp/unknown", "tok-ro"); code != http.StatusNotFound {
+		t.Errorf("/mcp/unknown 应返回 404,实际 %d", code)
+	}
+}
+
+// 只加载只读工具集时不暴露 /mcp/ops。
+func TestHandlerOnlyMountsLoadedSets(t *testing.T) {
+	log := zap.NewNop()
+	s, tasks, err := NewServer(SetReadOnly, log, 2)
+	if err != nil {
+		t.Fatal(err)
+	}
+	h := Handler([]Endpoint{{Set: SetReadOnly, Server: s, Tasks: tasks, Token: "tok"}}, log)
+	if code, _ := listTools(t, h, "/mcp/ops", "tok"); code != http.StatusNotFound {
+		t.Errorf("未加载 ops 时 /mcp/ops 应返回 404,实际 %d", code)
+	}
+}
+
+func TestParseSets(t *testing.T) {
+	cases := []struct {
+		in      string
+		want    []string
+		wantErr bool
+	}{
+		{"", AllSets, false},
+		{"readonly", []string{SetReadOnly}, false},
+		{" ops , readonly ,ops", []string{SetOps, SetReadOnly}, false},
+		{"readonly,foo", nil, true},
+		{" , ", nil, true},
+	}
+	for _, c := range cases {
+		got, err := ParseSets(c.in)
+		if (err != nil) != c.wantErr || (!c.wantErr && !reflect.DeepEqual(got, c.want)) {
+			t.Errorf("ParseSets(%q) = %v, %v;期望 %v, err=%v", c.in, got, err, c.want, c.wantErr)
+		}
+	}
+}
+
+func TestTokenFor(t *testing.T) {
+	s := Settings{Token: "base", SetTokens: map[string]string{SetOps: "ops-only"}}
+	if s.TokenFor(SetReadOnly) != "base" || s.TokenFor(SetOps) != "ops-only" {
+		t.Errorf("TokenFor 结果不符:readonly=%s ops=%s", s.TokenFor(SetReadOnly), s.TokenFor(SetOps))
+	}
+}
+
+func mustTools(t *testing.T, set string) []string {
+	t.Helper()
+	list, err := Tools(set)
+	if err != nil {
+		t.Fatal(err)
+	}
+	names := make([]string, 0, len(list))
+	for _, r := range list {
+		names = append(names, r.Meta().Name)
+	}
+	return names
+}

+ 34 - 1
service/agentmcp/server.go

@@ -6,6 +6,7 @@ package agentmcp
 
 import (
 	"fmt"
+	"strings"
 
 	"github.com/mark3labs/mcp-go/server"
 	"go.uber.org/zap"
@@ -22,6 +23,9 @@ const (
 	SetOps      = "ops"
 )
 
+// AllSets 全部工具集。一个进程可同时加载多个,各自挂在 EndpointPath(set) 上,共用一个端口。
+var AllSets = []string{SetReadOnly, SetOps}
+
 // Tools 返回指定工具集。
 func Tools(set string) ([]kit.Registrar, error) {
 	switch set {
@@ -30,7 +34,36 @@ func Tools(set string) ([]kit.Registrar, error) {
 	case SetOps:
 		return tools.Ops(), nil
 	}
-	return nil, fmt.Errorf("未知的工具集:%s(可选 readonly / ops)", set)
+	return nil, fmt.Errorf("未知的工具集:%s(可选 %s)", set, strings.Join(AllSets, " / "))
+}
+
+// EndpointPath 工具集的 MCP 路径:/mcp/<工具集>。只读工具集另外挂在 /mcp 上,兼容已登记的地址。
+func EndpointPath(set string) string {
+	return "/mcp/" + set
+}
+
+// ParseSets 解析逗号分隔的工具集列表(去空白、去重,保持顺序);空串表示全部。
+func ParseSets(s string) ([]string, error) {
+	if strings.TrimSpace(s) == "" {
+		return append([]string(nil), AllSets...), nil
+	}
+	var sets []string
+	seen := map[string]bool{}
+	for _, part := range strings.Split(s, ",") {
+		set := strings.TrimSpace(part)
+		if set == "" || seen[set] {
+			continue
+		}
+		if _, err := Tools(set); err != nil {
+			return nil, err
+		}
+		seen[set] = true
+		sets = append(sets, set)
+	}
+	if len(sets) == 0 {
+		return nil, fmt.Errorf("未指定工具集")
+	}
+	return sets, nil
 }
 
 // NewServer 创建 MCPServer 并注册工具集;同时返回其中的持久化长任务工具(交给 Handler 的 TaskRouter 处理任务协议)。

+ 27 - 9
service/cmd/agent-mcp/main.go

@@ -1,9 +1,12 @@
 // fwh-agent-mcp:新 AI 助手的业务数据 MCP 服务(Streamable HTTP)。
 //
-//	fwh-agent-mcp -set=readonly -listen=127.0.0.1:8091   # 只读工具集
-//	fwh-agent-mcp -set=ops      -listen=127.0.0.1:8092   # 写工具集
+//	fwh-agent-mcp -set=readonly,ops -listen=127.0.0.1:8091
 //
-// 配置读取独立的 ./config.agent-mcp.env(-config 可指定),不读主服务的 config.env;AGENT_MCP_TOKEN 必填。
+// 一个进程、一个端口加载多个工具集,每个工具集挂在 /mcp/<工具集>(只读工具集另挂 /mcp 兼容旧地址);
+// -set 省略时加载全部工具集。
+//
+// 配置读取独立的 ./config.agent-mcp.env(-config 可指定),不读主服务的 config.env;AGENT_MCP_TOKEN 必填,
+// AGENT_MCP_TOKEN_<工具集大写>(如 AGENT_MCP_TOKEN_OPS)可为单个工具集指定单独的令牌。
 package main
 
 import (
@@ -12,6 +15,7 @@ import (
 	"fmt"
 	"os"
 	"os/signal"
+	"strings"
 	"syscall"
 
 	"go.uber.org/zap"
@@ -20,11 +24,17 @@ import (
 )
 
 func main() {
-	set := flag.String("set", agentmcp.SetReadOnly, "工具集:readonly | ops")
+	setFlag := flag.String("set", strings.Join(agentmcp.AllSets, ","), "工具集,逗号分隔:"+strings.Join(agentmcp.AllSets, ","))
 	listen := flag.String("listen", "", "监听地址,默认取 AGENT_MCP_LISTEN(127.0.0.1:8091)")
 	configFile := flag.String("config", agentmcp.DefaultConfigFile, "配置文件路径")
 	flag.Parse()
 
+	sets, err := agentmcp.ParseSets(*setFlag)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "[fwh-agent-mcp] -set 无效:%v\n", err)
+		os.Exit(1)
+	}
+
 	// 配置文件缺失时直接退出:config.LoadConfig 读不到文件会静默使用默认值,连错库也不易察觉
 	settings, err := agentmcp.LoadSettings(*configFile)
 	if err != nil {
@@ -47,16 +57,24 @@ func main() {
 		log.Fatal("[agent-mcp] 初始化失败", zap.Error(err))
 	}
 
-	s, tasks, err := agentmcp.NewServer(*set, log, settings.MaxConcurrency)
-	if err != nil {
-		log.Fatal("[agent-mcp] 创建 MCP Server 失败", zap.Error(err))
+	endpoints := make([]agentmcp.Endpoint, 0, len(sets))
+	for _, set := range sets {
+		s, tasks, err := agentmcp.NewServer(set, log, settings.MaxConcurrency)
+		if err != nil {
+			log.Fatal("[agent-mcp] 创建 MCP Server 失败", zap.String("set", set), zap.Error(err))
+		}
+		endpoints = append(endpoints, agentmcp.Endpoint{Set: set, Server: s, Tasks: tasks, Token: settings.TokenFor(set)})
+		log.Info("[agent-mcp] 加载工具集",
+			zap.String("set", set),
+			zap.String("path", agentmcp.EndpointPath(set)),
+			zap.Bool("ownToken", settings.SetTokens[set] != ""))
 	}
 
 	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
 	defer stop()
 
-	log.Info("[agent-mcp] 启动", zap.String("set", *set), zap.String("version", agentmcp.ServerVersion), zap.Int("maxConcurrency", settings.MaxConcurrency))
-	if err := agentmcp.Serve(ctx, addr, agentmcp.Handler(s, settings.Token, tasks, log), log); err != nil {
+	log.Info("[agent-mcp] 启动", zap.Strings("sets", sets), zap.String("version", agentmcp.ServerVersion), zap.Int("maxConcurrency", settings.MaxConcurrency))
+	if err := agentmcp.Serve(ctx, addr, agentmcp.Handler(endpoints, log), log); err != nil {
 		log.Fatal("[agent-mcp] HTTP 服务异常退出", zap.Error(err))
 	}
 	log.Info("[agent-mcp] 已退出")