Files
xiaoqu_mcp_service/xiaoqu-agent-service
..

xiaoqu-agent-service

面向 MCP 客户端(Kimi Code / Claude 等)的小趣业务数据服务:独立 Spring Boot 应用,只读查询现有三库(m0 权限库 / m1 商城库 / m2 业务库),以 MCP(Streamable HTTP)形式对外暴露工具。

设计方案见 ../xiaoqu-franchisee/doc/agent-mcp-service-design.md。当前进度:P4 管理后台完成(P1 骨架 + P2 权限层 + P3 首批工具 + P4 Vue 管理后台)。

技术栈与已验证版本组合

组件 版本
JDK 17(Temurin 17.0.20.1)
Spring Boot 3.5.16
Spring AI(MCP server webmvc) 1.1.8
MyBatis-Plus 3.5.12(mybatis-plus-spring-boot3-starter)
MySQL 驱动 mysql-connector-j(Boot 管理)
Redis spring-boot-starter-data-redis(db5)
管理后台 Vue 3 + Element Plus + Vite 7

⚠️ 版本组合说明:Spring Boot 3.5.x + Spring AI 1.0.0 的 streamable 传输存在 NPE(spring-projects/spring-ai#6274),本工程选用已修复的 Spring AI 1.1.8,2026-09-03 实测 STREAMABLE 握手(initialize → tools/list → tools/call)通过。

工程结构

xiaoqu-agent-service/
├── mvnw / .mvn/wrapper/              # Maven Wrapper(本机无全局 mvn)
├── pom.xml
├── sql/
│   ├── README.md                     # migration 编写、回滚与不可变规则
│   ├── bootstrap/create-database.sql # 首次创建数据库(结构不在这里维护)
│   ├── rollback/                     # 与每个版本配套的人工回滚方案
│   └── seed-dev.sql                  # dev 种子数据,不进入 Flyway
├── front/                            # Vue 3 管理后台,Maven 打包时自动构建进 jar
├── src/main/java/me/iiv/agent/
│   ├── AgentServiceApplication.java
│   ├── config/                       # M0/M1/M2/SvcDataSourceConfig(独立 SqlSessionFactory + MapperScan)、McpToolConfig
│   ├── mcp/                          # ToolObject 标记、@XqTool 元数据、ToolSyncService(启动同步 tool 表,只插不删)
│   │   ├── GuardedToolCallback(Provider)  # 统一包装:scope 强制校验 + 审计
│   │   └── tool/                     # @Tool 工具(SysTools: sys.ping;AgentTools: agent.count / agent.list.query)
│   ├── security/                     # AkAuthFilter(/mcp ak→401/AuthContext)、AuthService(ak 解析+用户加载)、ToolGuard
│   ├── permission/                   # PermissionService:行级数据权限引擎(移植 getByIsAgent)
│   ├── common/                       # AuditService(audit_log 落库)
│   ├── admin/                        # 后台登录、会话/CSRF、Token/工具/作用域/审计 API
│   ├── entity/m0、m2、svc  mapper/m0、m1、m2、svc   # codegen 生成物(提交进仓库)
├── src/main/resources/
│   ├── application.yml               # MCP server: protocol=STREAMABLE, type=SYNC,端点 /mcp
│   ├── db/migration/                 # Flyway V1/V2... + 历史文件校验和清单
│   └── application-dev.yml           # 四数据源 + Redis,账号密码必须由环境变量提供
└── src/test/java/me/iiv/agent/       # 权限回归测试 + run-once 代码生成器

数据源分包约定:entity/m0|m1|m2|svc 与 mapper/... 一一对应各自的 SqlSessionFactory(@MapperScan + sqlSessionFactoryRef)。m0/m1/m2 只读,svc(自有库)读写。

鉴权(P2)

  • /mcp 全路径要求 Authorization: Bearer <ak>,缺失/无效/禁用/过期 → 401 JSON。
  • 判定链(设计文档 §3.2):ak → SHA-256 hex → agent_api_key 匹配 → 解析出人(Redis db5 entity:User:{id} 热缓存优先,fastjson camelCase JSON 用 Jackson 解析;读不到回源 m0 user 表)→ AuthContext 存 ThreadLocal → 工具调用前 scope 校验 → 审计落库。
  • ThreadLocal 实测结论:spring.ai.mcp.server.type=SYNC(application.yml 已显式设置)时,Spring AI 1.1.8 的工具回调在 servlet 请求线程(nio-8088-exec-*)上同步执行,ThreadLocal 的 AuthContext 与 RequestContextHolder 均有效(已用真实 curl 验证:agent.list.query 按人过滤、audit_log 正确落 user_id/token_id/client_ip)。AkAuthFilter 在请求结束 finally 清理。
  • tools/list 按作用域过滤(2026-09-04 起):MCP SDK 的工具清单在启动时静态注册,无法按请求过滤,故 ToolsListScopeFilter 在 servlet 层拦截 POST /mcp 的 tools/list 响应并重写 result.tools(只保留该 ak 作用域并集内、且启用状态的工具)。tools/call 路径不变:越权调用仍返回可读拒绝信息并落 denied 审计。
  • client_type 识别:X-Client-Type 头优先,否则 User-Agent 含 kimi→mcp-kimi / claude→mcp-claude / curl→cli,其余 unknown。
  • 统一输出规范(REQ-20260911-0011 起):schema_version 双跑——tools/call 默认(v1)返回原有 content text 不变;请求头 X-Mcp-Schema-Version: 2 时 McpToolCallEnvelopeFilter(@Order(15))在 servlet 层缓冲响应并写入 result.structuredContent 信封(schema_version/request_id/data|error),版本值非法直接返回 SCHEMA_VERSION_UNSUPPORTED。所有工具异常在 GuardedToolCallback 收敛为 8 个统一错误码(ToolCallException,message 即 {"error":{code,message,request_id}} JSON),裸异常细节脱敏后只落服务端日志。tools/list 重写时同步注入只读/幂等/非破坏性 annotations(ToolKindRegistry,源自 @XqTool.kind),v2 请求额外注入信封 outputSchema。审计与日志不记录 ak、手机号脱敏(MaskingUtil)。

行级数据权限引擎(permission/PermissionService)

移植老系统 WrapperUtils.getByIsAgent 语义,输出中性 id 集合,调用方拼 wrapper:

方法 老系统来源 语义
isAgentAuthority(authorityId) AuthorityServiceImpl.checkAuthorityIsagent isagent=1 → 代理商;isagent=0 → 总部;缺失/非法配置拒绝访问
visibleAgentIds(user) getByIsAgent 主重载 isagent!=1 返回 null(不过滤);否则 自己 + 直属下级(supervisor_id 一层)+ m2 warehouse_agent_auth 代管 manager_agent_id
isSalesNonLeader(user) saleFlag 判定(:703-710) Redis db5 Dictionary hash 的 authDeptId 命中 ",deptId," 且 authDeptPostId 不含 "postId,";字典缺失时拒绝访问
applyPropertyRowFilter 销售分支 saleFlag 分支(:716-735,互斥 return) 1:1 复刻(2026-09-03 决策):id IN (create_id=自己 ∪ [代管授权非空时] agent_id IN (下级+代管移除自己) 的物业),空集合则 id IN (0);命中后不再叠加 agent_id/代管物业 OR 结构
isPropertyUser(user) enterprise_type=2 规则(§3.3 通用规则,保留备用) 物业用户;property.list.query 不使用本规则(物业用户不查此列表,与老 /property/listByPage 一致,2026-09-03 决策)

agent.list.query 的行权限照搬老系统 AgentController.listByPage:isagent=1 默认只查直属下级;传 id 时只能精确查询自己或直属下级;isagent=0 可按任意合法 id 查询。

MCP 工具

工具名 分类 读写 说明
sys.ping system read 服务自检:服务名、版本、当前时间、Redis PING
agent.count system read 查 m0 库 agent 表 soft_delete=0 总数
agent.list.query customer read 代理商分页查询。入参 id/name/level/channelNo/province/city/serialnumber/page/rows;出参 id/name/level/channelNo/serialnumber/supervisorName/province/city/countyseat/contact/phone/agencyarea/memo/createTime。行权限:isagent=1 只查直属下级,id 精确查可看自身。supervisorName 批量 IN 富化
property.list.query customer read 物业(客户)分页查询(m0 properyt 表)。入参 name/agentId/serialnumber/province/city/contact/phone/page/rows;出参 id/name/serialnumber/agentName/createName/province/city/countyseat/address/contact/phone/createTime。行权限走 PermissionService 完整版 getByIsAgent 语义(不含 enterprise_type=2 限制,与老 /property/listByPage 一致)。agentName/createName 批量 IN 富化
integration.context.get integration read 身份与能力自检(REQ-20260911-0012)。无入参;返回 tenant_id(作用域编码映射)/principal_type/subject_id/scopes(可调用工具集合,与强制校验同源实时计算)/data_scope_version(token_scope 绑定最大修改时间 epoch 毫秒)/capability_levels(QUERY/SYNC,按 *.changes.list 数据驱动)/server_time
warehouse.in.list.query warehouse read 入库单分页查询;入库/申请日期独立过滤,主单代理、涉及仓库、流程参与人三类权限取并集
warehouse.in.detail.query warehouse read 入库单详情;支持调拨单,物料汇总、可选 SN、流程、附件和关联出库索引
warehouse.todo.list.query warehouse read 当前用户仓库待办统一查询(REQ-20260915-0008),合并老系统出入库/调拨三个待办接口;流程视图 user_id 过滤,不按代理商过滤;调拨派生出入库单带 derivedFrom 标记
delivery.order.list.query workorder read 送货工单分页查询;申请/完成日期独立过滤,代理、代管物业及流程参与人权限取并集
delivery.order.detail.query workorder read 送货工单详情;返回物流、配发物品、发货/签收附件、流程及关联单据
install.order.list.query workorder read 装机工单分页查询;仅保留申请日期范围,返回状态文案与安装数量汇总
install.order.detail.query workorder read 装机工单详情;返回明细、作业人员、地址、商城订单、流程、配发 SN、出入库关联及凭证
pickup.order.list.query workorder read 取货工单分页查询;申请/取货/完成日期独立过滤,物流按页批量富化
pickup.order.detail.query workorder read 取货工单详情;返回物流、取货物品、SN 调整、附件、流程及关联单据
district.paper.statistics.query hardware read 客户用纸统计(复刻老 /MonthlyStatistics/getMonthlyStatisticsMap)。入参 districtId/startDate/endDate/positionMode/deviceType/buildingId/floor/toiletId;出参 toiletGenders、summaryByGender(paperLengthM 米/useCount/perUseLengthM/deviceDayCount/rfidCount)、daily 按日明细。DB 聚合 m0 monthly_statistics;rfidCount 查 ES paperuselog(90 天热/冷索引,LocalEsDate 云/本地集群分界,rfid_use_flag 分口径)。详细设计见 doc/district-paper-statistics-design.md
battery.fault.list.query hardware read 电池故障分页查询。按站点/物业/代理商/SN/设备类型/故障类型/恢复状态/故障日期筛选;沿用设备行级权限,按故障时间和 ID 倒序。详细设计见 doc/battery-fault-mcp-tool-design.md
hardware.electricity.message.list.query hardware read 设备电量/信号/温度等消息记录分页查询(ES electricitymessage_yyyyMM 月索引,只走本地集群,无权限过滤,对齐老 /batteryAnalyse/batteryAnalyseList)。入参 snCode(必填)/startTime/endTime(必填且须同一天,跨天拒绝)/messageType(0-5,不传=全部类型)/page/rows(默认 1/500,上限 1000);出参 snCode/index/total/page/rows/records(createTime/messageType/messageTypeText/value),createTime ASC。详细设计见 doc/electricity-message-mcp-tool-design.md

分页统一规范:page 从 1 开始,rows 默认 20、上限 100,超出截断为 100 并在返回 notice 字段提示。

工具名使用点号分隔(MCP 规范允许 [a-zA-Z0-9._-],实测 Spring AI 1.1.8 与握手流程均正常)。工具的 category/kind 由代码 @XqTool 注解声明,启动时同步进 tool 表(只插不删,管理员停用状态不会被覆盖)。新工具默认不属于任何作用域 = 任何 token 调不到,需管理员配 scope。

启动

# 首次仅由数据库管理员创建空库;表结构由应用启动时的 Flyway 自动迁移
mysql -h192.168.1.181 -u<svc_db_admin> -p < sql/bootstrap/create-database.sql
# 启动一次让 ToolSyncService 同步 tool 表后:
mysql -h192.168.1.181 -u<svc_db_admin> -p xiaoqu_agent_svc_d < sql/seed-dev.sql

# 数据库/Redis 连接信息已明文写在 application-dev.yml(内网 dev 库);
# 仍需显式指定 profile
export SPRING_PROFILES_ACTIVE=dev
# 已有 V1 库首次接入时设 true;确认 flyway_schema_history 后可设 false
export SVC_FLYWAY_BASELINE_ON_MIGRATE=true
# 仅首次、且 admin_user 为空时创建管理员;创建完成后可移除这三个变量
export ADMIN_BOOTSTRAP_ACCOUNT=<admin_account>
export ADMIN_BOOTSTRAP_PASSWORD=<strong_password>
export ADMIN_BOOTSTRAP_NAME=<display_name>

./mvnw clean package
java -jar target/xiaoqu-agent-service.jar
# 或
./mvnw spring-boot:run
# 后台常驻(本机兼任 dev 部署机时):
nohup java -jar target/xiaoqu-agent-service.jar > target/app.log 2>&1 &
  • 不提供默认 profile;数据库/Redis 连接信息明文写在 application-dev.yml(内网 dev 库,与 xiaoqu-franchisee pom.xml dev profile 同一惯例);生产/测试环境不得在配置文件写明文密码。
  • dev 端口 8088,管理后台 http://localhost:8088/,MCP 端点 http://localhost:8088/mcp。
  • Flyway 在 Web 服务启动前执行 validate + migrate;checksum、版本或 SQL 执行失败时应用不会启动。
  • 新增 SQL 必须遵循 sql/README.md,禁止修改已合并的历史 migration。

部署脚本(推荐,本机 dev 部署)

cp deploy/.env.example deploy/.env.dev   # 填写 dev 凭据(已 gitignore,不入库)
./deploy/deploy.sh build                 # 全量测试 + 构建 jar(含 migration 治理测试)
./deploy/deploy.sh restart               # 停旧起新;启动先迁移,失败立即报告并阻断
./deploy/deploy.sh status | log | stop   # 状态 / 跟踪日志 / 停止
  • .env.dev 只需管理员引导和 Flyway 开关;数据库/Redis 已走配置文件。日志在 logs/agent-service.log,pid 在 deploy/app.pid。
  • 注意应用启动较慢(多数据源初始化,约 60-90s),脚本健康检查窗口已放宽到 180s。

生产部署(prd,HTTPS 已于 2026-09-11 上线)

  • 服务器:SSH 别名 xiaoqu-prd(123.57.245.202,Rocky 8),部署目录 /data/xiaoqu-agent-service(jar、app.env、logs/、app.pid)。连接参数在 deploy/remote.conf(已 gitignore)。
  • 发布:./deploy/deploy.sh remote-deploy(本地构建 → 上传 → 远程停旧起新 → 健康检查);只重启用 remote-restart。
  • 对外地址:MCP 端点 https://agent.ququclean.com/mcp,管理后台 https://agent.ququclean.com/。
  • TLS 由 Nginx 终结(配置 /etc/nginx/conf.d/agent.ququclean.com.conf,转发 127.0.0.1:8088):/mcp 单独 location,proxy_buffering off + 1 小时读写超时(SSE 流式必需);全站传递 X-Real-IP / X-Forwarded-For / X-Forwarded-Proto;80 端口 301 跳 HTTPS;firewalld 已永久放行 http/https。
  • 证书:阿里云一年期 DV 证书(DigiCert),文件在 /etc/nginx/ssl/agent.ququclean.com/(pem 644 / key 600),2027-03-11 到期,无自动续期。到期前在阿里云重新申请下载,替换这两个文件后 nginx -s reload。
  • prd 的 app.env 相比 dev 必须多出两项:ADMIN_COOKIE_SECURE=true(会话 cookie 仅经 HTTPS 下发)、SERVER_FORWARD_HEADERS_STRATEGY=framework(认 X-Forwarded 头,审计 client_ip 才能取到真实来源 IP,否则全是 127.0.0.1)。

测试 ak(仅 dev)

仓库不提供固定 ak。每位开发者在本地生成随机 ak,只把 SHA-256 写入 agent_api_key,再用 token_scope 关联需要的作用域;明文只保存在本机客户端配置(如 .kimi-code/mcp.json,已 gitignore)中,不提交版本库。仓库历史中曾出现的 dev ak 必须视为已泄露并在数据库中禁用。

手工插入的 ak 没有 key_cipher 密文:走 /mcp 正常(只校验哈希),但被 ensure 命中时会触发自动轮换(旧把禁用、签发新把)——这是预期行为。

AK="xqak_$(openssl rand -hex 24)"   # 生成随机 ak,明文只贴进本机客户端配置
printf %s "$AK" | shasum -a 256     # 输出的哈希写入 agent_api_key.key_hash

curl 验证(Streamable HTTP 握手)

# 1) initialize(从响应头取 Mcp-Session-Id)
curl -i -X POST http://localhost:8088/mcp \
  -H 'Authorization: Bearer <ak>' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'

# 2) notifications/initialized → 3) tools/list → 4) tools/call,均带:
#    -H 'Authorization: Bearer <ak>' -H 'Mcp-Session-Id: <session-id>'
curl -X POST http://localhost:8088/mcp \
  -H 'Authorization: Bearer <ak>' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'Mcp-Session-Id: <session-id>' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"agent.list.query","arguments":{"rows":100}}}'

响应为 SSE 帧(event: message / data: {...})。2026-09-03 实测:无 ak/无效 ak → 401;总部 ak 查 agent.list.query total=62(全量);代理商 ak total=26(与 SELECT COUNT(*) FROM agent WHERE soft_delete=0 AND supervisor_id=1 一致);无 scope 的 ak → isError:true + "该 token 的作用域不包含工具 [agent.list.query],请联系管理员在对应作用域开通";audit_log 记录 success/denied 与 client_type。

property.list.query 权限隔离实测(2026-09-03,均与直连 SQL 核对一致):

条件 总部 ak 代理商 ak
无过滤 total=141 total=73
province=北京市 total=38 total=31
name 模糊 '%测试%' — total=7(与 SQL 一致)

agent.list.query 抽测:channel_no='D' + province='北京市' → 总部 11 / 代理商 9(均与 SQL 一致);rows=500 → 截断为 100 并返回 notice。安全修复后,代理商精确查询仅允许自身或直属下级,权限外 id 返回空结果。

客户端接入(dev 阶段裸跑 HTTP)

dev 服务地址示例:http://192.168.4.236:8088/mcp(本机内网 IP;本机自测可用 localhost)。生产地址 https://agent.ququclean.com/mcp(Nginx 终结 TLS,部署细节见上文「生产部署」)。

Kimi Code — 项目级 .kimi-code/mcp.json(ak 明文直写;该文件已加入 .gitignore,不会提交版本库):

{
  "mcpServers": {
    "xiaoqu": {
      "url": "http://192.168.4.236:8088/mcp",
      "headers": {
        "Authorization": "Bearer <测试ak>"
      }
    }
  }
}

写入 ak 后 /new 开个新会话即可自发现工具(MCP 配置只在会话启动时加载)。

Claude Code:

claude mcp add --transport http xiaoqu http://192.168.4.236:8088/mcp \
  --header "Authorization: Bearer <测试ak>"

Open API(服务间接口,/api/open/**)

供其他系统在服务端到服务端场景下获取用户或服务 Token。鉴权使用 header X-Open-Key:生产优先在管理后台「服务密钥」页面创建并绑定作用域,明文只展示一次、数据库仅存 SHA-256 哈希;原有 app.open.api-key / app.open.clients 环境配置继续兼容。

POST /api/open/token/ensure —— 按 手机号/邮箱 + 作用域编码 确保 Token 存在(get-or-create):

curl -X POST http://localhost:8088/api/open/token/ensure \
  -H 'Content-Type: application/json' \
  -H 'X-Open-Key: <服务间密钥>' \
  -d '{"mobile":"13800001111","scopeCode":"operation"}'
  • 入参:mobile、email 至少一个(手机号优先);scopeCode 必填。人员只认企业账号(enterprise_type 0/1),查不到 → 404。
  • 已存在有效 Token → {"created":false,"token":"xqak_...(解密既有密文得到的明文)",...}(支持二次获取:服务端保存 SHA-256 哈希 + AES-GCM 密文,密钥由 app.token-cipher-key 派生)。
  • 已存在但无密文(历史数据)→ 自动轮换:禁用旧把、签发新把,按 created=true 返回新明文。
  • 不存在 → 自动创建永久 Token → {"created":true,"token":"xqak_...(明文)",...}。
  • 调用写 audit_log(open.token.ensure / client_type=open-api,不记录明文)。
  • 管理后台支持服务密钥列表、创建、编辑作用域、吊销/启用和轮换;吊销或轮换后旧密钥立即无法通过 /api/open/** 鉴权。

服务 Token(principalType=service):serviceName 必填;只能绑定「允许服务主体」的作用域(agent_scope.service_enabled=1,REQ-20260921-0004 起由标记驱动,取代原先的 sync 硬编码),旧版不限域密钥不可签发服务 Token。服务 Token 无用户身份:调 SYNC 变化流工具为全量;调在线查询工具按总部视角全量可见、不做行过滤(PermissionService 统一分支),走独立限流档 app.rate-limit.service-query-per-minute(默认 300/分钟);warehouse.todo.list.query 按当前登录人统计,服务身份调用返回 PERMISSION_DENIED。典型用法:运营后台免登录分页拉取全量数据(scope ops-query)。

签发规则(2026-09-04 起):一作用域一 Token。后台签发时选 N 个作用域会生成 N 把 token,每把独立绑定单作用域;编辑存量 token 时作用域为单选。

codegen(run-once)

表白名单:m0 user, agent, authority, properyt, agent_manage_district_auth;m2 warehouse_agent_auth;svc agent_api_key, agent_scope, tool, scope_tool, token_scope, audit_log, admin_user, app_open_client, app_open_client_scope(注:物业表真实表名为老系统遗留拼写 properyt)。生成物落在 src/main/java 对应分包并提交进仓库。

# 生成(依赖已下载后执行;连 dev 库)
export CODEGEN_DB_USERNAME=<codegen_user>
export CODEGEN_DB_PASSWORD=<password>
./mvnw -q dependency:build-classpath -Dmdep.includeScope=test -Dmdep.outputFile=target/cp.txt
javac -cp "$(cat target/cp.txt)" -d target/codegen-classes src/test/java/me/iiv/agent/codegen/CodeGenerator.java
java -cp "target/codegen-classes:$(cat target/cp.txt)" me.iiv.agent.codegen.CodeGenerator

重新生成前请先删除目标 entity/mapper 文件,避免覆盖人工改动。