一个生产级的多租户 MCP (Model Context Protocol) Server,把 AI Agent 安全、隔离、可观测地接入企业业务数据(DB / HTTP API)。
Spring Boot 3.4 · JDK 21 · Spring AI MCP 1.1.4 · MyBatis-Flex · PostgreSQL · Redis
让 AI Agent 用自然语言查企业业务数据时,不能让它直连数据库 —— 注入、越权、删库、跨租户泄漏,每一条都是事故。
本项目作为 AI 与企业数据之间的接入层,提供:
- 协议标准化:用 MCP 把异构的企业数据源统一暴露为 AI 可调用的 tool
- 多租户隔离:DataSource、限流器、缓存 key、SQL 模板权限、tool schema 全部按
tenantId分片 - 安全兜底:模板化 SQL + 白名单 AST 校验 + 只读 DB 账号三层防线
- 运行时可调:超时/限流/行数上限走字典表 + Pub/Sub 热更新,改值不重启
问题:spring-ai-mcp-server-webmvc 1.1.4 在启动时调用一次 ToolCallbackProvider.getToolCallbacks() 把结果注册到 McpAsyncServer.tools (CopyOnWriteArrayList),运行时 tools/list 请求直接读这个 list,不再回调 provider —— SSE / Streamable / Stateless 三种 transport 一致。这意味着自实现 ToolCallbackProvider 无法做 per-tenant schema。
方案:在 servlet filter 层(协议层之前)拦截 tools/list 请求,自己构造响应短路掉 spring-ai-mcp 的处理链。
参见 McpToolsListInterceptFilter:
OncePerRequestFilter+@Order(HIGHEST_PRECEDENCE+100)- 缓存 body → 解析 JSON-RPC method →
tools/list自己处理(短路 chain)/ 其他透传 - 短路路径:复用
AuthenticationService.verifyMcp验 token → 读X-Tenant-Id设TenantContext→ 调PerTenantToolCallbackProvider拿合并后的 schema → 序列化ToolDefinition.inputSchema嵌入响应 - 缓存:Caffeine 30s TTL,key=tenantId
- 失效:监听
TemplateChangedEvent/ActionAuthChangedEvent全量清空
痛点:高级租户可以覆盖模板写自定义 SQL,但 AI 看到的 inputSchema 是按模板 paramSchema 全局生成的。如果租户 SQL 引入了模板里没声明的占位符,AI 永远不会传 → 运行时被 padding 静默补 null → 怪查询。
方案:
- 写入路径在
TenantActionConfigService.validateCustomSql解析 custom_sql 占位符 → diff 模板 paramSchema 字段集 → 有超集字段直接拒(CUSTOM_SQL_REFERENCES_UNKNOWN_PARAM400)—— fail-fast 拦在写入路径 - 运行时
BusinessExecutor跳过 paramSchema 强校验,让租户自由传参 DatabaseAdapter.padMissingParamsFromSql从 SQL 文本自身解析占位符给缺失 key 补 null,让(:foo IS NULL OR ...)这种"可选过滤"模式可用- 安全模型不退化:SqlWhitelistValidator 仍按
TENANT_CUSTOM严校验(长度/分号/AST/函数黑名单),prepared statement 防注入,只读账号兜底
TenantDataSourceManager 用 LinkedHashMap(accessOrder=true) 实现 LRU,key=tenantId:dsName,配置 connector.datasource-pool.max-pools(默认 300)控制上限,超过淘汰最久未用并 close()。
为什么需要 LRU:300 池 × 5 连接 = 1500 连接 worst case,需要 PgBouncer 或调大 PG max_connections。不加 LRU 则无界增长。
并发策略:快路径(命中)在 synchronized 内只做一次 map.get;慢路径(构建新池)在锁外执行查询 + HikariCP 初始化,再加锁决策去重,避免阻塞其他 (租户, ds)。
| 层级 | 存放 | 修改方式 | 示例 |
|---|---|---|---|
L1 字典表 sys_dict |
DB + 内存 ConcurrentHashMap | Admin API CRUD + Redis Pub/Sub 广播 | 超时/重试/限流阈值/行数上限 |
L2 application.yaml |
配置文件 | 改文件重启 | 连接池、线程池、端口、密钥 |
L3 BusinessConstants |
代码常量 | 改代码重编译 | Redis key 前缀、Header 名 |
业务代码读值永远走 sysDictService.getInt(key, default)。不能往代码常量里塞数字。
TwoLevelCacheManager = Caffeine(L1) + Redis(L2)。写入/失效时同时广播 channel cache:invalidate,消息格式 {cacheName}|{key},其他实例收到后清本地 L1。多实例部署一致性保证。
sys_dict 走 channel dict:refresh。Admin 写操作还会发 Spring ApplicationEvent,本 JVM 内连接池/缓存即时失效;跨实例靠 Redis Pub/Sub。两套机制各司其职。
access_type 一个字段同时表达"接入方式 + 方言":POSTGRES / MYSQL / ORACLE / SQLSERVER / API。
- 同一 action 在
action_template中可有多行(PG 版 + MySQL 版 + ...),运行时按template.accessType == datasource.accessType匹配 - AI 视角同 action 是一个 MCP tool,
DynamicMcpToolProvider按 action 字段去重 - 多方言行的 description / param_schema / datasource_name 必须一致(写入强校验,不一致拒绝)
SqlWhitelistValidator.validate(sql, source):TEMPLATE(运维预审)跳过 AST 解析避免 JSqlParser 误伤合法方言,TENANT_CUSTOM严校验
- 软删:
tenant_config/tenant_datasource/action_template/sys_dict有deleted字段,Flex 自动过滤;每表独立无级联 - 物理删(Purge):在
X-API-Key基础上额外要求X-Purge-Api-Key,两 key 独立配置,admin 日常运维拿不到 purge key - 状态守卫:
TenantStatusGuard在 MCP 入口 fail-fast 检查,租户禁用/软删时不进入 rate limiter,不污染限流状态
- AES-256-GCM,密文格式
Base64(IV[12] || ciphertext || GCM_TAG[16]) - 启动时密钥缺失/格式错 → fail-fast 阻止应用启动
- 解密时
AEADBadTagException单独捕获并以SecurityException上抛(篡改告警信号) - HikariCP 密码:
new HikariDataSource(hc)后立即hc.setPassword(null)清空配置对象 - 日志脱敏:
MaskingConverter是 logback conversion word,输出阶段对密码/token/手机号等正则脱敏
┌──────────────────────────┐
微信用户 → OpenClaw / ClawBot │ Enterprise Connector │
(AI 意图解析) │ (本项目, MCP Server) │
│ │ │
│ POST /mcp │ ┌──────────────────┐ │ ┌─────────────┐
│ Streamable HTTP │ │ McpToolsList │ │ │ 租户 A DB │
│ X-Tenant-Id: A │→ │ InterceptFilter │ │ → │ (只读账号) │
├─────────────────────┤ │ (per-session │ │ └─────────────┘
│ Authorization Bearer│ │ schema) │ │
│ │ └────────┬─────────┘ │ ┌─────────────┐
│ │ ↓ │ → │ 租户 A API │
│ │ ┌──────────────────┐ │ └─────────────┘
│ │ │ McpToolService │ │
│ │ │ 限流/幂等/审计 │ │ ┌─────────────┐
│ │ └────────┬─────────┘ │ │ Audit Log │
│ │ ↓ │ → │ (Append) │
│ │ ┌──────────────────┐ │ └─────────────┘
│ │ │ BusinessExecutor │ │
│ │ │ ┌──────┬───────┐ │ │ ┌─────────────┐
│ │ │ │ DB │ HTTP │ │ │ │ PG + Redis │
│ │ │ │ Adpt │ Adpt │ │ │←──→ │ (元数据/缓存)│
│ │ │ └──────┴───────┘ │ │ └─────────────┘
│ │ └──────────────────┘ │
│ └──────────────────────────┘
↓
多个租户 (按 tenantId 路由到对应物理资源, 不共享)
完整架构决策见 docs/PROJECT_BRIEF.md 和 docs/DEVELOPMENT_PLAN.md。
| 类别 | 选型 | 理由 |
|---|---|---|
| 语言/框架 | Java 21 + Spring Boot 3.4.5 | 虚拟线程 + Jakarta,现代企业 Java 标准栈 |
| MCP 协议 | Spring AI 1.1.4 (spring-ai-starter-mcp-server-webmvc) |
官方实现,Streamable HTTP 单端点 |
| ORM | MyBatis-Flex 1.11.6 | 原生复合主键 + APT 类型安全 + 软删自动过滤 |
| 数据库 | PostgreSQL 16 (元数据 + JSONB) | 跨方言适配支持 MySQL / SQL Server |
| 缓存 | Caffeine (L1) + Redis (L2) | 多实例一致性 + 本地零延迟 |
| 限流/熔断 | Resilience4j 2.3.0 | per-tenant 限流,阈值热更新 |
| SQL 安全 | JSqlParser 5.1 AST + 自维护函数黑名单 | TENANT_CUSTOM 来源严校验 |
| 加密 | AES-256-GCM | 凭证字段,启动 fail-fast |
| 可观测 | Micrometer + Prometheus + Logback JSON | 结构化日志 + MDC traceId |
| 测试 | JUnit 5 + Testcontainers | 165+ 测试,单测/IT ≈ 3:1 |
- JDK 21(必须,JDK 17 编译失败)
- Docker(用于本地起 PG + Redis)
- Maven Wrapper 已包含
Linux / macOS / WSL2 / Git Bash:
# 1. 启 PG + Redis
docker compose up -d
# 2. 加载 DDL + 种子字典
psql -h localhost -U postgres -f sql/01_create_tables.sql
psql -h localhost -U postgres -f sql/04_seed_dict.sql
# 3. 设环境变量
export DB_PASSWORD=postgres
export REDIS_HOST=localhost
export MCP_AUTH_TOKEN=$(openssl rand -base64 32)
export ADMIN_API_KEY=$(openssl rand -base64 32)
export ENCRYPTION_KEY=$(openssl rand -base64 32) # AES-256, 必须 32 字节
# 4. JDK 21
export JAVA_HOME="/path/to/jdk-21"
export PATH="$JAVA_HOME/bin:$PATH"
# 5. 编译 + 启动(clean 顶掉 IDE 可能残留的半成品 class,确保 APT 重新生成 TableDef)
./mvnw clean compile spring-boot:runWindows · PowerShell(openssl 来自 Git for Windows):
# 1. 启 PG + Redis
docker compose up -d
# 2. 加载 DDL + 种子字典
psql -h localhost -U postgres -f sql/01_create_tables.sql
psql -h localhost -U postgres -f sql/04_seed_dict.sql
# 3. 设环境变量(仅当前窗口生效)
$env:DB_PASSWORD = "postgres"
$env:REDIS_HOST = "localhost"
$env:MCP_AUTH_TOKEN = (openssl rand -base64 32)
$env:ADMIN_API_KEY = (openssl rand -base64 32)
$env:ENCRYPTION_KEY = (openssl rand -base64 32) # AES-256, 必须 32 字节
# 4. JDK 21
$env:JAVA_HOME = "D:\soft\java\jdk-21.0.2"
$env:PATH = "$env:JAVA_HOME\bin;$env:PATH"
# 5. 编译 + 启动(clean 顶掉 IDE 残留的半成品 class;.\mvnw.cmd 在 cmd 和 PowerShell 下都能跑)
.\mvnw.cmd clean compile spring-boot:runWindows · cmd(openssl 来自 Git for Windows;for /f 为交互式命令行写法,写进 .bat 需把 %i 改成 %%i):
rem 1. 启 PG + Redis
docker compose up -d
rem 2. 加载 DDL + 种子字典
psql -h localhost -U postgres -f sql/01_create_tables.sql
psql -h localhost -U postgres -f sql/04_seed_dict.sql
rem 3. 设环境变量(仅当前窗口生效)
set DB_PASSWORD=postgres
set REDIS_HOST=localhost
for /f "delims=" %i in ('openssl rand -base64 32') do set MCP_AUTH_TOKEN=%i
for /f "delims=" %i in ('openssl rand -base64 32') do set ADMIN_API_KEY=%i
for /f "delims=" %i in ('openssl rand -base64 32') do set ENCRYPTION_KEY=%i
rem 4. JDK 21
set JAVA_HOME=D:\soft\java\jdk-21.0.2
set PATH=%JAVA_HOME%\bin;%PATH%
rem 5. 编译 + 启动(clean 顶掉 IDE 残留的半成品 class)
.\mvnw.cmd clean compile spring-boot:run💡 为什么启动前要
clean compile:用 VSCode / Eclipse 打开过项目时,IDE 的编译器(ECJ)可能在 MyBatis-Flex APT 没生成 TableDef 的情况下,往target/classes编进半成品 class。spring-boot:run不重新编译、直接加载它们,会在启动时抛java.lang.Error: Unresolved compilation problems: XXX cannot be resolved。先mvn clean compile让 javac 跑一遍 APT 重新生成 TableDef,即可避免。
Linux / macOS / WSL2 / Git Bash:
# 全量(需 Docker)
./mvnw test
# 只单测
./mvnw test -Dtest='!*IntegrationTest'
# 只 IT
./mvnw test -Dtest='*IntegrationTest'Windows(cmd / PowerShell 通用) —— .\mvnw.cmd 两边都能跑,-Dtest 用双引号包整段(cmd 不会剥单引号):
- 全量(需 Docker):
.\mvnw.cmd test - 只单测:
.\mvnw.cmd test "-Dtest=!*IntegrationTest" - 只 IT:
.\mvnw.cmd test "-Dtest=*IntegrationTest"
⚠️ Windows Docker Desktop 4.70 跑不了 IT(CLI-auth 代理问题),请在 WSL2 / Linux / macOS / CI 上跑。详见 CLAUDE.md "集成测试"章节。
# tools/list (per-tenant schema)
curl -X POST http://localhost:8080/mcp \
-H "Authorization: Bearer $MCP_AUTH_TOKEN" \
-H "X-Tenant-Id: tenant-001" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'完整端到端示例见 docs/USAGE_GUIDE.md 和 docs/postman。
| 文件 | 用途 |
|---|---|
| CLAUDE.md | 关键架构决策 + 陷阱 + 阶段状态(最权威,~600 行) |
| docs/PROJECT_BRIEF.md | 面向演讲/面试的概览 |
| docs/DEVELOPMENT_PLAN.md | 完整设计文档(业务模型/DDL/分阶段任务,~1400 行) |
| docs/USAGE_GUIDE.md | 端到端使用手册 |
| Phase | 内容 | 状态 |
|---|---|---|
| 1 | 骨架 + 数据层(实体/Mapper/异常体系/SysDictService) | ✅ |
| 2 | 核心服务层(两级缓存 / DataSource 池 / DB+HTTP 适配器 / 同步执行) | ✅ |
| 3 | 异步 + 安全 + 限流(AsyncTaskService / SqlWhitelist / Resilience4j) | ✅ |
| 4 | MCP 接入 + Admin API(动态 tool 注册 / 6 个 Admin Controller) | ✅ |
| 5 | 可观测 + 测试 + 部署(结构化日志 / Prometheus / Testcontainers IT) | ✅ |
| 6 | 多数据源 + 授权白名单 + 软删/Purge | ✅ |
| 7 | 多方言适配(POSTGRES/MYSQL/ORACLE/SQLSERVER) | ✅ |
165+ 个 @Test,单测 / IT ≈ 3:1。
PoC / 个人作品集项目。未在生产环境运行,但所有架构决策都按生产标准设计(可观测、安全、合规、可扩展)。代码质量、测试覆盖、文档完整度可以作为企业 AI 落地的参考实现。
如果你在做:
- 企业级 AI Agent 接入数据
- 多租户 SaaS 的隔离设计
- Spring AI MCP 1.1.x 的工程化实践
- 模板化 SQL + AI 安全方案
—— 欢迎提 issue 交流。