feat(lzwcai-agile-db): 更新AgileDB技能至v0.4.2版本并扩展工具集

- 将技能版本从0.2.0升级至0.4.2
- 工具数量从33个扩展至57个,新增数据源管理、AI训练、库表关联配置等功能
- 新增MQTT字段关联同步模块(8个工具)和库表关联配置(3个工具)
- 添加重要的契约提示和安全确认原则,包括target默认值、alter_table操作限制等
- 修正工具参数说明,如execute_sql的executableSql改为sql,参数结构优化
- 增强安全机制,明确危险操作的用户确认流程和目标资源选择规则
- 更新README.md中的工具数量统计和功能描述
This commit is contained in:
2026-06-17 14:40:43 +08:00
parent 557361632c
commit ba5cd4bbe1
115 changed files with 7587 additions and 575 deletions

Binary file not shown.

View File

@@ -1,7 +1,8 @@
--- ---
name: lzwcai-agile-db name: lzwcai-agile-db
description: AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据库操作工作流指导,适合零基础用户。 description: AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据库操作工作流指导,适合零基础用户。
version: 0.2.0 metadata:
version: 0.4.2
--- ---
# lzwcai-agile-db # lzwcai-agile-db
@@ -9,33 +10,56 @@ version: 0.2.0
AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据库操作工作流指导,适合零基础用户。 AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据库操作工作流指导,适合零基础用户。
## 完整工具清单(33 个工具) ## 完整工具清单(57 个工具)
本 skill 基于 `lzwcai_mcp_agile_db` MCP Server共提供 33 个工具,分为 9 大类 本 skill 基于 `lzwcai_mcp_agile_db` MCP Server共提供 57 个工具,分为 12 大类
### 一、数据源管理6 个工具) > **⚠️ 契约提示2026-06 真机验证)**:以下契约文档曾写错、已按真机修正,调用时务必遵守:
> - **`target` 默认 `test`**(不是 prod。写线上库必须显式传 `target="prod"`。
> - **`alter_table` 的 operation 只有 3 种**`ADD_COLUMN` / `MODIFY_COLUMN` / `DROP_COLUMN`。其余RENAME_COLUMN/ALTER_COLUMN_TYPE/SET_NOT_NULL...)后端**不支持**。
> - **`alter_database` 改名字段是 `newName`**(不是 newDatabaseName
> - **`generate_table_by_description` 是异步**:返回 taskId需用 `get_ai_training_detail` 轮询。
> - **`list_tables_with_ai` 的 `datasourceId` 实为「库/配置 ID」**list_databases 返回的 config id不是连接 ID。
### 一、数据源管理10 个工具)
| 工具 | 功能 | 危险等级 | | 工具 | 功能 | 危险等级 |
|------|------|----------| |------|------|----------|
| `list_datasources` | 获取数据源列表 | 安全 | | `list_datasources` | 获取数据源列表 | 安全 |
| `get_datasource_detail` | 获取数据源详情(含数据库、表结构) | 安全 | | `get_datasource_detail` | 获取数据源详情(含数据库、表结构) | 安全 |
| `create_datasource` | 创建外部数据源连接 | 安全 | | `create_datasource` | 创建外部数据源连接(可选先测连接) | 安全 |
| `update_datasource` | 更新数据源连接信息 | 中等 | | `update_datasource` | 更新数据源连接信息 | 中等 |
| `toggle_datasource_status` | 启用/停用数据源 | 中等 | | `toggle_datasource_status` | 启用/停用数据源 | 中等 |
| `delete_datasource` | 删除数据源 | **危险** | | `delete_datasource` | 删除数据源 | **危险** |
| `test_connection` | 测试外部数据库连接(不创建) | 安全 |
| `get_realtime_structure` | 实时探测远端库表结构 | 安全 |
| `create_builtin_datasource` | 创建内置 PostgreSQL 连接(建库表链路第一步) | 安全 |
| `update_builtin_datasource` | 修改内置 PostgreSQL 连接 | 中等 |
### 二、数据库与表管理(6 个工具) ### 二、数据库与表管理(9 个工具)
| 工具 | 功能 | 危险等级 | | 工具 | 功能 | 危险等级 |
|------|------|----------| |------|------|----------|
| `list_databases` | 获取数据源下的数据库列表 | 安全 | | `list_databases` | 获取数据源下的数据库列表 | 安全 |
| `list_tables` | 获取数据源下的表列表 | 安全 | | `list_tables` | 获取数据源下的表列表(基础) | 安全 |
| `list_tables_with_ai` | 获取表元数据列表(含 AI 训练描述,内置库主列表) | 安全 |
| `get_table_detail` | 获取表结构详情(字段、类型、主键) | 安全 | | `get_table_detail` | 获取表结构详情(字段、类型、主键) | 安全 |
| `create_table` | 创建新表 | **危险** | | `create_database` | 在内置源下创建数据库(建表前置) | **危险** |
| `alter_table` | 修改表结构 | **危险** | | `create_table` | 在指定库创建新表 | **危险** |
| `generate_table_by_description` | AI 根据自然语言生成表结构 | 安全(仅生成,不创建) | | `create_database_table` | 一次性建库+表(合并接口) | **危险** |
| `alter_table` | 修改表结构(仅 ADD/MODIFY/DROP_COLUMN | **危险** |
| `alter_database` | 修改数据库(改名,字段 newName | **危险** |
### 三、表数据 CRUD5 个工具) ### 三、AI 训练与生成4 个工具)
| 工具 | 功能 | 危险等级 |
|------|------|----------|
| `generate_table_by_description` | AI 自然语言生成表结构(异步,返回 taskId | 安全(仅生成,不创建) |
| `get_ai_training_detail` | 查询/轮询 AI 训练任务详情 | 安全 |
| `list_ai_trainings` | AI 补全训练任务列表 | 安全 |
| `create_ai_training_by_selected` | 按选中表创建 AI 补全训练 | 中等 |
### 四、表数据 CRUD5 个工具target 默认 test
| 工具 | 功能 | 危险等级 | | 工具 | 功能 | 危险等级 |
|------|------|----------| |------|------|----------|
@@ -45,26 +69,26 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
| `delete_table_rows` | 删除数据行(按主键) | **危险** | | `delete_table_rows` | 删除数据行(按主键) | **危险** |
| `export_table_excel` | 导出表数据为 Excelbase64 | 安全 | | `export_table_excel` | 导出表数据为 Excelbase64 | 安全 |
### 、SQL 执行1 个工具) ### 、SQL 执行1 个工具)
| 工具 | 功能 | 危险等级 | | 工具 | 功能 | 危险等级 |
|------|------|----------| |------|------|----------|
| `execute_sql` | 执行原生 SQL 查询 | 中等/危险 | | `execute_sql` | 执行原生 SQL 查询(入参 `sql`target 默认 prod | 中等/危险 |
### 、数据导入2 个工具) ### 、数据导入2 个工具)
| 工具 | 功能 | 危险等级 | | 工具 | 功能 | 危险等级 |
|------|------|----------| |------|------|----------|
| `preview_import_data` | 上传 Excel 文件AI 智能识别并预览 | 安全 | | `preview_import_data` | 从 URL 下载 Excel 文件AI 智能识别并预览 | 安全 |
| `confirm_import_data` | 确认导入 AI 识别后的数据 | **危险** | | `confirm_import_data` | 确认导入(需传 databaseName自动组装结构 | **危险** |
### 、表订阅1 个工具) ### 、表订阅1 个工具)
| 工具 | 功能 | 危险等级 | | 工具 | 功能 | 危险等级 |
|------|------|----------| |------|------|----------|
| `toggle_table_subscription` | 切换表的订阅状态 | 中等 | | `toggle_table_subscription` | 切换表的订阅状态configId+tableName+subscribe | 中等 |
### 、API 密钥管理(6 个工具) ### 、API 密钥管理(7 个工具)
| 工具 | 功能 | 危险等级 | | 工具 | 功能 | 危险等级 |
|------|------|----------| |------|------|----------|
@@ -74,8 +98,9 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
| `delete_api_key` | 删除 API 密钥 | **危险** | | `delete_api_key` | 删除 API 密钥 | **危险** |
| `get_api_key_permissions` | 查看指定密钥的权限配置 | 安全 | | `get_api_key_permissions` | 查看指定密钥的权限配置 | 安全 |
| `grant_api_key_permissions` | 批量为 API 密钥授予权限 | **危险** | | `grant_api_key_permissions` | 批量为 API 密钥授予权限 | **危险** |
| `revoke_api_key_permissions` | 撤销/删除已授予的权限(按权限记录 ID | **危险** |
### 、技能与工具管理(6 个工具) ### 、技能与工具管理(7 个工具)
| 工具 | 功能 | 危险等级 | | 工具 | 功能 | 危险等级 |
|------|------|----------| |------|------|----------|
@@ -84,7 +109,29 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
| `create_skill` | 为数据源创建技能 | 中等 | | `create_skill` | 为数据源创建技能 | 中等 |
| `create_sql_tool` | 将 SQL 查询创建为可复用工具 | 中等 | | `create_sql_tool` | 将 SQL 查询创建为可复用工具 | 中等 |
| `delete_skill_tool` | 删除技能下的工具 | **危险** | | `delete_skill_tool` | 删除技能下的工具 | **危险** |
| `update_skill_config` | 更新技能配置 | 中等 | | `update_skill_config` | 更新技能配置(名称/描述/模板) | 中等 |
| `update_skill_tool` | 修改技能工具id+description+uniqueName | 中等 |
### 十、库表关联配置3 个工具,外置源纳管/内置删库)
| 工具 | 功能 | 危险等级 |
|------|------|----------|
| `create_connection_config` | 建库表关联(外置源建连后选库选表的最后一步) | 中等 |
| `update_connection_config` | 改库表关联范围 | 中等 |
| `delete_connection_config` | 删库表关联(也是「删内置库」的实现) | **危险** |
### 十一、MQTT 字段关联同步8 个工具,内置源专属)
| 工具 | 功能 | 危险等级 |
|------|------|----------|
| `list_mqtt_configs` | MQTT 同步配置列表 | 安全 |
| `get_mqtt_config_detail` | 配置详情 | 安全 |
| `list_mqtt_target_tables` | 可同步目标表列表 | 安全 |
| `list_mqtt_target_table_columns` | 目标表字段列表 | 安全 |
| `create_mqtt_config` | 新增同步配置 | 中等 |
| `update_mqtt_config` | 修改同步配置 | 中等 |
| `delete_mqtt_config` | 删除同步配置 | **危险** |
| `refresh_mqtt_config_cache` | 刷新配置缓存 | 安全 |
--- ---
@@ -103,8 +150,11 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
### 环境参数 `target` ### 环境参数 `target`
- `prod` = 生产环境(正式数据,默认值 - `prod` = 生产环境(正式数据)
- `test` = 测试环境(试数据) - `test` = 测试环境(试数据)
> **重要**:表数据 CRUDquery/insert/update/delete/export的 `target` **默认 `test`**(安全优先)。要操作正式数据必须**显式传 `target="prod"`**。
> 例外:`execute_sql` 的 target 默认 `prod`(沿用原行为)。
### 主键 `primaryKey` ### 主键 `primaryKey`
@@ -120,6 +170,20 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
## ⚠️ 安全确认原则(必须遵守) ## ⚠️ 安全确认原则(必须遵守)
### 〇、目标资源不得擅自选择(最高优先级铁律)
定位操作目标的参数(`datasourceId` / `connectionId` / `databaseName` / `tableId` / `tableName` / `apiKeyId` / `skillId` 等),**永远不得猜测、编造或"随手挑一个"**
- **多个候选** → 列出全部,让用户选;严禁默认第一个或自认为"最合理"的那个。
- **唯一候选** → 先声明"我将使用 XXX",给用户否决机会;创建/写入/删除类仍需用户确认后执行。
- **没有候选 / 信息不足** → 直接问用户,不得编造。
> 当你发现自己正在"替用户决定用哪个数据源/数据库/表"时,立刻停下来改成提问。
**创建类操作**`create_table` / `create_datasource` 等)执行前必须逐项确认落点,绝不"随便找个数据源就建"。例如建表须确认:数据源(`connectionId`+ 数据库(`databaseName`+ 表名(`tableName`),三者都要用户点头。
---
### 一、执行工具前的风险评估 ### 一、执行工具前的风险评估
当执行以下类型的操作时,**必须先询问用户确认**,不得擅作主张: 当执行以下类型的操作时,**必须先询问用户确认**,不得擅作主张:
@@ -294,26 +358,36 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
**用户**: "查一下 users 表前 10 条数据" **用户**: "查一下 users 表前 10 条数据"
``` ```
调用: query_table_data(tableId="5", pageNum=1, pageSize=10) 调用: query_table_data(tableId="5", pageNum=1, pageSize=10) // target 默认 test查正式数据传 target="prod"
返回: { 返回: {
"columns": [{"name": "id", "type": "INTEGER"}, {"name": "username", "type": "VARCHAR"}, ...], "data": {
"data": [{"id": 1, "username": "admin", "email": "admin@test.com"}, ...], "tables": {
"tableName": "users",
"columns": [
{"columnName": "id", "columnType": "serial", "isPrimaryKey": true},
{"columnName": "username", "columnType": "varchar"}, ...
]
},
"content": [[1, "admin", "admin@test.com"], [2, "user1", "user1@test.com"], ...],
"total": 156 "total": 156
}
} }
回复: users 表共 156 条记录,当前显示第 1-10 条: 回复: users 表共 156 条记录,当前显示第 1-10 条:
| id | username | email | created_at | | id | username | email |
|----|----------|-----------------|---------------------| |----|----------|-----------------|
| 1 | admin | admin@test.com | 2024-01-15 10:30:00 | | 1 | admin | admin@test.com |
| 2 | user1 | user1@test.com | 2024-01-16 14:20:00 | | 2 | user1 | user1@test.com |
... ...
``` ```
> **返回结构注意**:列定义在 `data.tables.columns[]`(字段名 `columnName`、是否主键 `isPrimaryKey`),行数据在 `data.content[]` 且是**位置数组**(按列顺序,不是键值对象)。展示/取主键时要把 content 的每个位置对到 columns 的同序字段。
### 注意事项 ### 注意事项
- `target` 参数:`prod`=生产环境(默认),`test`=测试环境 - `target` 参数:`test`=测试环境(**默认**`prod`=生产环境(查正式数据须显式传)
- 如果用户未指定数量,默认 `pageSize=10` - 如果用户未指定数量,默认 `pageSize=10`
- 如果用户说"翻页",增加 `pageNum` 参数 - 如果用户说"翻页",增加 `pageNum` 参数
- 如果用户想看更多数据,可以增大 `pageSize`(最大根据 API 限制) - 如果用户想看更多数据,可以增大 `pageSize`(最大根据 API 限制)
- 数据返回格式为对象数组 - 数据是位置数组(`data.content[]`),需对照 `data.tables.columns[]` 的列顺序解析
--- ---
@@ -329,7 +403,7 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
1. 如果用户直接提供 SQL直接使用 1. 如果用户直接提供 SQL直接使用
如果用户提供自然语言需求,先转换为 SQL 如果用户提供自然语言需求,先转换为 SQL
2. 调用 execute_sql(datasourceId="xx", executableSql="SELECT ...") 2. 调用 execute_sql(datasourceId="xx", sql="SELECT ...")
3. 展示查询结果 3. 展示查询结果
@@ -345,7 +419,7 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
调用: execute_sql( 调用: execute_sql(
datasourceId="58", datasourceId="58",
executableSql="SELECT region, COUNT(*) as order_count FROM orders GROUP BY region ORDER BY order_count DESC" sql="SELECT region, COUNT(*) as order_count FROM orders GROUP BY region ORDER BY order_count DESC"
) )
返回: { 返回: {
"columns": [{"name": "region"}, {"name": "order_count"}], "columns": [{"name": "region"}, {"name": "order_count"}],
@@ -362,7 +436,8 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
### 注意事项 ### 注意事项
- `datasourceId` 必须提供,如果用户未指定,先询问 - `datasourceId` 必须提供,如果用户未指定,先询问
- `sqlTemplate` 可选,用于模板化查询 - `sqlTemplate` 可选,用于模板化查询
- `parameters` 可选,用于参数化查询(防止 SQL 注入) - `params` 可选(对象),用于参数化查询(防止 SQL 注入);工具内部映射为后端的 `parameters`
- `target` 可选:`test`=测试环境(默认),`prod`=生产环境
- **执行危险操作DELETE/DROP/TRUNCATE必须向用户确认** - **执行危险操作DELETE/DROP/TRUNCATE必须向用户确认**
- 如果查询结果超过 100 行,建议用户使用 `query_table_data` 代替 - 如果查询结果超过 100 行,建议用户使用 `query_table_data` 代替
- 对于只读查询SELECT可以直接执行对于写操作INSERT/UPDATE/DELETE必须二次确认 - 对于只读查询SELECT可以直接执行对于写操作INSERT/UPDATE/DELETE必须二次确认
@@ -497,7 +572,7 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
- `data` 只包含要更新的字段,不需要提供全部字段 - `data` 只包含要更新的字段,不需要提供全部字段
- 插入数据时,自增主键不需要提供 - 插入数据时,自增主键不需要提供
- 如果操作涉及多行,使用批量操作或循环调用 - 如果操作涉及多行,使用批量操作或循环调用
- 增删改操作默认作用于 `prod` 环境 - 增删改操作 `target` **默认 `test`**(安全优先);要写正式数据必须显式传 `target="prod"`
--- ---
@@ -511,12 +586,20 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
用户请求: "我需要一个用户表" / "帮我设计一个订单系统的表结构" 用户请求: "我需要一个用户表" / "帮我设计一个订单系统的表结构"
1. 调用 generate_table_by_description(requirement="用户描述") 1. 调用 generate_table_by_description(requirement="用户描述")
→ 返回 taskId异步任务此时还没有表结构
2. 展示 AI 生成的表结构(表名、字段、类型、注释 2. 轮询 get_ai_training_detail(taskId="xx") 直到 trainingStatus=2已完成
trainingStatus: 0 待训练 / 1 训练中 / 2 已完成 / 3 失败
完成后表结构在 createTableData.data.tables[] 里
3. 用户确认后,调用 create_table(connectionId="xx", databaseName="xx", tableName="xx", columns=[...]) 3. 展示 AI 生成的表结构(表名、字段、类型、注释)
4. 确认创建成功 4. ⚠️ 确认落点:列出可用数据源,让用户选择 connectionId 和 databaseName
(多个数据源时必须让用户选,绝不擅自找一个;只有一个也要说明后再继续)
5. 用户确认表结构 + 落点后,调用 create_table(connectionId="xx", databaseName="xx", tableName="xx", columns=[...])
6. 确认创建成功
``` ```
### 示例 ### 示例
@@ -527,28 +610,34 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
调用: generate_table_by_description( 调用: generate_table_by_description(
requirement="我需要一个商品表,包含商品名称、价格、库存、分类,用于电商系统" requirement="我需要一个商品表,包含商品名称、价格、库存、分类,用于电商系统"
) )
返回: { 返回: {"data": {"taskId": "831", "status": "0", "message": "任务已提交等待AI生成表结构"}}
← 异步!返回的是 taskId不是表结构
轮询: get_ai_training_detail(taskId="831")
返回(完成时): {
"data": {
"trainingStatus": "2", // 2=已完成
"progress": 100,
"createTableData": {"data": {"tables": [{
"tableName": "products", "tableName": "products",
"tableComment": "商品表", "tableComment": "商品表",
"columns": [ "columns": [
{"columnName": "id", "columnType": "SERIAL", "isPrimaryKey": true, "isAutoIncrement": true, "columnComment": "主键ID"}, {"columnName": "id", "columnType": "SERIAL", "isPrimaryKey": true, "columnComment": "主键ID"},
{"columnName": "name", "columnType": "VARCHAR", "columnLength": 200, "isNullable": false, "columnComment": "商品名称"}, {"columnName": "name", "columnType": "VARCHAR", "columnLength": 200, "isNullable": false, "columnComment": "商品名称"},
{"columnName": "price", "columnType": "DECIMAL", "columnLength": 10, "columnComment": "价格"}, {"columnName": "price", "columnType": "DECIMAL", "columnLength": 10, "columnComment": "价格"},
{"columnName": "stock", "columnType": "INTEGER", "columnComment": "库存数量"}, {"columnName": "stock", "columnType": "INTEGER", "columnComment": "库存数量"}
{"columnName": "category_id", "columnType": "INTEGER", "columnComment": "分类ID"},
{"columnName": "created_at", "columnType": "TIMESTAMP", "defaultValue": "NOW()", "columnComment": "创建时间"}
] ]
}]}}
}
} }
回复: AI 已生成表结构设计: 回复: AI 已生成表结构设计:
表名: products (商品表) 表名: products (商品表)
字段: 字段:
- id (SERIAL, 主键, 自增) - 主键ID - id (SERIAL, 主键) - 主键ID
- name (VARCHAR(200), 非空) - 商品名称 - name (VARCHAR(200), 非空) - 商品名称
- price (DECIMAL(10)) - 价格 - price (DECIMAL(10)) - 价格
- stock (INTEGER) - 库存数量 - stock (INTEGER) - 库存数量
- category_id (INTEGER) - 分类ID
- created_at (TIMESTAMP, 默认NOW()) - 创建时间
是否需要在此基础上修改或直接创建? 是否需要在此基础上修改或直接创建?
@@ -559,7 +648,7 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
databaseName="order_db", databaseName="order_db",
tableName="products", tableName="products",
tableComment="商品表", tableComment="商品表",
columns=[...] // 使用 AI 生成的 columns columns=[...] // 使用轮询拿到的 createTableData.data.tables[0].columns
) )
回复: 已成功创建表 products (商品表) 回复: 已成功创建表 products (商品表)
``` ```
@@ -580,15 +669,13 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
4. 确认修改成功 4. 确认修改成功
``` ```
**可用的 operations 类型** **可用的 operations 类型(真机验证,后端仅支持这 3 种)**
- `ADD_COLUMN`:添加字段 - `ADD_COLUMN`:添加字段
- `MODIFY_COLUMN`:修改字段(改类型/长度/可空/默认值/注释,整列重定义)
- `DROP_COLUMN`:删除字段 - `DROP_COLUMN`:删除字段
- `RENAME_COLUMN`:重命名字段
- `ALTER_COLUMN_TYPE`:修改字段类型 > ⚠️ 后端**不支持** `RENAME_COLUMN`/`ALTER_COLUMN_TYPE`/`SET_NOT_NULL`/`DROP_NOT_NULL`/`SET_DEFAULT`/`DROP_DEFAULT`(会报「不支持的操作类型」)。改字段类型/约束统一用 `MODIFY_COLUMN` 传完整列定义。
- `SET_NOT_NULL`:设置为非空 > `operations[].column` 是列定义对象字段同建表columnName/columnType/columnLength/isNullable/columnComment/defaultValue 等)。
- `DROP_NOT_NULL`:取消非空约束
- `SET_DEFAULT`:设置默认值
- `DROP_DEFAULT`:删除默认值
### 注意事项 ### 注意事项
- `requirement` 参数应尽可能详细,包含业务场景和字段需求 - `requirement` 参数应尽可能详细,包含业务场景和字段需求
@@ -608,9 +695,9 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
``` ```
用户请求: "帮我导入这个 Excel 文件" / "把表格数据导入数据库" 用户请求: "帮我导入这个 Excel 文件" / "把表格数据导入数据库"
1. 用户将 Excel 文件转为 base64 编码 1. 拿到 Excel 文件的可下载 URLhttp/https<500KB.xlsx/.xls
2. 调用 preview_import_data(connectionId="xx", file_base64="...", file_name="data.xlsx", target="test") 2. 调用 preview_import_data(connectionId="xx", file_url="https://example.com/data.xlsx", target="test")
3. 展示 AI 识别的表结构和数据预览 3. 展示 AI 识别的表结构和数据预览
@@ -618,9 +705,10 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
说明:导入的数据将写入数据库,请确认数据来源合法合规,不包含敏感信息、政治内容或违规内容。 说明:导入的数据将写入数据库,请确认数据来源合法合规,不包含敏感信息、政治内容或违规内容。
请确认是否继续? 请确认是否继续?
5. 用户确认无误后,调用 confirm_import_data(connectionId="xx", data={...}, target="test") 5. 用户确认无误后,调用 confirm_import_data(connectionId="xx", databaseName="目标库名", data=<预览返回的原文>, target="test")
(把 preview_import_data 的返回原样传给 data工具会自动组装后端要求的结构databaseName 是落库的目标库)
6. 确认导入成功 6. 确认导入成功(返回含 insertedRows 插入行数)
``` ```
### 注意事项 ### 注意事项
@@ -628,7 +716,10 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
- 支持格式:.xlsx / .xls - 支持格式:.xlsx / .xls
- 导入前默认使用 `test` 环境(安全做法) - 导入前默认使用 `test` 环境(安全做法)
- 如果用户要导入到正式环境,必须二次确认 - 如果用户要导入到正式环境,必须二次确认
- base64 编码的文件内容需要提供文件名 - `file_url` 要求 http/https 可下载链接,且文件大小 < 500KB
- `file_url` 对应的文件扩展名需为 `.xlsx` / `.xls`
- **`confirm_import_data` 必须传 `databaseName`**(落库目标库);`data` 直接传 `preview_import_data` 的返回原文即可,工具内部会自动解包并组装成 `{tableStructure(含databaseName), allData}`
- AI 识别会把中文表头转成英文列名(如「姓名」→`name`);若导入数据键名与生成的列名对不上会报「未找到 XX 字段」,此时需按预览返回的列名核对
--- ---
@@ -707,6 +798,23 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
- `database`:数据库级别权限 - `database`:数据库级别权限
- `table`:表级别权限 - `table`:表级别权限
### 7.7 撤销权限
```
调用: get_api_key_permissions(apiKeyId="7")
返回: {
"data": {
"connectionPermissions": [{"id": "101", "connectionId": "58", "permissionType": "read"}],
"databasePermissions": [...],
"tablePermissions": [...]
}
}
调用: revoke_api_key_permissions(permissionIds=["101"])
```
> 说明:`revoke_api_key_permissions` 按权限记录的 `id` 删除,需先从 `get_api_key_permissions` 获取。
--- ---
## 场景 8技能与工具管理 ## 场景 8技能与工具管理
@@ -768,6 +876,20 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
) )
``` ```
### 8.7 修改技能下某个工具
```
调用: update_skill_tool(
id="2066468871151718402", // 工具 ID来自 get_skill_tools 返回的 id
description="改后的业务描述", // 可选
uniqueName="新展示名", // 可选(工具展示名)
sqlTemplate="SELECT ...", // 可选
resultType="list" // 可选single / list
)
```
> 说明:后端实体真实字段为 `id`/`uniqueName`/`description`/`sqlTemplate`/`resultType`。旧参数名 `skillToolId`/`name`/`businessDescription` 仍有兼容映射,但建议直接用上面的真实字段名。`businessScenario` 后端无此字段,会被丢弃。
--- ---
## 场景 9表订阅管理 ## 场景 9表订阅管理
@@ -778,13 +900,117 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
调用: toggle_table_subscription( 调用: toggle_table_subscription(
configId="数据库配置ID", configId="数据库配置ID",
tableName="orders", tableName="orders",
isSubscribe=true // true=订阅, false=取消订阅 subscribe=true // true=订阅, false=取消订阅
) )
``` ```
--- ---
## 最佳实践 ## 场景 10库表关联配置外置源纳管 / 内置删库)
「库表关联配置」是数据源能用于智能问数的**最后一步**:外置数据源建好连接后,必须选定要纳管的库与表,平台才会同步结构、允许后续问数。这一类有 3 个工具:`create_connection_config` / `update_connection_config` / `delete_connection_config`
> **概念澄清**
> - 配置config≠ 连接connection。`create_connection_config` 落库后产生的 **config id**,正是 `list_databases` / `list_tables_with_ai` 里要传的那个「库/配置 ID」`datasourceId` 参数实际指它,不是连接 ID
> - `delete_connection_config` 既是「删库表关联」,也是**「删除内置库」的唯一实现**——删内置库就是删掉它对应的那条配置。
### 10.1 外置源纳管(建连接后的收尾步骤)
```
用户请求: "把刚建的 MySQL 数据源接进来" / "让这个数据源能问数"
1. 已有外置连接create_datasource 返回的 connectionId
2. 调用 get_realtime_structure(datasourceId="连接ID") 探测真实库表
3. ⚠️ 列出探测到的库和表,让用户勾选要纳管的范围
(多个库/表时必须让用户选,不得擅自全选或挑第一个)
4. 调用 create_connection_config(connectionId="xx", databases=[{databaseName, tableNames:[...]}])
5. 确认纳管成功(返回 config id后续 list_tables_with_ai 用它)
```
**示例**:
**用户**: "把 mysql_test 数据源的 order_db 库接进来,纳管 orders 和 users 两张表"
```
调用: get_realtime_structure(datasourceId="58") // 先确认库表真实存在
调用: create_connection_config(
connectionId="58",
syncTables=true, // 默认 true同步表结构
databases=[
{"databaseName": "order_db", "tableNames": ["orders", "users"]}
]
)
回复: 已纳管 order_db 库的 orders、users 两张表,该数据源现在可用于智能问数。
```
### 10.2 修改纳管范围
```
用户请求: "再把 products 表也纳管进来" / "改一下纳管的表"
1. 先拿到该配置的 config idlist_databases 返回里的 id
2. ⚠️ 与用户确认最终要纳管的完整库表清单(这是全量覆盖,不是增量追加)
3. 调用 update_connection_config(id="配置ID", connectionId="xx", databases=[...])
4. 确认更新成功
```
> ⚠️ **全量覆盖语义**`databases` 传的是更新后的**完整**纳管范围,不是增量。漏传已有的表会导致那些表被移出纳管,务必带上全部要保留的表。
**示例**:
```
调用: update_connection_config(
id="123", // 配置 ID编辑必传
connectionId="58",
databases=[
{"databaseName": "order_db", "tableNames": ["orders", "users", "products"]}
]
)
回复: 已更新纳管范围order_db 现纳管 orders、users、products 三张表。
```
### 10.3 删除配置(含删除内置库)
```
⚠️ 安全提醒:此操作存在删除风险。
说明:删除库表关联配置后,该库表将退出纳管、无法再问数;
若目标是内置库,则该库本身会被删除,库内数据永久丢失,不可恢复。
请确认是否继续?(回复"确认"继续,或取消操作)
用户确认后,调用 delete_connection_config(ids=["配置ID1", "配置ID2"])
```
**示例**:
**用户**: "删掉那个测试内置库"
```
回复: ⚠️ 确认要删除内置库「test_db」配置 ID=456
库内全部数据将永久删除,不可恢复。请回复"确认删除"继续。
用户: "确认删除"
调用: delete_connection_config(ids=["456"])
回复: 已删除配置 456内置库 test_db
```
### 注意事项
- `databases` 每项是 `{databaseName, tableNames:[...]}``tableNames` 是表名字符串数组(不是 tableId
- `syncTables` 默认 `true`(纳管时同步表结构),一般无需手动传
- `update_connection_config` 必传 `id`(配置 ID`databases` 为**全量**覆盖
- `delete_connection_config``ids` 是数组,支持批量删;删内置库即删其对应配置 ID
- 内置库删除属**危险操作**,务必先确认库名与影响后再执行
---
### 1. 安全第一 ### 1. 安全第一
@@ -839,8 +1065,8 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
- `1` - 已停止 - `1` - 已停止
### 环境 ### 环境
- `prod` - 生产环境(默认 - `test` - 测试环境(**表数据 CRUD 的默认值**,安全优先
- `test` - 测试环境 - `prod` - 生产环境(操作正式数据须显式传 `target="prod"`;仅 `execute_sql` 默认 prod
### 常用字段类型 ### 常用字段类型
| 类型 | 用途 | 示例 | | 类型 | 用途 | 示例 |
@@ -854,16 +1080,13 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
| `BOOLEAN` | 布尔值 | `is_active BOOLEAN` | | `BOOLEAN` | 布尔值 | `is_active BOOLEAN` |
### 表结构变更操作类型alter_table ### 表结构变更操作类型alter_table
| 类型 | 用途 | | 类型 | 用途 | column 对象关键字段 |
|------|------| |------|------|------|
| `ADD_COLUMN` | 添加字段 | | `ADD_COLUMN` | 添加字段 | columnName, columnType, columnLength, isNullable, columnComment |
| `DROP_COLUMN` | 删除字段 | | `MODIFY_COLUMN` | 修改字段(类型/长度/约束等,含改类型) | columnName, columnType, columnLength, ... |
| `RENAME_COLUMN` | 重命名字段 | | `DROP_COLUMN` | 删除字段 | columnName |
| `ALTER_COLUMN_TYPE` | 修改字段类型 |
| `SET_NOT_NULL` | 设置为非空 | > 后端**只支持上述 3 种**。`RENAME_COLUMN`/`ALTER_COLUMN_TYPE`/`SET_NOT_NULL`/`DROP_NOT_NULL`/`SET_DEFAULT`/`DROP_DEFAULT` 均会报「不支持的操作类型」(已真机验证)。改字段类型用 `MODIFY_COLUMN`。
| `DROP_NOT_NULL` | 取消非空约束 |
| `SET_DEFAULT` | 设置默认值 |
| `DROP_DEFAULT` | 删除默认值 |
### 权限级别API 密钥授权) ### 权限级别API 密钥授权)
| 级别 | 范围 | | 级别 | 范围 |
@@ -879,7 +1102,7 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
如果用户说"帮我查一下数据库",按以下步骤操作: 如果用户说"帮我查一下数据库",按以下步骤操作:
1. 调用 `list_datasources()` 获取数据源列表 1. 调用 `list_datasources()` 获取数据源列表
2. 展示列表,让用户选择或默认第一个运行中的数据源 2. 展示列表,让用户选择目标数据源(**多个候选时必须让用户选,不得默认第一个**;只有一个候选也要先说明"我将使用 XXX"再继续)
3. 调用 `get_datasource_detail(datasourceId="xx")` 获取数据库和表信息 3. 调用 `get_datasource_detail(datasourceId="xx")` 获取数据库和表信息
4. 引导用户选择要操作的表 4. 引导用户选择要操作的表
5. 根据用户意图调用相应的工具: 5. 根据用户意图调用相应的工具:

Binary file not shown.

View File

@@ -1,6 +1,6 @@
# lzwcai-mcp-agile-db # lzwcai-mcp-agile-db
数据库管理平台 MCP Server提供 33 个工具用于数据库管理、表操作、数据 CRUD、API 密钥管理、技能与工具管理等。 数据库管理平台 MCP Server提供 34 个工具用于数据库管理、表操作、数据 CRUD、API 密钥管理、技能与工具管理等。
## 环境变量 ## 环境变量
@@ -58,6 +58,7 @@ lzwcai-mcp-agile-db
- `delete_api_key` - 删除密钥 - `delete_api_key` - 删除密钥
- `get_api_key_permissions` - 查看密钥权限 - `get_api_key_permissions` - 查看密钥权限
- `grant_api_key_permissions` - 授予权限 - `grant_api_key_permissions` - 授予权限
- `revoke_api_key_permissions` - 撤销/删除已授予权限
### 技能与工具管理 ### 技能与工具管理
- `get_skill_by_datasource` - 获取技能信息 - `get_skill_by_datasource` - 获取技能信息

View File

@@ -1,202 +1,10 @@
2026-06-11 09:30:49 - root - INFO - [logger_config.py:102] - 日志系统初始化完成 - 日志目录: E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcp_agile_db\lzwcai_mcp_agile_db\logs 2026-06-17 11:19:35 - root - INFO - [logger_config.py:102] - 日志系统初始化完成 - 日志目录: E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcp_agile_db\lzwcai_mcp_agile_db\logs
2026-06-11 09:30:49 - mcp.server.lowlevel.server - DEBUG - [server.py:162] - Initializing server 'lzwcai_mcp_agile_db' 2026-06-17 11:19:35 - mcp.server.lowlevel.server - DEBUG - [server.py:162] - Initializing server 'lzwcai_mcp_agile_db'
2026-06-11 09:30:49 - mcp.server.lowlevel.server - DEBUG - [server.py:439] - Registering handler for ListToolsRequest 2026-06-17 11:19:35 - mcp.server.lowlevel.server - DEBUG - [server.py:439] - Registering handler for ListToolsRequest
2026-06-11 09:30:49 - mcp.server.lowlevel.server - DEBUG - [server.py:519] - Registering handler for CallToolRequest 2026-06-17 11:19:35 - mcp.server.lowlevel.server - DEBUG - [server.py:519] - Registering handler for CallToolRequest
2026-06-11 09:30:49 - lzwcai_mcp_agile_db.server - INFO - [server.py:124] - ================================================== 2026-06-17 11:19:35 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:48] - [客户端初始化] base_url=http://x
2026-06-11 09:30:49 - lzwcai_mcp_agile_db.server - INFO - [server.py:125] - lzwcai-mcp-agile-db MCP Server 启动 2026-06-17 11:19:35 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:78] - [API响应] HTTP 400
2026-06-11 09:30:49 - lzwcai_mcp_agile_db.server - INFO - [server.py:130] - 已注册工具数量: 33 2026-06-17 11:19:35 - lzwcai_mcp_agile_db.utils.api_client - ERROR - [api_client.py:98] - [API错误] HTTP 400, 参数不合法
2026-06-11 09:30:49 - lzwcai_mcp_agile_db.server - INFO - [server.py:131] - ================================================== 2026-06-17 11:19:35 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:78] - [API响应] HTTP 200
2026-06-11 09:30:49 - lzwcai_mcp_agile_db.server - INFO - [server.py:133] - 开始运行 MCP Server (stdio 模式) 2026-06-17 11:19:35 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:78] - [API响应] HTTP 404
2026-06-11 09:30:49 - asyncio - DEBUG - [proactor_events.py:634] - Using proactor: IocpProactor 2026-06-17 11:19:35 - lzwcai_mcp_agile_db.utils.api_client - ERROR - [api_client.py:107] - [API错误] HTTP 404, {"path": "/x"}
2026-06-11 09:30:49 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: root=InitializedNotification(method='notifications/initialized', params=None, jsonrpc='2.0')
2026-06-11 09:30:50 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: <mcp.shared.session.RequestResponder object at 0x0000025426747BF0>
2026-06-11 09:30:50 - mcp.server.lowlevel.server - INFO - [server.py:720] - Processing request of type ListToolsRequest
2026-06-11 09:30:50 - mcp.server.lowlevel.server - DEBUG - [server.py:723] - Dispatching request of type ListToolsRequest
2026-06-11 09:30:50 - lzwcai_mcp_agile_db.server - INFO - [server.py:42] - 收到 ListTools 请求
2026-06-11 09:30:50 - mcp.server.lowlevel.server - DEBUG - [server.py:790] - Response sent
2026-06-11 09:33:24 - root - INFO - [logger_config.py:102] - 日志系统初始化完成 - 日志目录: E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcp_agile_db\lzwcai_mcp_agile_db\logs
2026-06-11 09:33:24 - mcp.server.lowlevel.server - DEBUG - [server.py:162] - Initializing server 'lzwcai_mcp_agile_db'
2026-06-11 09:33:24 - mcp.server.lowlevel.server - DEBUG - [server.py:439] - Registering handler for ListToolsRequest
2026-06-11 09:33:24 - mcp.server.lowlevel.server - DEBUG - [server.py:519] - Registering handler for CallToolRequest
2026-06-11 09:33:24 - lzwcai_mcp_agile_db.server - INFO - [server.py:124] - ==================================================
2026-06-11 09:33:24 - lzwcai_mcp_agile_db.server - INFO - [server.py:125] - lzwcai-mcp-agile-db MCP Server 启动
2026-06-11 09:33:24 - lzwcai_mcp_agile_db.server - INFO - [server.py:130] - 已注册工具数量: 33
2026-06-11 09:33:24 - lzwcai_mcp_agile_db.server - INFO - [server.py:131] - ==================================================
2026-06-11 09:33:24 - lzwcai_mcp_agile_db.server - INFO - [server.py:133] - 开始运行 MCP Server (stdio 模式)
2026-06-11 09:33:24 - asyncio - DEBUG - [proactor_events.py:634] - Using proactor: IocpProactor
2026-06-11 09:33:24 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: root=InitializedNotification(method='notifications/initialized', params=None, jsonrpc='2.0')
2026-06-11 09:33:25 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: <mcp.shared.session.RequestResponder object at 0x0000020155C7D190>
2026-06-11 09:33:25 - mcp.server.lowlevel.server - INFO - [server.py:720] - Processing request of type ListToolsRequest
2026-06-11 09:33:25 - mcp.server.lowlevel.server - DEBUG - [server.py:723] - Dispatching request of type ListToolsRequest
2026-06-11 09:33:25 - lzwcai_mcp_agile_db.server - INFO - [server.py:42] - 收到 ListTools 请求
2026-06-11 09:33:25 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:48] - [客户端初始化] base_url=https://dempdemo.lzwcai.com
2026-06-11 09:33:25 - lzwcai_mcp_agile_db.server - INFO - [server.py:56] - ListTools 响应: 返回 33 个工具
2026-06-11 09:33:25 - mcp.server.lowlevel.server - DEBUG - [server.py:790] - Response sent
2026-06-11 09:33:45 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: <mcp.shared.session.RequestResponder object at 0x0000020155844410>
2026-06-11 09:33:45 - mcp.server.lowlevel.server - INFO - [server.py:720] - Processing request of type CallToolRequest
2026-06-11 09:33:45 - mcp.server.lowlevel.server - DEBUG - [server.py:723] - Dispatching request of type CallToolRequest
2026-06-11 09:33:45 - lzwcai_mcp_agile_db.server - INFO - [server.py:66] - 收到 CallTool 请求: name=list_datasources
2026-06-11 09:33:46 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:100] - [API请求] GET https://dempdemo.lzwcai.com/api/datasource/connection/list
2026-06-11 09:33:46 - httpx - DEBUG - [_config.py:82] - load_ssl_context verify=True cert=None trust_env=True http2=False
2026-06-11 09:33:46 - httpx - DEBUG - [_config.py:148] - load_verify_locations cafile='D:\\anaconda3\\Library\\ssl\\cacert.pem'
2026-06-11 09:33:46 - httpcore.connection - DEBUG - [_trace.py:47] - connect_tcp.started host='dempdemo.lzwcai.com' port=443 local_address=None timeout=30.0 socket_options=None
2026-06-11 09:33:46 - httpcore.connection - DEBUG - [_trace.py:47] - connect_tcp.complete return_value=<httpcore._backends.sync.SyncStream object at 0x0000020155E9D8E0>
2026-06-11 09:33:46 - httpcore.connection - DEBUG - [_trace.py:47] - start_tls.started ssl_context=<ssl.SSLContext object at 0x0000020155E740D0> server_hostname='dempdemo.lzwcai.com' timeout=30.0
2026-06-11 09:33:46 - httpcore.connection - DEBUG - [_trace.py:47] - start_tls.complete return_value=<httpcore._backends.sync.SyncStream object at 0x0000020155D4CFE0>
2026-06-11 09:33:46 - httpcore.http11 - DEBUG - [_trace.py:47] - send_request_headers.started request=<Request [b'GET']>
2026-06-11 09:33:46 - httpcore.http11 - DEBUG - [_trace.py:47] - send_request_headers.complete
2026-06-11 09:33:46 - httpcore.http11 - DEBUG - [_trace.py:47] - send_request_body.started request=<Request [b'GET']>
2026-06-11 09:33:46 - httpcore.http11 - DEBUG - [_trace.py:47] - send_request_body.complete
2026-06-11 09:33:46 - httpcore.http11 - DEBUG - [_trace.py:47] - receive_response_headers.started request=<Request [b'GET']>
2026-06-11 09:33:46 - httpcore.http11 - DEBUG - [_trace.py:47] - receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'', [(b'Server', b'nginx/1.25.2'), (b'Date', b'Thu, 11 Jun 2026 01:33:40 GMT'), (b'Content-Type', b'application/json;charset=utf-8'), (b'Content-Length', b'51'), (b'Connection', b'keep-alive'), (b'Vary', b'Origin'), (b'Vary', b'Access-Control-Request-Method'), (b'Vary', b'Access-Control-Request-Headers'), (b'X-Content-Type-Options', b'nosniff'), (b'X-XSS-Protection', b'1; mode=block'), (b'X-Frame-Options', b'SAMEORIGIN'), (b'Strict-Transport-Security', b'max-age=31536000; includeSubDomains'), (b'X-Frame-Options', b'DENY'), (b'X-Content-Type-Options', b'nosniff'), (b'X-XSS-Protection', b'1; mode=block')])
2026-06-11 09:33:46 - httpx - INFO - [_client.py:1038] - HTTP Request: GET https://dempdemo.lzwcai.com/api/datasource/connection/list?pageNum=1&pageSize=20 "HTTP/1.1 200 "
2026-06-11 09:33:46 - httpcore.http11 - DEBUG - [_trace.py:47] - receive_response_body.started request=<Request [b'GET']>
2026-06-11 09:33:46 - httpcore.http11 - DEBUG - [_trace.py:47] - receive_response_body.complete
2026-06-11 09:33:46 - httpcore.http11 - DEBUG - [_trace.py:47] - response_closed.started
2026-06-11 09:33:46 - httpcore.http11 - DEBUG - [_trace.py:47] - response_closed.complete
2026-06-11 09:33:46 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:74] - [API响应] HTTP 200
2026-06-11 09:33:46 - lzwcai_mcp_agile_db.utils.api_client - ERROR - [api_client.py:91] - [API错误] 登录过期,请重新登录
2026-06-11 09:33:46 - lzwcai_mcp_agile_db.server - ERROR - [server.py:96] - 工具执行失败: list_datasources, 错误: 登录过期,请重新登录
Traceback (most recent call last):
File "E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcp_agile_db\lzwcai_mcp_agile_db\server.py", line 84, in handle_call_tool
result = await tool_instance.execute(arguments or {})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcp_agile_db\lzwcai_mcp_agile_db\tools\datasources.py", line 30, in execute
return self.client.get("/api/datasource/connection/list", params=params)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcp_agile_db\lzwcai_mcp_agile_db\utils\api_client.py", line 102, in get
return self._handle_response(response, url)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcp_agile_db\lzwcai_mcp_agile_db\utils\api_client.py", line 92, in _handle_response
raise Exception(error_msg)
Exception: 登录过期,请重新登录
2026-06-11 09:33:46 - mcp.server.lowlevel.server - DEBUG - [server.py:790] - Response sent
2026-06-11 09:39:21 - root - INFO - [logger_config.py:102] - 日志系统初始化完成 - 日志目录: E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcp_agile_db\lzwcai_mcp_agile_db\logs
2026-06-11 09:39:21 - mcp.server.lowlevel.server - DEBUG - [server.py:162] - Initializing server 'lzwcai_mcp_agile_db'
2026-06-11 09:39:21 - mcp.server.lowlevel.server - DEBUG - [server.py:439] - Registering handler for ListToolsRequest
2026-06-11 09:39:21 - mcp.server.lowlevel.server - DEBUG - [server.py:519] - Registering handler for CallToolRequest
2026-06-11 09:39:21 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:48] - [客户端初始化] base_url=https://dempdemo.lzwcai.com
2026-06-11 09:39:21 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:100] - [API请求] GET https://dempdemo.lzwcai.com/api/datasource/connection/list
2026-06-11 09:39:21 - httpx - DEBUG - [_config.py:82] - load_ssl_context verify=True cert=None trust_env=True http2=False
2026-06-11 09:39:21 - httpx - DEBUG - [_config.py:148] - load_verify_locations cafile='C:\\Users\\HiWin10\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\certifi\\cacert.pem'
2026-06-11 09:39:21 - httpcore.connection - DEBUG - [_trace.py:47] - connect_tcp.started host='dempdemo.lzwcai.com' port=443 local_address=None timeout=30.0 socket_options=None
2026-06-11 09:39:21 - httpcore.connection - DEBUG - [_trace.py:47] - connect_tcp.complete return_value=<httpcore._backends.sync.SyncStream object at 0x0000027729C00710>
2026-06-11 09:39:21 - httpcore.connection - DEBUG - [_trace.py:47] - start_tls.started ssl_context=<ssl.SSLContext object at 0x0000027729DFE9D0> server_hostname='dempdemo.lzwcai.com' timeout=30.0
2026-06-11 09:39:21 - httpcore.connection - DEBUG - [_trace.py:47] - start_tls.complete return_value=<httpcore._backends.sync.SyncStream object at 0x0000027729C23170>
2026-06-11 09:39:21 - httpcore.http11 - DEBUG - [_trace.py:47] - send_request_headers.started request=<Request [b'GET']>
2026-06-11 09:39:21 - httpcore.http11 - DEBUG - [_trace.py:47] - send_request_headers.complete
2026-06-11 09:39:21 - httpcore.http11 - DEBUG - [_trace.py:47] - send_request_body.started request=<Request [b'GET']>
2026-06-11 09:39:21 - httpcore.http11 - DEBUG - [_trace.py:47] - send_request_body.complete
2026-06-11 09:39:21 - httpcore.http11 - DEBUG - [_trace.py:47] - receive_response_headers.started request=<Request [b'GET']>
2026-06-11 09:39:21 - httpcore.http11 - DEBUG - [_trace.py:47] - receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'', [(b'Server', b'nginx/1.25.2'), (b'Date', b'Thu, 11 Jun 2026 01:39:15 GMT'), (b'Content-Type', b'application/json'), (b'Transfer-Encoding', b'chunked'), (b'Connection', b'keep-alive'), (b'Vary', b'Origin'), (b'Vary', b'Access-Control-Request-Method'), (b'Vary', b'Access-Control-Request-Headers'), (b'X-Content-Type-Options', b'nosniff'), (b'X-XSS-Protection', b'1; mode=block'), (b'X-Frame-Options', b'SAMEORIGIN'), (b'Strict-Transport-Security', b'max-age=31536000; includeSubDomains'), (b'X-Frame-Options', b'DENY'), (b'X-Content-Type-Options', b'nosniff'), (b'X-XSS-Protection', b'1; mode=block')])
2026-06-11 09:39:21 - httpx - INFO - [_client.py:1038] - HTTP Request: GET https://dempdemo.lzwcai.com/api/datasource/connection/list "HTTP/1.1 200 "
2026-06-11 09:39:21 - httpcore.http11 - DEBUG - [_trace.py:47] - receive_response_body.started request=<Request [b'GET']>
2026-06-11 09:39:21 - httpcore.http11 - DEBUG - [_trace.py:47] - receive_response_body.complete
2026-06-11 09:39:21 - httpcore.http11 - DEBUG - [_trace.py:47] - response_closed.started
2026-06-11 09:39:21 - httpcore.http11 - DEBUG - [_trace.py:47] - response_closed.complete
2026-06-11 09:39:21 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:74] - [API响应] HTTP 200
2026-06-11 09:39:52 - root - INFO - [logger_config.py:102] - 日志系统初始化完成 - 日志目录: E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcp_agile_db\lzwcai_mcp_agile_db\logs
2026-06-11 09:39:52 - mcp.server.lowlevel.server - DEBUG - [server.py:162] - Initializing server 'lzwcai_mcp_agile_db'
2026-06-11 09:39:52 - mcp.server.lowlevel.server - DEBUG - [server.py:439] - Registering handler for ListToolsRequest
2026-06-11 09:39:52 - mcp.server.lowlevel.server - DEBUG - [server.py:519] - Registering handler for CallToolRequest
2026-06-11 09:39:52 - lzwcai_mcp_agile_db.server - INFO - [server.py:124] - ==================================================
2026-06-11 09:39:52 - lzwcai_mcp_agile_db.server - INFO - [server.py:125] - lzwcai-mcp-agile-db MCP Server 启动
2026-06-11 09:39:52 - lzwcai_mcp_agile_db.server - INFO - [server.py:130] - 已注册工具数量: 33
2026-06-11 09:39:52 - lzwcai_mcp_agile_db.server - INFO - [server.py:131] - ==================================================
2026-06-11 09:39:52 - lzwcai_mcp_agile_db.server - INFO - [server.py:133] - 开始运行 MCP Server (stdio 模式)
2026-06-11 09:39:52 - asyncio - DEBUG - [proactor_events.py:634] - Using proactor: IocpProactor
2026-06-11 09:39:52 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: root=InitializedNotification(method='notifications/initialized', params=None, jsonrpc='2.0')
2026-06-11 09:39:53 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: <mcp.shared.session.RequestResponder object at 0x000002B57D06C860>
2026-06-11 09:39:53 - mcp.server.lowlevel.server - INFO - [server.py:720] - Processing request of type ListToolsRequest
2026-06-11 09:39:53 - mcp.server.lowlevel.server - DEBUG - [server.py:723] - Dispatching request of type ListToolsRequest
2026-06-11 09:39:53 - lzwcai_mcp_agile_db.server - INFO - [server.py:42] - 收到 ListTools 请求
2026-06-11 09:39:53 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:48] - [客户端初始化] base_url=https://dempdemo.lzwcai.com
2026-06-11 09:39:53 - lzwcai_mcp_agile_db.server - INFO - [server.py:56] - ListTools 响应: 返回 33 个工具
2026-06-11 09:39:53 - mcp.server.lowlevel.server - DEBUG - [server.py:790] - Response sent
2026-06-11 09:40:08 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: <mcp.shared.session.RequestResponder object at 0x000002B57D188A40>
2026-06-11 09:40:08 - mcp.server.lowlevel.server - INFO - [server.py:720] - Processing request of type CallToolRequest
2026-06-11 09:40:08 - mcp.server.lowlevel.server - DEBUG - [server.py:723] - Dispatching request of type CallToolRequest
2026-06-11 09:40:08 - lzwcai_mcp_agile_db.server - INFO - [server.py:66] - 收到 CallTool 请求: name=list_datasources
2026-06-11 09:40:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:100] - [API请求] GET https://dempdemo.lzwcai.com/api/datasource/connection/list
2026-06-11 09:40:08 - httpx - DEBUG - [_config.py:82] - load_ssl_context verify=True cert=None trust_env=True http2=False
2026-06-11 09:40:08 - httpx - DEBUG - [_config.py:148] - load_verify_locations cafile='D:\\anaconda3\\Library\\ssl\\cacert.pem'
2026-06-11 09:40:08 - httpcore.connection - DEBUG - [_trace.py:47] - connect_tcp.started host='dempdemo.lzwcai.com' port=443 local_address=None timeout=30.0 socket_options=None
2026-06-11 09:40:08 - httpcore.connection - DEBUG - [_trace.py:47] - connect_tcp.complete return_value=<httpcore._backends.sync.SyncStream object at 0x000002B57D28C560>
2026-06-11 09:40:08 - httpcore.connection - DEBUG - [_trace.py:47] - start_tls.started ssl_context=<ssl.SSLContext object at 0x000002B57D2642D0> server_hostname='dempdemo.lzwcai.com' timeout=30.0
2026-06-11 09:40:08 - httpcore.connection - DEBUG - [_trace.py:47] - start_tls.complete return_value=<httpcore._backends.sync.SyncStream object at 0x000002B57CF80410>
2026-06-11 09:40:08 - httpcore.http11 - DEBUG - [_trace.py:47] - send_request_headers.started request=<Request [b'GET']>
2026-06-11 09:40:08 - httpcore.http11 - DEBUG - [_trace.py:47] - send_request_headers.complete
2026-06-11 09:40:08 - httpcore.http11 - DEBUG - [_trace.py:47] - send_request_body.started request=<Request [b'GET']>
2026-06-11 09:40:08 - httpcore.http11 - DEBUG - [_trace.py:47] - send_request_body.complete
2026-06-11 09:40:08 - httpcore.http11 - DEBUG - [_trace.py:47] - receive_response_headers.started request=<Request [b'GET']>
2026-06-11 09:40:08 - httpcore.http11 - DEBUG - [_trace.py:47] - receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'', [(b'Server', b'nginx/1.25.2'), (b'Date', b'Thu, 11 Jun 2026 01:40:02 GMT'), (b'Content-Type', b'application/json'), (b'Transfer-Encoding', b'chunked'), (b'Connection', b'keep-alive'), (b'Vary', b'Origin'), (b'Vary', b'Access-Control-Request-Method'), (b'Vary', b'Access-Control-Request-Headers'), (b'X-Content-Type-Options', b'nosniff'), (b'X-XSS-Protection', b'1; mode=block'), (b'X-Frame-Options', b'SAMEORIGIN'), (b'Strict-Transport-Security', b'max-age=31536000; includeSubDomains'), (b'X-Frame-Options', b'DENY'), (b'X-Content-Type-Options', b'nosniff'), (b'X-XSS-Protection', b'1; mode=block')])
2026-06-11 09:40:08 - httpx - INFO - [_client.py:1038] - HTTP Request: GET https://dempdemo.lzwcai.com/api/datasource/connection/list?pageNum=1&pageSize=20 "HTTP/1.1 200 "
2026-06-11 09:40:08 - httpcore.http11 - DEBUG - [_trace.py:47] - receive_response_body.started request=<Request [b'GET']>
2026-06-11 09:40:08 - httpcore.http11 - DEBUG - [_trace.py:47] - receive_response_body.complete
2026-06-11 09:40:08 - httpcore.http11 - DEBUG - [_trace.py:47] - response_closed.started
2026-06-11 09:40:08 - httpcore.http11 - DEBUG - [_trace.py:47] - response_closed.complete
2026-06-11 09:40:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:74] - [API响应] HTTP 200
2026-06-11 09:40:08 - lzwcai_mcp_agile_db.server - INFO - [server.py:86] - 工具执行成功: list_datasources
2026-06-11 09:40:08 - lzwcai_mcp_agile_db.server - DEBUG - [server.py:87] - 工具返回结果: {
"total": 14,
"rows": [
{
"createBy": "",
"createTime": "2026-06-10 16:47:43",
"updateBy": "",
"updateTime": "2026-06-10 16:47:43",
"remark": "设备报价管理系统包含设备基础信息、预设方案模板、报价单主表和明细表四个核心数据对象,支持从设备参数管理到整套产线方案配置的完整报价流程,为销售部门提供标准化报价服务,实现快速方案生成和精准成本核算。",
"id": "58",
"enterpriseId": "1937166012193443842",
"deptId": "1171",
"userId": "292",
"host": "host.docker.internal",
"port": 5432,
"datasourceName": "HMD产品",
"database...
2026-06-11 09:40:08 - mcp.server.lowlevel.server - DEBUG - [server.py:790] - Response sent
2026-06-11 09:40:55 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: <mcp.shared.session.RequestResponder object at 0x000002B57D0617C0>
2026-06-11 09:40:55 - mcp.server.lowlevel.server - INFO - [server.py:720] - Processing request of type CallToolRequest
2026-06-11 09:40:55 - mcp.server.lowlevel.server - DEBUG - [server.py:723] - Dispatching request of type CallToolRequest
2026-06-11 09:40:55 - lzwcai_mcp_agile_db.server - INFO - [server.py:66] - 收到 CallTool 请求: name=list_api_keys
2026-06-11 09:40:55 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:100] - [API请求] GET https://dempdemo.lzwcai.com/api/datasource/api_key/list
2026-06-11 09:40:55 - httpcore.connection - DEBUG - [_trace.py:47] - close.started
2026-06-11 09:40:55 - httpcore.connection - DEBUG - [_trace.py:47] - close.complete
2026-06-11 09:40:55 - httpcore.connection - DEBUG - [_trace.py:47] - connect_tcp.started host='dempdemo.lzwcai.com' port=443 local_address=None timeout=30.0 socket_options=None
2026-06-11 09:40:55 - httpcore.connection - DEBUG - [_trace.py:47] - connect_tcp.complete return_value=<httpcore._backends.sync.SyncStream object at 0x000002B57D2623C0>
2026-06-11 09:40:55 - httpcore.connection - DEBUG - [_trace.py:47] - start_tls.started ssl_context=<ssl.SSLContext object at 0x000002B57D2642D0> server_hostname='dempdemo.lzwcai.com' timeout=30.0
2026-06-11 09:40:55 - httpcore.connection - DEBUG - [_trace.py:47] - start_tls.complete return_value=<httpcore._backends.sync.SyncStream object at 0x000002B57D2626F0>
2026-06-11 09:40:55 - httpcore.http11 - DEBUG - [_trace.py:47] - send_request_headers.started request=<Request [b'GET']>
2026-06-11 09:40:55 - httpcore.http11 - DEBUG - [_trace.py:47] - send_request_headers.complete
2026-06-11 09:40:55 - httpcore.http11 - DEBUG - [_trace.py:47] - send_request_body.started request=<Request [b'GET']>
2026-06-11 09:40:55 - httpcore.http11 - DEBUG - [_trace.py:47] - send_request_body.complete
2026-06-11 09:40:55 - httpcore.http11 - DEBUG - [_trace.py:47] - receive_response_headers.started request=<Request [b'GET']>
2026-06-11 09:40:56 - httpcore.http11 - DEBUG - [_trace.py:47] - receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'', [(b'Server', b'nginx/1.25.2'), (b'Date', b'Thu, 11 Jun 2026 01:40:49 GMT'), (b'Content-Type', b'application/json'), (b'Transfer-Encoding', b'chunked'), (b'Connection', b'keep-alive'), (b'Vary', b'Origin'), (b'Vary', b'Access-Control-Request-Method'), (b'Vary', b'Access-Control-Request-Headers'), (b'X-Content-Type-Options', b'nosniff'), (b'X-XSS-Protection', b'1; mode=block'), (b'X-Frame-Options', b'SAMEORIGIN'), (b'Strict-Transport-Security', b'max-age=31536000; includeSubDomains'), (b'X-Frame-Options', b'DENY'), (b'X-Content-Type-Options', b'nosniff'), (b'X-XSS-Protection', b'1; mode=block')])
2026-06-11 09:40:56 - httpx - INFO - [_client.py:1038] - HTTP Request: GET https://dempdemo.lzwcai.com/api/datasource/api_key/list?pageNum=1&pageSize=20 "HTTP/1.1 200 "
2026-06-11 09:40:56 - httpcore.http11 - DEBUG - [_trace.py:47] - receive_response_body.started request=<Request [b'GET']>
2026-06-11 09:40:56 - httpcore.http11 - DEBUG - [_trace.py:47] - receive_response_body.complete
2026-06-11 09:40:56 - httpcore.http11 - DEBUG - [_trace.py:47] - response_closed.started
2026-06-11 09:40:56 - httpcore.http11 - DEBUG - [_trace.py:47] - response_closed.complete
2026-06-11 09:40:56 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:74] - [API响应] HTTP 200
2026-06-11 09:40:56 - lzwcai_mcp_agile_db.server - INFO - [server.py:86] - 工具执行成功: list_api_keys
2026-06-11 09:40:56 - lzwcai_mcp_agile_db.server - DEBUG - [server.py:87] - 工具返回结果: {
"total": 6,
"rows": [
{
"createBy": "",
"createTime": "2026-06-06 15:10:31",
"updateBy": "",
"updateTime": "2026-06-06 15:10:31",
"remark": null,
"id": "7",
"apiKey": "Lb8LgEJ7eBUU8QMifKUJvo9w6YLAotbKJ-w1DKU8ZrU",
"apiKeyName": "AWINBEXT",
"enterpriseId": "1937166012193443842",
"status": 0,
"expireTime": "2027-06-06T15:10:32.000+08:00"
},
{
"createBy": "",
"createTime": "2026-05-25 14:47:11",
"u...
2026-06-11 09:40:56 - mcp.server.lowlevel.server - DEBUG - [server.py:790] - Response sent

View File

@@ -1,15 +1,2 @@
2026-06-11 09:33:46 - lzwcai_mcp_agile_db.utils.api_client - ERROR - [api_client.py:91] - [API错误] 登录过期,请重新登录 2026-06-17 11:19:35 - lzwcai_mcp_agile_db.utils.api_client - ERROR - [api_client.py:98] - [API错误] HTTP 400, 参数不合法
2026-06-11 09:33:46 - lzwcai_mcp_agile_db.server - ERROR - [server.py:96] - 工具执行失败: list_datasources, 错误: 登录过期,请重新登录 2026-06-17 11:19:35 - lzwcai_mcp_agile_db.utils.api_client - ERROR - [api_client.py:107] - [API错误] HTTP 404, {"path": "/x"}
Traceback (most recent call last):
File "E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcp_agile_db\lzwcai_mcp_agile_db\server.py", line 84, in handle_call_tool
result = await tool_instance.execute(arguments or {})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcp_agile_db\lzwcai_mcp_agile_db\tools\datasources.py", line 30, in execute
return self.client.get("/api/datasource/connection/list", params=params)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcp_agile_db\lzwcai_mcp_agile_db\utils\api_client.py", line 102, in get
return self._handle_response(response, url)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcp_agile_db\lzwcai_mcp_agile_db\utils\api_client.py", line 92, in _handle_response
raise Exception(error_msg)
Exception: 登录过期,请重新登录

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
lzwcai-mcp-agile-db MCP Server lzwcai-mcp-agile-db MCP Server
数据库管理平台 MCP 工具服务,提供 33 个工具用于数据库管理、表操作、API 密钥管理等 数据库管理平台 MCP 工具服务,提供数据库管理、表/数据操作、技能、API 密钥、MQTT 同步、AI 训练等工具
""" """
import json import json
@@ -104,19 +104,24 @@ async def handle_call_tool(
async def run_server(): async def run_server():
"""运行 MCP Server (stdio 模式)""" """运行 MCP Server (stdio 模式)"""
try:
async with stdio_server() as streams: async with stdio_server() as streams:
await server.run( await server.run(
streams[0], streams[0],
streams[1], streams[1],
InitializationOptions( InitializationOptions(
server_name="lzwcai_mcp_agile_db", server_name="lzwcai_mcp_agile_db",
server_version="0.1.0", server_version="0.1.12",
capabilities=server.get_capabilities( capabilities=server.get_capabilities(
notification_options=NotificationOptions(), notification_options=NotificationOptions(),
experimental_capabilities={}, experimental_capabilities={},
), ),
), ),
) )
finally:
# 释放全局 HTTP 客户端
if _api_client is not None:
await _api_client.close()
def main(): def main():

View File

@@ -0,0 +1,68 @@
"""
AI 补全训练工具
含 generate_table_by_description 的轮询配套 get_ai_training_detail。
训练状态 trainingStatus: 0 待训练 / 1 训练中 / 2 已完成 / 3 失败
"""
from ._base import register_tool, ToolDef
@register_tool("list_ai_trainings")
class ListAiTrainingsTool(ToolDef):
name = "list_ai_trainings"
description = "获取 AI 补全训练任务列表"
input_schema = {
"type": "object",
"properties": {
"datasourceId": {"type": "string", "description": "数据源 ID可选过滤"},
"pageNum": {"type": "integer", "default": 1, "description": "页码"},
"pageSize": {"type": "integer", "default": 20, "description": "每页数量"},
},
"required": [],
}
async def execute(self, args: dict) -> dict:
params = {k: v for k, v in args.items() if v is not None}
return await self.client.get("/ai/training/list", params=params)
@register_tool("create_ai_training_by_selected")
class CreateAiTrainingBySelectedTool(ToolDef):
name = "create_ai_training_by_selected"
description = "按选中的表创建 AI 补全训练任务"
input_schema = {
"type": "object",
"properties": {
"datasourceId": {"type": "string", "description": "数据源 ID"},
"tableIds": {
"type": "array",
"description": "要训练的表 ID 数组",
"items": {"type": "string"},
},
},
"required": ["tableIds"],
}
async def execute(self, args: dict) -> dict:
return await self.client.post("/ai/training/createBySelected", json_data=args)
@register_tool("get_ai_training_detail")
class GetAiTrainingDetailTool(ToolDef):
name = "get_ai_training_detail"
description = (
"查询 AI 训练任务详情(轮询用)。"
"trainingStatus: 0 待训练 / 1 训练中 / 2 已完成 / 3 失败。"
"generate_table_by_description 返回 taskId 后,用本工具轮询直到 status=2 取结果"
)
input_schema = {
"type": "object",
"properties": {
"taskId": {"type": "string", "description": "训练任务 IDgenerate_table 返回的 taskId"},
},
"required": ["taskId"],
}
async def execute(self, args: dict) -> dict:
return await self.client.get(f"/ai/training/{args['taskId']}")

View File

@@ -1,5 +1,5 @@
""" """
API 密钥管理工具 (工具 18-23) API 密钥管理工具(含创建、状态切换、删除、权限查询/授予/撤销)
""" """
from ._base import register_tool, ToolDef from ._base import register_tool, ToolDef
@@ -119,3 +119,34 @@ class GrantApiKeyPermissionsTool(ToolDef):
async def execute(self, args: dict) -> dict: async def execute(self, args: dict) -> dict:
return await self.client.post("/datasource/api_key/permission/grant_batch", json_data=args) return await self.client.post("/datasource/api_key/permission/grant_batch", json_data=args)
@register_tool("revoke_api_key_permissions")
class RevokeApiKeyPermissionsTool(ToolDef):
name = "revoke_api_key_permissions"
description = "撤销/删除 API 密钥已授予的权限(按权限记录 ID"
input_schema = {
"type": "object",
"properties": {
"permissionIds": {
"type": "array",
"items": {"type": "string"},
"description": (
"权限记录 ID 列表。"
"先从 get_api_key_permissions 获取,"
"取 connectionPermissions / databasePermissions / tablePermissions 中每项的 id 字段"
),
},
},
"required": ["permissionIds"],
}
async def execute(self, args: dict) -> dict:
args = dict(args)
permission_ids = args.pop("permissionIds", None) or []
# 过滤掉空字符串/None防止拼接出类似 "1,,2" 的非法 ID
permission_ids = [pid for pid in permission_ids if pid is not None and str(pid).strip()]
if not permission_ids:
raise ValueError("permissionIds 不能为空")
ids = ",".join(str(pid).strip() for pid in permission_ids)
return await self.client.delete(f"/datasource/api_key/permission/{ids}")

View File

@@ -0,0 +1,104 @@
"""
库表关联配置工具(外置/内置通用)
对应文档 §2.2 / §4.2
- postConnectionConfig 建库表关联配置(外置源建完连接后选库选表的最后一步)
- putConnectionConfig 改库表关联配置
- deleteConnectionConfig 删配置(同时也是「删除库」的实现)
"""
from ._base import register_tool, ToolDef
@register_tool("create_connection_config")
class CreateConnectionConfigTool(ToolDef):
name = "create_connection_config"
description = (
"创建「库表关联」配置:外置数据源建完连接后,选择要纳管的库与表的最后一步。"
"完成后数据源才能用于智能问数"
)
input_schema = {
"type": "object",
"properties": {
"connectionId": {"type": "string", "description": "数据源连接ID"},
"syncTables": {"type": "boolean", "default": True, "description": "是否同步表结构,默认 true"},
"databases": {
"type": "array",
"description": "要纳管的库及其表",
"items": {
"type": "object",
"properties": {
"databaseName": {"type": "string", "description": "库名"},
"tableNames": {
"type": "array",
"description": "选中的表名数组",
"items": {"type": "string"},
},
},
"required": ["databaseName", "tableNames"],
},
},
},
"required": ["connectionId", "databases"],
}
async def execute(self, args: dict) -> dict:
args = dict(args)
args.setdefault("syncTables", True)
return await self.client.post("/datasource/config", json_data=args)
@register_tool("update_connection_config")
class UpdateConnectionConfigTool(ToolDef):
name = "update_connection_config"
description = "更新「库表关联」配置(编辑已纳管的库表范围)"
input_schema = {
"type": "object",
"properties": {
"id": {"type": "string", "description": "配置 ID编辑时必传"},
"connectionId": {"type": "string", "description": "数据源连接ID"},
"syncTables": {"type": "boolean", "default": True, "description": "是否同步表结构,默认 true"},
"databases": {
"type": "array",
"description": "要纳管的库及其表",
"items": {
"type": "object",
"properties": {
"databaseName": {"type": "string", "description": "库名"},
"tableNames": {
"type": "array",
"description": "选中的表名数组",
"items": {"type": "string"},
},
},
"required": ["databaseName", "tableNames"],
},
},
},
"required": ["id", "connectionId", "databases"],
}
async def execute(self, args: dict) -> dict:
args = dict(args)
args.setdefault("syncTables", True)
return await self.client.put("/datasource/config", json_data=args)
@register_tool("delete_connection_config")
class DeleteConnectionConfigTool(ToolDef):
name = "delete_connection_config"
description = "删除库表关联配置(批量)。这也是「删除内置库」的实现:传入对应库的配置 ID"
input_schema = {
"type": "object",
"properties": {
"ids": {
"type": "array",
"description": "配置 ID 数组",
"items": {"type": "string"},
},
},
"required": ["ids"],
}
async def execute(self, args: dict) -> dict:
return await self.client.post("/datasource/config/deletes", json_data={"ids": args["ids"]})

View File

@@ -2,8 +2,10 @@
数据导入工具 (工具 30-31) 数据导入工具 (工具 30-31)
""" """
import base64
import io import io
import mimetypes
import os
from urllib.parse import urlparse, unquote
from ._base import register_tool, ToolDef from ._base import register_tool, ToolDef
@@ -11,31 +13,75 @@ from ._base import register_tool, ToolDef
@register_tool("preview_import_data") @register_tool("preview_import_data")
class PreviewImportDataTool(ToolDef): class PreviewImportDataTool(ToolDef):
name = "preview_import_data" name = "preview_import_data"
description = "上传 Excel 文件AI 智能识别并预览表结构/数据" description = "从 URL 下载 Excel 文件AI 智能识别并预览表结构/数据"
input_schema = { input_schema = {
"type": "object", "type": "object",
"properties": { "properties": {
"connectionId": {"type": "string", "description": "数据源 ID"}, "connectionId": {"type": "string", "description": "数据源 ID"},
"target": {"type": "string", "enum": ["prod", "test"], "default": "test", "description": "环境,默认 test"}, "target": {"type": "string", "enum": ["prod", "test"], "default": "test", "description": "环境,默认 test"},
"file_base64": {"type": "string", "description": "Excel 文件 base64 编码.xlsx/.xls, <500KB"}, "file_url": {"type": "string", "description": "Excel 文件下载地址.xlsx/.xls, <500KB"},
"file_name": {"type": "string", "description": "文件名(如 data.xlsx"},
}, },
"required": ["connectionId", "file_base64"], "required": ["connectionId", "file_url"],
} }
async def execute(self, args: dict) -> dict: async def execute(self, args: dict) -> dict:
args = dict(args) args = dict(args)
connection_id = args.pop("connectionId") connection_id = args.pop("connectionId")
target = args.pop("target", "test") target = args.pop("target", "test")
file_base64 = args.pop("file_base64") file_url = args.pop("file_url")
file_name = args.pop("file_name", "import.xlsx")
# 解码 base64 文件 # 0. 基础 URL 校验
file_content = base64.b64decode(file_base64) parsed_url = urlparse(file_url)
if parsed_url.scheme not in ("http", "https"):
raise ValueError("仅支持 http/https 协议的下载地址")
# 构建文件上传 # 1. 下载文件
download_resp = await self.client.client.get(file_url, follow_redirects=True, timeout=30.0)
download_resp.raise_for_status()
# 1.1 预先根据 Content-Length 拦截超大文件,避免无意义下载
max_size = 500 * 1024
content_length = download_resp.headers.get("content-length")
if content_length:
try:
if int(content_length) > max_size:
raise ValueError(
f"文件 Content-Length {content_length} bytes 超过平台限制 {max_size} bytes (500KB)"
)
except ValueError as e:
if "超过平台限制" in str(e):
raise
# 非数字时忽略
file_content = download_resp.content
# 2. 文件大小校验(平台限制 500KB
if len(file_content) > max_size:
raise ValueError(
f"文件大小 {len(file_content)} bytes 超过平台限制 {max_size} bytes (500KB)"
"请先压缩或拆分后重试"
)
# 3. 解析文件名并校验扩展名
file_name = self._extract_file_name(download_resp, file_url)
allowed_exts = (".xlsx", ".xls")
if not file_name.lower().endswith(allowed_exts):
raise ValueError(
f"不支持的文件类型: {file_name},仅支持 {', '.join(allowed_exts)}"
)
# 4. 推断 MIME 类型
content_type, _ = mimetypes.guess_type(file_name)
if not content_type:
content_type = (
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
if file_name.lower().endswith(".xlsx")
else "application/vnd.ms-excel"
)
# 5. 构建文件上传
files = { files = {
"file": (file_name, io.BytesIO(file_content), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), "file": (file_name, io.BytesIO(file_content), content_type),
} }
return await self.client.upload( return await self.client.upload(
@@ -44,29 +90,109 @@ class PreviewImportDataTool(ToolDef):
params={"target": target}, params={"target": target},
) )
@staticmethod
def _extract_file_name(response, file_url: str) -> str:
"""从响应头或 URL 中提取文件名。"""
# 优先从 Content-Disposition 解析
content_disposition = response.headers.get("content-disposition")
if content_disposition:
# 简单解析 filename="xxx" 或 filename*=UTF-8''xxx
# 注意filename* 也包含 filename= 前缀,必须先检查 filename*
for part in content_disposition.split(";"):
part = part.strip()
if part.lower().startswith("filename*="):
encoded = part.split("=", 1)[1]
# 形如 UTF-8''xxx
if "''" in encoded:
_, _, file_name = encoded.partition("''")
return os.path.basename(unquote(file_name, encoding="utf-8"))
return os.path.basename(unquote(encoded, encoding="utf-8"))
if part.lower().startswith("filename="):
file_name = part.split("=", 1)[1].strip('"')
return os.path.basename(unquote(file_name))
# fallback 从 URL 路径获取
parsed = urlparse(file_url)
path = unquote(parsed.path)
file_name = os.path.basename(path) if path else ""
if file_name and "." in file_name:
return file_name
return "import.xlsx"
@register_tool("confirm_import_data") @register_tool("confirm_import_data")
class ConfirmImportDataTool(ToolDef): class ConfirmImportDataTool(ToolDef):
name = "confirm_import_data" name = "confirm_import_data"
description = "确认导入 AI 识别后的数据" description = (
"确认导入 AI 识别后的数据(建表+插数据)。"
"传入 preview_import_data 返回的 data 原文 + databaseName 即可,"
"工具内部会自动组装成后端要求的 {tableStructure(含databaseName), allData} 结构"
)
input_schema = { input_schema = {
"type": "object", "type": "object",
"properties": { "properties": {
"connectionId": {"type": "string", "description": "数据源 ID"}, "connectionId": {"type": "string", "description": "数据源连接 ID"},
"target": {"type": "string", "enum": ["prod", "test"], "description": "环境"}, "databaseName": {"type": "string", "description": "落库的数据库名(导入目标库)"},
"data": {"type": "object", "description": "导入数据(含 tableStructure + allData"}, "target": {"type": "string", "enum": ["prod", "test"], "default": "test", "description": "环境,默认 test"},
"data": {"type": "object", "description": "preview_import_data 返回的 data含 tableStructure/allData或已组装好的最终结构"},
}, },
"required": ["connectionId", "data"], "required": ["connectionId", "data"],
} }
@staticmethod
def _build_body(data: dict, database_name) -> dict:
"""把 preview 返回的 data 组装成 confirm 要求的 body。
后端真实结构(已真机探测确认):
{ "tableStructure": <单表对象,含 columns 且需带 databaseName>, "allData": [...] }
preview 返回的 data.tableStructure 是 {success,message,data:{tables:[...]}} 的包装,
需取出 data.tables[0] 作为单表对象。
"""
if not isinstance(data, dict):
return data
# 若传入的是 preview 工具的完整返回信封 {code, msg, data:{...}},先剥一层
if "tableStructure" not in data and isinstance(data.get("data"), dict):
inner_env = data["data"]
if "tableStructure" in inner_env or "allData" in inner_env:
data = inner_env
ts = data.get("tableStructure")
single_table = None
if isinstance(ts, dict):
if "columns" in ts:
# 已是单表对象(调用方自行组装过)
single_table = dict(ts)
else:
# preview 包装tableStructure.data.tables[0]
inner = ts.get("data") if isinstance(ts.get("data"), dict) else {}
tables = inner.get("tables") if isinstance(inner, dict) else None
if isinstance(tables, list) and tables:
single_table = dict(tables[0])
if single_table is None:
# 无法识别结构,原样透传(兼容调用方已构造好完整 body 的情况)
return data
if database_name and not single_table.get("databaseName"):
single_table["databaseName"] = database_name
all_data = data.get("allData")
if all_data is None:
all_data = data.get("data") or []
return {"tableStructure": single_table, "allData": all_data}
async def execute(self, args: dict) -> dict: async def execute(self, args: dict) -> dict:
args = dict(args) args = dict(args)
connection_id = args.pop("connectionId") connection_id = args.pop("connectionId")
target = args.pop("target", "test") target = args.pop("target", "test")
database_name = args.pop("databaseName", None)
data = args.pop("data") data = args.pop("data")
body = self._build_body(data, database_name)
return await self.client.post( return await self.client.post(
f"/datasource/connection/{connection_id}/import_document/confirm", f"/datasource/connection/{connection_id}/import_document/confirm",
json_data=data, json_data=body,
params={"target": target}, params={"target": target},
) )

View File

@@ -45,6 +45,28 @@ class ListTablesTool(ToolDef):
return await self.client.get("/datasource/table/list", params=params) return await self.client.get("/datasource/table/list", params=params)
@register_tool("list_tables_with_ai")
class ListTablesWithAiTool(ToolDef):
name = "list_tables_with_ai"
description = (
"获取表元数据列表(含 AI 训练描述),内置库管理的主列表。"
"注意datasourceId 实为「配置/库」ID即 create_connection_config 落库后的 config id"
)
input_schema = {
"type": "object",
"properties": {
"datasourceId": {"type": "string", "description": "配置/库 ID非数据源连接 ID"},
"pageNum": {"type": "integer", "default": 1, "description": "页码"},
"pageSize": {"type": "integer", "default": 20, "description": "每页数量"},
},
"required": ["datasourceId"],
}
async def execute(self, args: dict) -> dict:
params = {k: v for k, v in args.items() if v is not None}
return await self.client.get("/datasource/table/ailist", params=params)
@register_tool("get_table_detail") @register_tool("get_table_detail")
class GetTableDetailTool(ToolDef): class GetTableDetailTool(ToolDef):
name = "get_table_detail" name = "get_table_detail"
@@ -117,8 +139,8 @@ class AlterTableTool(ToolDef):
"items": { "items": {
"type": "object", "type": "object",
"properties": { "properties": {
"operation": {"type": "string", "enum": ["ADD_COLUMN", "DROP_COLUMN", "RENAME_COLUMN", "ALTER_COLUMN_TYPE", "SET_NOT_NULL", "DROP_NOT_NULL", "SET_DEFAULT", "DROP_DEFAULT"], "description": "变更类型"}, "operation": {"type": "string", "enum": ["ADD_COLUMN", "MODIFY_COLUMN", "DROP_COLUMN"], "description": "变更类型:新增/修改/删除字段(后端实测仅支持这三种)"},
"column": {"type": "object", "description": "列定义(根据 operation 不同包含不同字段)"}, "column": {"type": "object", "description": "列定义。ADD/MODIFY 需 columnName+columnType(+columnLength/columnComment 等)DROP 仅需 columnName"},
}, },
"required": ["operation", "column"], "required": ["operation", "column"],
}, },
@@ -137,7 +159,7 @@ class AlterTableTool(ToolDef):
@register_tool("generate_table_by_description") @register_tool("generate_table_by_description")
class GenerateTableByDescriptionTool(ToolDef): class GenerateTableByDescriptionTool(ToolDef):
name = "generate_table_by_description" name = "generate_table_by_description"
description = "通过自然语言描述让 AI 生成表结构(异步任务" description = "通过自然语言描述让 AI 生成表结构(异步任务,返回 taskId。需配合 get_ai_training_detail 轮询 taskId 获取生成结果"
input_schema = { input_schema = {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -149,3 +171,116 @@ class GenerateTableByDescriptionTool(ToolDef):
async def execute(self, args: dict) -> dict: async def execute(self, args: dict) -> dict:
return await self.client.post("/datasource/connection/generate_table", json_data=args) return await self.client.post("/datasource/connection/generate_table", json_data=args)
@register_tool("create_database")
class CreateDatabaseTool(ToolDef):
name = "create_database"
description = "在内置数据源下创建一个数据库(建表前置步骤)"
input_schema = {
"type": "object",
"properties": {
"connectionId": {"type": "string", "description": "数据源连接 ID"},
"databaseName": {"type": "string", "description": "数据库名(仅字母数字下划线)"},
},
"required": ["connectionId", "databaseName"],
}
async def execute(self, args: dict) -> dict:
args = dict(args)
connection_id = args.pop("connectionId")
return await self.client.post(f"/datasource/connection/{connection_id}/create_database", json_data=args)
@register_tool("create_database_table")
class CreateDatabaseTableTool(ToolDef):
name = "create_database_table"
description = (
"一次性创建数据库 + 表(合并接口,免去先建库再建表)。"
"支持两种用法:①单表便捷参数 tableName/tableComment/columns"
"②批量传 tables 数组(一次建多张表)。二者任选其一"
)
# 单字段columnName/columnType/...)的 schema单表和批量两种用法共用
_COLUMN_ITEMS = {
"type": "object",
"properties": {
"columnName": {"type": "string", "description": "字段名"},
"columnType": {"type": "string", "description": "字段类型VARCHAR/INTEGER/SERIAL等"},
"columnLength": {"type": "integer", "description": "字段长度"},
"isPrimaryKey": {"type": "boolean", "description": "是否主键"},
"isNullable": {"type": "boolean", "description": "是否可空"},
"isAutoIncrement": {"type": "boolean", "description": "是否自增"},
"columnComment": {"type": "string", "description": "字段注释"},
"defaultValue": {"type": "string", "description": "默认值"},
},
"required": ["columnName", "columnType"],
}
input_schema = {
"type": "object",
"properties": {
"connectionId": {"type": "string", "description": "数据源连接 ID"},
"databaseName": {"type": "string", "description": "数据库名(仅字母数字下划线)"},
# —— 用法①:单表便捷参数 ——
"tableName": {"type": "string", "description": "表名(用法①单表,小写字母+数字+下划线)"},
"tableComment": {"type": "string", "description": "表注释(用法①单表)"},
"columns": {
"type": "array",
"description": "字段定义数组(用法①单表,结构同 create_table",
"items": _COLUMN_ITEMS,
},
# —— 用法②:批量 tables 数组 ——
"tables": {
"type": "array",
"description": "表定义数组(用法②批量建多表),每项含 tableName/tableComment/columns",
"items": {
"type": "object",
"properties": {
"tableName": {"type": "string", "description": "表名"},
"tableComment": {"type": "string", "description": "表注释"},
"columns": {"type": "array", "description": "字段定义数组", "items": _COLUMN_ITEMS},
},
"required": ["tableName", "columns"],
},
},
},
"required": ["connectionId", "databaseName"],
}
async def execute(self, args: dict) -> dict:
args = dict(args)
connection_id = args.pop("connectionId")
# 后端真实 body 结构:{databaseName, tables:[{tableName,tableComment,columns}]}(已真机探测确认)
if not args.get("tables"):
table = {"tableName": args.pop("tableName", None), "columns": args.pop("columns", [])}
if args.get("tableComment") is not None:
table["tableComment"] = args.pop("tableComment")
args["tables"] = [table]
else:
# 批量用法下,清掉可能并存的单表字段,避免歧义
args.pop("tableName", None)
args.pop("tableComment", None)
args.pop("columns", None)
return await self.client.post(f"/datasource/connection/{connection_id}/create_database_table", json_data=args)
@register_tool("alter_database")
class AlterDatabaseTool(ToolDef):
name = "alter_database"
description = "修改数据库(如改名)"
input_schema = {
"type": "object",
"properties": {
"connectionId": {"type": "string", "description": "数据源连接 ID"},
"databaseName": {"type": "string", "description": "当前数据库名"},
"newName": {"type": "string", "description": "新数据库名"},
},
"required": ["connectionId", "databaseName", "newName"],
}
async def execute(self, args: dict) -> dict:
args = dict(args)
connection_id = args.pop("connectionId")
# 后端改名字段名为 newName已真机探测确认兼容历史传参 newDatabaseName
if "newName" not in args and "newDatabaseName" in args:
args["newName"] = args.pop("newDatabaseName")
return await self.client.put(f"/datasource/connection/{connection_id}/alter_database", json_data=args)

View File

@@ -71,7 +71,7 @@ class CreateDatasourceTool(ToolDef):
"type": "object", "type": "object",
"properties": { "properties": {
"datasourceName": {"type": "string", "description": "数据源名称3-20字"}, "datasourceName": {"type": "string", "description": "数据源名称3-20字"},
"datasourceType": {"type": "string", "enum": ["mysql", "postgresql", "oracle", "sqlserver", "dameng"], "description": "数据库类型"}, "datasourceType": {"type": "string", "enum": ["mysql", "postgresql", "oracle", "sqlserver", "dameng", "kingbase", "sqlite", "mariadb"], "description": "数据库类型"},
"host": {"type": "string", "description": "数据库地址"}, "host": {"type": "string", "description": "数据库地址"},
"port": {"type": "integer", "description": "端口号"}, "port": {"type": "integer", "description": "端口号"},
"databaseName": {"type": "string", "description": "要连接的数据库名"}, "databaseName": {"type": "string", "description": "要连接的数据库名"},
@@ -88,7 +88,7 @@ class CreateDatasourceTool(ToolDef):
args = dict(args) args = dict(args)
test_first = args.pop("test_first", True) test_first = args.pop("test_first", True)
# 如果需要先测试连接 # 如果需要先测试连接(测试失败时 client 会直接抛异常,由上层统一捕获)
if test_first: if test_first:
test_data = { test_data = {
"datasourceName": args.get("datasourceName"), "datasourceName": args.get("datasourceName"),
@@ -100,9 +100,7 @@ class CreateDatasourceTool(ToolDef):
"password": args.get("password"), "password": args.get("password"),
"connectionType": args.get("connectionType", "user_password"), "connectionType": args.get("connectionType", "user_password"),
} }
test_result = await self.client.post("/datasource/connection/test", json_data=test_data) await self.client.post("/datasource/connection/test", json_data=test_data)
if test_result.get("code") != 200:
return {"success": False, "error": f"连接测试失败: {test_result.get('msg', '未知错误')}"}
# 创建数据源 # 创建数据源
return await self.client.post("/datasource/connection", json_data=args) return await self.client.post("/datasource/connection", json_data=args)
@@ -117,11 +115,13 @@ class UpdateDatasourceTool(ToolDef):
"properties": { "properties": {
"id": {"type": "string", "description": "数据源 ID"}, "id": {"type": "string", "description": "数据源 ID"},
"datasourceName": {"type": "string", "description": "更新名称"}, "datasourceName": {"type": "string", "description": "更新名称"},
"datasourceType": {"type": "string", "enum": ["mysql", "postgresql", "oracle", "sqlserver", "dameng", "kingbase", "sqlite", "mariadb"], "description": "数据库类型"},
"host": {"type": "string", "description": "更新地址"}, "host": {"type": "string", "description": "更新地址"},
"port": {"type": "integer", "description": "更新端口"}, "port": {"type": "integer", "description": "更新端口"},
"databaseName": {"type": "string", "description": "更新数据库名"}, "databaseName": {"type": "string", "description": "更新数据库名"},
"username": {"type": "string", "description": "更新用户名"}, "username": {"type": "string", "description": "更新用户名"},
"password": {"type": "string", "description": "新密码(不传则不变)"}, "password": {"type": "string", "description": "新密码(不传则不变)"},
"connectionType": {"type": "string", "enum": ["user_password", "ssl"], "description": "连接类型"},
"remark": {"type": "string", "description": "更新描述"}, "remark": {"type": "string", "description": "更新描述"},
}, },
"required": ["id"], "required": ["id"],
@@ -171,3 +171,79 @@ class DeleteDatasourceTool(ToolDef):
# 删除数据源 # 删除数据源
return await self.client.delete(f"/datasource/connection/{ds_id}") return await self.client.delete(f"/datasource/connection/{ds_id}")
@register_tool("test_connection")
class TestConnectionTool(ToolDef):
name = "test_connection"
description = "测试外部数据库连接是否可用(不创建数据源)"
input_schema = {
"type": "object",
"properties": {
"datasourceType": {"type": "string", "enum": ["mysql", "postgresql", "oracle", "sqlserver", "dameng", "kingbase", "sqlite", "mariadb"], "description": "数据库类型"},
"host": {"type": "string", "description": "数据库地址"},
"port": {"type": "integer", "description": "端口号"},
"databaseName": {"type": "string", "description": "要连接的数据库名"},
"username": {"type": "string", "description": "数据库用户名"},
"password": {"type": "string", "description": "密码"},
"connectionType": {"type": "string", "enum": ["user_password", "ssl"], "description": "连接类型,默认 user_password"},
},
"required": ["datasourceType", "host", "port", "databaseName", "username"],
}
async def execute(self, args: dict) -> dict:
args = dict(args)
args.setdefault("connectionType", "user_password")
return await self.client.post("/datasource/connection/test", json_data=args)
@register_tool("get_realtime_structure")
class GetRealtimeStructureTool(ToolDef):
name = "get_realtime_structure"
description = "拉取数据源真实的库表结构(实时探测远端数据库)"
input_schema = {
"type": "object",
"properties": {
"datasourceId": {"type": "string", "description": "数据源 ID"},
},
"required": ["datasourceId"],
}
async def execute(self, args: dict) -> dict:
ds_id = args["datasourceId"]
return await self.client.get(f"/datasource/connection/realtime/structure/{ds_id}")
@register_tool("create_builtin_datasource")
class CreateBuiltinDatasourceTool(ToolDef):
name = "create_builtin_datasource"
description = "创建内置 PostgreSQL 数据源连接,返回 connectionId内置源建库建表链路第一步"
input_schema = {
"type": "object",
"properties": {
"datasourceName": {"type": "string", "description": "数据源名称3-20字"},
"remark": {"type": "string", "description": "数据源描述"},
},
"required": ["datasourceName"],
}
async def execute(self, args: dict) -> dict:
return await self.client.post("/datasource/connection/create_builtin_postgresql", json_data=args)
@register_tool("update_builtin_datasource")
class UpdateBuiltinDatasourceTool(ToolDef):
name = "update_builtin_datasource"
description = "修改内置 PostgreSQL 数据源连接信息"
input_schema = {
"type": "object",
"properties": {
"id": {"type": "string", "description": "数据源 ID"},
"datasourceName": {"type": "string", "description": "更新名称"},
"remark": {"type": "string", "description": "更新描述"},
},
"required": ["id"],
}
async def execute(self, args: dict) -> dict:
return await self.client.put("/datasource/connection/update_builtin_database", json_data=args)

View File

@@ -0,0 +1,151 @@
"""
MQTT 字段关联同步工具(内置源专属,文档 §8
接口前缀为 /system/mqtt注意与 /datasource 不同)。
关于 sourceTable / targetTable 的方向(已真机验证):
直接调后端 API 时按字面透传即可——create 传什么detail 回显就是什么,
字段自洽、后端不做调换。例create 传 sourceTable=sys_user/targetTable=users
detail 读回仍是 sourceTable=sys_user/targetTable=users。
文档 §8 提到的「前端提交时调换」是前端 Vue 组件为 UI 展示所做的转换,
与后端 API 契约无关,本工具(直连 API无需调换。
"""
from ._base import register_tool, ToolDef
@register_tool("list_mqtt_configs")
class ListMqttConfigsTool(ToolDef):
name = "list_mqtt_configs"
description = "获取 MQTT 字段关联同步配置列表"
input_schema = {
"type": "object",
"properties": {
"pageNum": {"type": "integer", "default": 1, "description": "页码"},
"pageSize": {"type": "integer", "default": 20, "description": "每页数量"},
},
"required": [],
}
async def execute(self, args: dict) -> dict:
params = {k: v for k, v in args.items() if v is not None}
return await self.client.get("/system/mqtt/config/list", params=params)
@register_tool("get_mqtt_config_detail")
class GetMqttConfigDetailTool(ToolDef):
name = "get_mqtt_config_detail"
description = "获取 MQTT 同步配置详情"
input_schema = {
"type": "object",
"properties": {
"configId": {"type": "string", "description": "配置 ID"},
},
"required": ["configId"],
}
async def execute(self, args: dict) -> dict:
return await self.client.get(f"/system/mqtt/config/{args['configId']}")
@register_tool("list_mqtt_target_tables")
class ListMqttTargetTablesTool(ToolDef):
name = "list_mqtt_target_tables"
description = "获取 MQTT 可同步的目标表列表"
input_schema = {"type": "object", "properties": {}, "required": []}
async def execute(self, args: dict) -> dict:
return await self.client.get("/system/mqtt/config/tableList")
@register_tool("list_mqtt_target_table_columns")
class ListMqttTargetTableColumnsTool(ToolDef):
name = "list_mqtt_target_table_columns"
description = "获取 MQTT 目标表的字段列表"
input_schema = {
"type": "object",
"properties": {
"tableName": {"type": "string", "description": "目标表名"},
},
"required": ["tableName"],
}
async def execute(self, args: dict) -> dict:
return await self.client.get(f"/system/mqtt/config/tableColumns/{args['tableName']}")
@register_tool("create_mqtt_config")
class CreateMqttConfigTool(ToolDef):
name = "create_mqtt_config"
description = (
"新增 MQTT 字段关联同步配置。"
"sourceTable/targetTable 按字面透传即可(实测确认 create 存入与 detail 回显一致,"
"无需调换文档§8 的调换是前端 UI 层行为,与本 API 无关)"
)
input_schema = {
"type": "object",
"properties": {
"sourceTable": {"type": "string", "description": "源表名"},
"targetTable": {"type": "string", "description": "目标表名"},
"config": {"type": "object", "description": "完整配置体(字段映射等),按后端结构透传"},
},
"required": [],
}
async def execute(self, args: dict) -> dict:
# config 若存在则以其为主体,否则直接用 args去掉 config 包裹键)
body = args.get("config") if isinstance(args.get("config"), dict) else dict(args)
return await self.client.post("/system/mqtt/config", json_data=body)
@register_tool("update_mqtt_config")
class UpdateMqttConfigTool(ToolDef):
name = "update_mqtt_config"
description = (
"修改 MQTT 字段关联同步配置。"
"sourceTable/targetTable 按字面透传即可(实测确认无需调换,详见 create_mqtt_config"
)
input_schema = {
"type": "object",
"properties": {
"id": {"type": "string", "description": "配置 ID"},
"sourceTable": {"type": "string", "description": "源表名"},
"targetTable": {"type": "string", "description": "目标表名"},
"config": {"type": "object", "description": "完整配置体,按后端结构透传"},
},
"required": ["id"],
}
async def execute(self, args: dict) -> dict:
if isinstance(args.get("config"), dict):
body = dict(args["config"])
body.setdefault("id", args.get("id"))
else:
body = dict(args)
return await self.client.put("/system/mqtt/config", json_data=body)
@register_tool("delete_mqtt_config")
class DeleteMqttConfigTool(ToolDef):
name = "delete_mqtt_config"
description = "删除 MQTT 同步配置(支持多个 id逗号拼接"
input_schema = {
"type": "object",
"properties": {
"ids": {"type": "string", "description": "配置 ID多个用逗号拼接"},
},
"required": ["ids"],
}
async def execute(self, args: dict) -> dict:
return await self.client.delete(f"/system/mqtt/config/{args['ids']}")
@register_tool("refresh_mqtt_config_cache")
class RefreshMqttConfigCacheTool(ToolDef):
name = "refresh_mqtt_config_cache"
description = "刷新 MQTT 同步配置缓存"
input_schema = {"type": "object", "properties": {}, "required": []}
async def execute(self, args: dict) -> dict:
return await self.client.get("/system/mqtt/config/refreshCache")

View File

@@ -119,14 +119,17 @@ class DeleteSkillToolTool(ToolDef):
@register_tool("update_skill_config") @register_tool("update_skill_config")
class UpdateSkillConfigTool(ToolDef): class UpdateSkillConfigTool(ToolDef):
name = "update_skill_config" name = "update_skill_config"
description = "更新技能配置(如 MCP Server 配置模板)" description = "更新技能(名称/描述/MCP 配置模板等)。对应后端 skill/updateOrGet"
input_schema = { input_schema = {
"type": "object", "type": "object",
"properties": { "properties": {
"datasourceId": {"type": "string", "description": "数据源 ID"}, "datasourceId": {"type": "string", "description": "数据源 ID"},
"configTemplate": {"type": "string", "description": "配置模板 JSON 字符串"}, "skillId": {"type": "string", "description": "技能 ID可选"},
"name": {"type": "string", "description": "技能名称(可选)"},
"description": {"type": "string", "description": "技能描述(可选)"},
"configTemplate": {"type": "string", "description": "配置模板 JSON 字符串(可选)"},
}, },
"required": ["datasourceId", "configTemplate"], "required": ["datasourceId"],
} }
async def execute(self, args: dict) -> dict: async def execute(self, args: dict) -> dict:
@@ -135,3 +138,40 @@ class UpdateSkillConfigTool(ToolDef):
if "configTemplate" in args and isinstance(args["configTemplate"], dict): if "configTemplate" in args and isinstance(args["configTemplate"], dict):
args["configTemplate"] = json.dumps(args["configTemplate"]) args["configTemplate"] = json.dumps(args["configTemplate"])
return await self.client.post("/datasource/skill/updateOrGet", json_data=args) return await self.client.post("/datasource/skill/updateOrGet", json_data=args)
@register_tool("update_skill_tool")
class UpdateSkillToolTool(ToolDef):
name = "update_skill_tool"
description = "修改技能下某个工具的名称/描述/SQL等对应后端 tskilltool/updateOrGetupsert 语义)"
# 真实工具实体字段(已真机探测确认):主键 id、展示名 uniqueName、描述 description、
# SQL 模板 sqlTemplate、结果类型 resultType。后端不认 skillToolId/businessDescription/businessScenario。
input_schema = {
"type": "object",
"properties": {
"id": {"type": "string", "description": "技能工具 IDget_skill_tools 返回的 id"},
"uniqueName": {"type": "string", "description": "工具展示名(可选)"},
"description": {"type": "string", "description": "工具描述(可选)"},
"sqlTemplate": {"type": "string", "description": "SQL 模板(可选)"},
"resultType": {"type": "string", "enum": ["single", "list"], "description": "结果类型(可选)"},
},
"required": ["id"],
}
# 旧参数名 -> 真实字段名(向后兼容此前错误实现的调用方)
_LEGACY_MAP = {
"skillToolId": "id",
"name": "uniqueName",
"businessDescription": "description",
}
async def execute(self, args: dict) -> dict:
args = dict(args)
for old, new in self._LEGACY_MAP.items():
if old in args and new not in args:
args[new] = args.pop(old)
else:
args.pop(old, None)
# businessScenario 后端实体无此字段,丢弃避免干扰
args.pop("businessScenario", None)
return await self.client.post("/datasource/skill/tskilltool/updateOrGet", json_data=args)

View File

@@ -35,4 +35,11 @@ class ExecuteSqlTool(ToolDef):
body["businessName"] = args["businessName"] body["businessName"] = args["businessName"]
if "params" in args: if "params" in args:
body["parameters"] = args["params"] body["parameters"] = args["params"]
return await self.client.post("/datasource/sqlExecutionLog/testSqlWithSchema", json_data=body) # target 通过 query 参数下发prod/test 双环境),默认 prod
target = args.get("target", "prod")
params = {"target": target} if target else None
return await self.client.post(
"/datasource/sqlExecutionLog/testSqlWithSchema",
json_data=body,
params=params,
)

View File

@@ -8,22 +8,24 @@ from ._base import register_tool, ToolDef
@register_tool("toggle_table_subscription") @register_tool("toggle_table_subscription")
class ToggleTableSubscriptionTool(ToolDef): class ToggleTableSubscriptionTool(ToolDef):
name = "toggle_table_subscription" name = "toggle_table_subscription"
description = "切换表的订阅状态" description = "切换表的订阅状态(订阅依赖该表已配置 MQTT 字段关联,否则后端会报操作失败)"
input_schema = { input_schema = {
"type": "object", "type": "object",
"properties": { "properties": {
"tableId": {"type": "string", "description": "表 ID"}, "configId": {"type": "string", "description": "库/配置 ID即 list_databases 返回的 config id"},
"datasourceId": {"type": "string", "description": "数据源 ID"}, "tableName": {"type": "string", "description": "表名(注意:后端按表名而非 tableId 识别)"},
"subscribe": {"type": "boolean", "description": "true=订阅, false=取消订阅"}, "subscribe": {"type": "boolean", "description": "true=订阅, false=取消订阅"},
}, },
"required": ["tableId", "datasourceId", "subscribe"], "required": ["configId", "tableName", "subscribe"],
} }
async def execute(self, args: dict) -> dict: async def execute(self, args: dict) -> dict:
# 映射参数名为后端 API 期望的格式 # 后端真实字段已真机探测确认configId + tableName + isSubscribe(bool)
# 兼容旧参数名 datasourceId->configId
config_id = args.get("configId") or args.get("datasourceId")
body = { body = {
"tableId": args["tableId"], "configId": config_id,
"datasourceId": args["datasourceId"], "tableName": args.get("tableName"),
"subscribe": args["subscribe"], "isSubscribe": args.get("subscribe"),
} }
return await self.client.post("/datasource/subscription/toggle", json_data=body) return await self.client.post("/datasource/subscription/toggle", json_data=body)

View File

@@ -10,12 +10,12 @@ from ._base import register_tool, ToolDef
@register_tool("query_table_data") @register_tool("query_table_data")
class QueryTableDataTool(ToolDef): class QueryTableDataTool(ToolDef):
name = "query_table_data" name = "query_table_data"
description = "查询内置表数据(分页)" description = "查询内置表数据(分页)。target 默认 test调试环境查询线上数据须显式传 target=prod"
input_schema = { input_schema = {
"type": "object", "type": "object",
"properties": { "properties": {
"tableId": {"type": "string", "description": "表 ID"}, "tableId": {"type": "string", "description": "表 ID"},
"target": {"type": "string", "enum": ["prod", "test"], "default": "prod", "description": "环境,默认 prod"}, "target": {"type": "string", "enum": ["prod", "test"], "default": "test", "description": "环境,默认 test调试线上数据传 prod"},
"pageNum": {"type": "integer", "default": 1, "description": "页码"}, "pageNum": {"type": "integer", "default": 1, "description": "页码"},
"pageSize": {"type": "integer", "default": 10, "description": "每页数量"}, "pageSize": {"type": "integer", "default": 10, "description": "每页数量"},
}, },
@@ -25,6 +25,7 @@ class QueryTableDataTool(ToolDef):
async def execute(self, args: dict) -> dict: async def execute(self, args: dict) -> dict:
args = dict(args) args = dict(args)
table_id = args.pop("tableId") table_id = args.pop("tableId")
args.setdefault("target", "test")
params = {k: v for k, v in args.items() if v is not None} params = {k: v for k, v in args.items() if v is not None}
return await self.client.get(f"/datasource/connection/builtin/table/{table_id}", params=params) return await self.client.get(f"/datasource/connection/builtin/table/{table_id}", params=params)
@@ -32,12 +33,12 @@ class QueryTableDataTool(ToolDef):
@register_tool("insert_table_row") @register_tool("insert_table_row")
class InsertTableRowTool(ToolDef): class InsertTableRowTool(ToolDef):
name = "insert_table_row" name = "insert_table_row"
description = "向内置表插入一行数据" description = "向内置表插入一行数据。⚠️ 写操作 target 默认 test调试环境写入线上库须显式传 target=prod"
input_schema = { input_schema = {
"type": "object", "type": "object",
"properties": { "properties": {
"tableId": {"type": "string", "description": "表 ID"}, "tableId": {"type": "string", "description": "表 ID"},
"target": {"type": "string", "enum": ["prod", "test"], "default": "prod", "description": "环境,默认 prod"}, "target": {"type": "string", "enum": ["prod", "test"], "default": "test", "description": "⚠️ 环境,默认 test调试写入线上库必须显式传 prod"},
"data": {"type": "object", "description": "行数据(键值对,键为字段名)"}, "data": {"type": "object", "description": "行数据(键值对,键为字段名)"},
}, },
"required": ["tableId", "data"], "required": ["tableId", "data"],
@@ -46,21 +47,22 @@ class InsertTableRowTool(ToolDef):
async def execute(self, args: dict) -> dict: async def execute(self, args: dict) -> dict:
args = dict(args) args = dict(args)
table_id = args.pop("tableId") table_id = args.pop("tableId")
target = args.pop("target", "prod") target = args.pop("target", "test")
data = args.pop("data", {}) data = args.pop("data", {})
params = {"target": target} if target else {} params = {"target": target} if target else {}
return await self.client.post(f"/datasource/connection/builtin/table/{table_id}/rows", json_data=data, params=params) body = {"data": data}
return await self.client.post(f"/datasource/connection/builtin/table/{table_id}/rows", json_data=body, params=params)
@register_tool("update_table_row") @register_tool("update_table_row")
class UpdateTableRowTool(ToolDef): class UpdateTableRowTool(ToolDef):
name = "update_table_row" name = "update_table_row"
description = "更新内置表的指定行" description = "更新内置表的指定行。⚠️ 写操作 target 默认 test调试环境修改线上库须显式传 target=prod"
input_schema = { input_schema = {
"type": "object", "type": "object",
"properties": { "properties": {
"tableId": {"type": "string", "description": "表 ID"}, "tableId": {"type": "string", "description": "表 ID"},
"target": {"type": "string", "enum": ["prod", "test"], "default": "prod", "description": "环境,默认 prod"}, "target": {"type": "string", "enum": ["prod", "test"], "default": "test", "description": "⚠️ 环境,默认 test调试修改线上库必须显式传 prod"},
"primaryKey": {"type": "object", "description": "主键值(如 {\"id\": 1}"}, "primaryKey": {"type": "object", "description": "主键值(如 {\"id\": 1}"},
"data": {"type": "object", "description": "要更新的字段值"}, "data": {"type": "object", "description": "要更新的字段值"},
}, },
@@ -70,7 +72,7 @@ class UpdateTableRowTool(ToolDef):
async def execute(self, args: dict) -> dict: async def execute(self, args: dict) -> dict:
args = dict(args) args = dict(args)
table_id = args.pop("tableId") table_id = args.pop("tableId")
target = args.pop("target", "prod") target = args.pop("target", "test")
primary_key = args.pop("primaryKey") primary_key = args.pop("primaryKey")
data = args.pop("data", {}) data = args.pop("data", {})
params = {"target": target} if target else {} params = {"target": target} if target else {}
@@ -81,12 +83,12 @@ class UpdateTableRowTool(ToolDef):
@register_tool("delete_table_rows") @register_tool("delete_table_rows")
class DeleteTableRowsTool(ToolDef): class DeleteTableRowsTool(ToolDef):
name = "delete_table_rows" name = "delete_table_rows"
description = "删除内置表的指定行(根据主键批量删除)" description = "删除内置表的指定行(根据主键批量删除)。⚠️ 写操作 target 默认 test调试环境删除线上库数据须显式传 target=prod"
input_schema = { input_schema = {
"type": "object", "type": "object",
"properties": { "properties": {
"tableId": {"type": "string", "description": "表 ID"}, "tableId": {"type": "string", "description": "表 ID"},
"target": {"type": "string", "enum": ["prod", "test"], "default": "prod", "description": "环境,默认 prod"}, "target": {"type": "string", "enum": ["prod", "test"], "default": "test", "description": "⚠️ 环境,默认 test调试删除线上库数据必须显式传 prod"},
"primaryKeys": { "primaryKeys": {
"type": "array", "type": "array",
"description": "主键数组(如 [{\"id\": 1}, {\"id\": 2}]", "description": "主键数组(如 [{\"id\": 1}, {\"id\": 2}]",
@@ -99,7 +101,7 @@ class DeleteTableRowsTool(ToolDef):
async def execute(self, args: dict) -> dict: async def execute(self, args: dict) -> dict:
args = dict(args) args = dict(args)
table_id = args.pop("tableId") table_id = args.pop("tableId")
target = args.pop("target", "prod") target = args.pop("target", "test")
primary_keys = args.pop("primaryKeys") primary_keys = args.pop("primaryKeys")
params = {"target": target} if target else {} params = {"target": target} if target else {}
body = {"primaryKeys": primary_keys} body = {"primaryKeys": primary_keys}
@@ -109,12 +111,12 @@ class DeleteTableRowsTool(ToolDef):
@register_tool("export_table_excel") @register_tool("export_table_excel")
class ExportTableExcelTool(ToolDef): class ExportTableExcelTool(ToolDef):
name = "export_table_excel" name = "export_table_excel"
description = "导出表数据为 Excel 文件(返回 base64 编码)" description = "导出表数据为 Excel 文件(返回 base64 编码)。target 默认 test调试环境导出线上数据须显式传 target=prod"
input_schema = { input_schema = {
"type": "object", "type": "object",
"properties": { "properties": {
"tableId": {"type": "string", "description": "表 ID"}, "tableId": {"type": "string", "description": "表 ID"},
"target": {"type": "string", "enum": ["prod", "test"], "default": "prod", "description": "环境,默认 prod"}, "target": {"type": "string", "enum": ["prod", "test"], "default": "test", "description": "环境,默认 test调试线上数据传 prod"},
}, },
"required": ["tableId"], "required": ["tableId"],
} }
@@ -122,7 +124,7 @@ class ExportTableExcelTool(ToolDef):
async def execute(self, args: dict) -> dict: async def execute(self, args: dict) -> dict:
args = dict(args) args = dict(args)
table_id = args.pop("tableId") table_id = args.pop("tableId")
target = args.pop("target", "prod") target = args.pop("target", "test")
params = {"target": target} if target else {} params = {"target": target} if target else {}
result = await self.client.get(f"/datasource/connection/builtin/table/{table_id}/export/excel", params=params) result = await self.client.get(f"/datasource/connection/builtin/table/{table_id}/export/excel", params=params)

View File

@@ -64,14 +64,14 @@ class AgileDBAPIClient:
return headers return headers
def _build_url(self, path: str) -> str: def _build_url(self, path: str) -> str:
"""构建完整 URL,自动去掉路径中多余的 /api 前缀""" """构建完整 URL
约定base_url 已包含完整地址(如含 /api 前缀则配在环境变量里),
各工具传入的 path 不带 /api 前缀,此处直接拼接。
"""
if path.startswith('http://') or path.startswith('https://'): if path.startswith('http://') or path.startswith('https://'):
return path return path
# 去掉 /api 前缀,因为 base_url 已经包含完整地址 return f"{self.base_url}{path}"
clean_path = path
if clean_path.startswith('/api'):
clean_path = clean_path[4:]
return f"{self.base_url}{clean_path}"
def _handle_response(self, response: httpx.Response, url: str) -> Dict[str, Any]: def _handle_response(self, response: httpx.Response, url: str) -> Dict[str, Any]:
"""统一处理 API 响应""" """统一处理 API 响应"""
@@ -80,20 +80,32 @@ class AgileDBAPIClient:
if response.status_code == 204: if response.status_code == 204:
return {"success": True, "data": None} return {"success": True, "data": None}
response.raise_for_status() # 先尝试解析 body再判断状态码。
# 平台会用非 2xx 状态码 + body 里的 {code, msg} 返回业务错误,
# 若先 raise_for_status() 会在解析 body 前抛异常,导致真正的 msg 全部丢失。
try: try:
data = response.json() data = response.json()
except json.JSONDecodeError: except (json.JSONDecodeError, UnicodeDecodeError):
# 非 JSON 响应(如文件下载 # 非 JSON 响应(如 Excel/文件下载,二进制内容 .json() 会抛 UnicodeDecodeError
response.raise_for_status()
return {"success": True, "data": response.content, "raw": True} return {"success": True, "data": response.content, "raw": True}
# 检查平台 API 的 {code, msg} 格式 # 检查平台 API 的 {code, msg} 格式
# 平台约定code 为 200 或 0 均表示成功(见数据库模块文档 §1.1
if isinstance(data, dict) and 'code' in data: if isinstance(data, dict) and 'code' in data:
if data['code'] != 200: if data['code'] not in (0, 200):
error_msg = data.get('msg', '未知错误') error_msg = data.get('msg', '未知错误')
logger.error(f"[API错误] {error_msg}") logger.error(f"[API错误] HTTP {response.status_code}, {error_msg}")
raise Exception(error_msg) raise Exception(error_msg)
# code 合法即视为业务成功,不再用 HTTP 状态码二次否决
return data
# 无 {code} 信封的响应:回落到 HTTP 状态码判断,
# 非 2xx 时把 body 文本带进异常,避免错误细节丢失。
if response.status_code >= 400:
detail = data if isinstance(data, str) else json.dumps(data, ensure_ascii=False)
logger.error(f"[API错误] HTTP {response.status_code}, {detail}")
raise Exception(f"HTTP {response.status_code}: {detail}")
return data return data
@@ -147,9 +159,13 @@ class AgileDBAPIClient:
try: try:
logger.info(f"[API请求] DELETE {url}") logger.info(f"[API请求] DELETE {url}")
headers = self._get_headers() headers = self._get_headers()
# 注意httpx 的 client.delete() 便捷方法不接受 json 参数(仅 post/put/patch 有)。
# 需要带 body 的 DELETE 必须走通用 request(),否则会抛 TypeError。
if json_data is not None: if json_data is not None:
headers['Content-Type'] = 'application/json' headers['Content-Type'] = 'application/json'
response = await self.client.delete(url, headers=headers, params=params, json=json_data) response = await self.client.request("DELETE", url, headers=headers, params=params, json=json_data)
else:
response = await self.client.delete(url, headers=headers, params=params)
return self._handle_response(response, url) return self._handle_response(response, url)
except httpx.TimeoutException: except httpx.TimeoutException:
raise Exception(f"API 请求超时: {url}") raise Exception(f"API 请求超时: {url}")

View File

@@ -44,7 +44,7 @@ def get_env_config() -> dict:
dict: 包含所有配置的字典 dict: 包含所有配置的字典
""" """
return { return {
"api_key": get_api_key(""), "api_key": os.environ.get("API_KEY", ""),
"base_url": get_base_url(), "base_url": get_base_url(),
} }

View File

@@ -0,0 +1,454 @@
# 数据库模块 —— 功能、接口与业务链路说明
> 适用范围:`src/components/databasePage/` 全部组件 + `src/server/database.ts` API 层
> 数据源分两类:**外置数据源external** 与 **内置数据源builtin / 内置 PostgreSQL**
> 所有接口前缀均为 `/api/datasource`MQTT 同步相关为 `/api/system/mqtt`
> 大部分接口超时设置为 `300000ms`5 分钟,因含 AI 处理)
---
## 0. 名词与基础概念
| 名词 | 说明 |
| --- | --- |
| 连接 / 数据源connection | 一条数据库连接记录,`sourceType``external``builtin` |
| 配置config | 数据源下「数据库 + 选中表」的关联配置,一条 config 对应一个库 |
| 表元数据table metadata | 平台侧记录的表结构字段、注释、AI 训练描述等) |
| 技能skill | 绑定到某数据源的「智能问数技能」 |
| 工具tool | 技能下的一条可复用 SQL 查询能力(沉淀为 MCP 工具) |
| 环境target | 内置数据源数据分 `prod`(线上)/ `test`(调试)两套 |
| status | 数据源状态:`0` 运行中,`1` 已停止 |
### 两类数据源核心差异
| 维度 | 外置数据源 external | 内置数据源 builtin |
| --- | --- | --- |
| 来源 | 连接用户已有的远程数据库 | 平台内置 PostgreSQL平台直接建库建表 |
| 默认数字员工 | `1001`SQL处理助手 | `1002`SQL处理助手 |
| 建表能力 | ❌ 只读连接,不能建表 | ✅ AI 智能建表 / Excel 导入建表 / 手动建表 |
| 数据增删改 | ❌ 不提供行级 CRUD | ✅ 行级增删改查 + Excel 导入导出 |
| 环境切换 | ❌ 无 | ✅ prod / test 双环境 |
| 表订阅同步 | ❌ | ✅ MQTT 数据变更订阅 |
| 创建入口 | `CreateDataSource.vue` | `CreateBuiltinDataSource.vue` |
---
## 1. 数据源(连接)的增删改查 —— 内外置通用
入口:[DataSourcePageMain.vue](../src/components/databasePage/DataSourcePageMain.vue) → [DataSourceList.vue](../src/components/databasePage/DataSourceList.vue)
### 1.1 查询(列表 / 详情)
| 功能 | 组件方法 | 接口函数 | 方法 | 路径 |
| --- | --- | --- | --- | --- |
| 数据源列表(分页/搜索/状态筛选) | `fetchDataSources()` | `getConnectionList(params)` | GET | `/api/datasource/connection/list` |
| 数据源详情 | `fetchDataSourceDetail()` | `getConnectionDetail(id)` | GET | `/api/datasource/connection/{id}` |
| 连接实例详情(含库表结构) | — | `getConnectionInstanceDetail(id)` | GET | `/api/datasource/connection/{id}` |
| 查看数据源实时库表结构 | `loadDatabaseList()` | `getConnectionRealtimeStructure(id)` | GET | `/api/datasource/connection/realtime/structure/{id}` |
- 列表查询参数:`{ datasourceName?, status?, pageNum, pageSize }`,前端用手动 `IntersectionObserver` 做无限滚动。
- 响应判定统一:`code === 200 || code === 0` 视为成功,列表数据在 `response.rows`,总数在 `response.total`
### 1.2 启用 / 停用
| 功能 | 组件方法 | 接口函数 | 方法 | 路径 |
| --- | --- | --- | --- | --- |
| 切换运行状态 | `handleChangeStatus()` | `putConnectionChangeStatus({ id, status })` | PUT | `/api/datasource/connection/changeStatus` |
- `status` 切换:`1->0` 启用,`0->1` 停用。
### 1.3 删除
| 功能 | 组件方法 | 接口函数 | 方法 | 路径 |
| --- | --- | --- | --- | --- |
| 删除数据源 | `handleDelete()` | `deleteConnection(id)` | DELETE | `/api/datasource/connection/{id}` |
**业务链路(删除)**
```
用户点删除 → Modal 二次确认
→ 若 status===0运行中先 putConnectionChangeStatus({id, status:1}) 停用
→ 等待 500ms 确保状态更新
→ deleteConnection(id)
→ 成功后 fetchDataSources() 刷新列表
```
---
## 2. 外置数据源external的创建与编辑
入口组件:[CreateDataSource.vue](../src/components/databasePage/CreateDataSource.vue)4 步向导)
### 2.1 步骤与接口
```
步骤0 选择数据库类型mysql/postgresql/oracle/sqlserver/dameng/kingbase/sqlite/mariadb
步骤1 配置连接信息host/port/库名/用户名/密码/认证方式)
→ 点「下一步」自动触发 testConnection() 测试连接
→ 测试通过 → postConnectionDetail() / putConnectionDetail() 创建/更新数据源
→ emit('refresh') 通知列表刷新
→ getConnectionRealtimeStructure() 拉取真实库表结构
步骤2 选择数据库和数据表大数据量shallowRef + 分批渲染 + 防抖搜索)
步骤3 确认信息
→ postConnectionConfig() / putConnectionConfig() 落库「库表关联」配置
→ emit('success')
```
### 2.2 接口清单
| 功能 | 接口函数 | 方法 | 路径 | 关键入参 |
| --- | --- | --- | --- | --- |
| 测试连接 | `testConnection(data)` | POST | `/api/datasource/connection/test` | host/port/databaseName/datasourceType/username/password/connectionType |
| 创建数据源 | `postConnectionDetail(data)` | POST | `/api/datasource/connection` | 同上 + datasourceName/remark |
| 更新数据源 | `putConnectionDetail(data)` | PUT | `/api/datasource/connection` | 同上 + id编辑时密码可空表示不改 |
| 拉取实时库表结构 | `getConnectionRealtimeStructure(id)` | GET | `/api/datasource/connection/realtime/structure/{id}` | 数据源 id |
| 创建库表关联配置 | `postConnectionConfig(data)` | POST | `/api/datasource/config` | connectionId / syncTables / databases[] |
| 更新库表关联配置 | `putConnectionConfig(data)` | PUT | `/api/datasource/config` | id + 同上 |
| 删除配置 | `deleteConnectionConfig(ids[])` | POST | `/api/datasource/config/deletes` | `{ ids: [] }` |
- `postConnectionConfig` 请求体结构:
```json
{
"id": "配置ID编辑传",
"connectionId": "数据源ID",
"syncTables": true,
"databases": [
{ "databaseName": "库名", "tableNames": ["表1", "表2"] }
]
}
```
- 编辑模式不回显密码;密码留空 = 不修改密码。
---
## 3. 内置数据源builtin / 内置 PostgreSQL的创建与编辑
入口组件:[CreateBuiltinDataSource.vue](../src/components/databasePage/CreateBuiltinDataSource.vue)
支持三种模式:`create`(新建数据源)/ `edit`(编辑数据源)/ `addTable`(为已有库新增表)。
### 3.1 步骤与接口create 模式)
```
步骤0 填写数据源信息datasourceName / remark
→ postCreateBuiltinPostgreSQLConnection() 创建内置 PG 连接,返回 connectionId
步骤1 填写业务场景描述 + 数据库名(仅字母数字下划线,自动加 _数据源id 后缀)
→ AI 生成表结构postGenerateTable() 返回 taskId
→ 轮询 getAiTrainingDetail(taskId)trainingStatus: 0待训/1训练中/2完成/3失败最多 60 次 × 2s
→ 解析 createTableData.data.tables 填入表列表
→ postCreateDatabase(connectionId, { databaseName }) 创建数据库
步骤2 预览 / 编辑表结构TableDetailEditor
→ handleSubmit() 遍历表逐张 postCreateTable(connectionId, requestData)
→ emit('refresh') + emit('success')
```
### 3.2 接口清单
| 功能 | 接口函数 | 方法 | 路径 |
| --- | --- | --- | --- |
| 创建内置 PG 连接 | `postCreateBuiltinPostgreSQLConnection(data)` | POST | `/api/datasource/connection/create_builtin_postgresql` |
| 修改内置 PG 连接 | `putUpdateBuiltinDatabase(data)` | PUT | `/api/datasource/connection/update_builtin_database` |
| AI 生成表结构 | `postGenerateTable(data)` | POST | `/api/datasource/connection/generate_table` |
| 查询 AI 训练任务详情(轮询) | `getAiTrainingDetail(taskId)` | GET | `/api/ai/training/{taskId}` |
| 创建数据库 | `postCreateDatabase(connectionId, data)` | POST | `/api/datasource/connection/{connectionId}/create_database` |
| 创建数据表 | `postCreateTable(connectionId, data)` | POST | `/api/datasource/connection/{connectionId}/create_table` |
| 创建库+表(合并) | `postCreateDatabaseTable(connectionId, data)` | POST | `/api/datasource/connection/{connectionId}/create_database_table` |
- `addTable` 模式不直接调建表接口,而是 `emit('submitTables', tables)` 交回父组件(如 DatabaseDetail提交。
- AI 业务描述润色走 `getAgentGeneric` + `AGENT_GENERIC_CODES.SQL_SCENARIO_DESCRIPTION``server/employee`)。
---
## 4. 数据库 / 表结构的增删改查(主要面向内置源)
入口组件:[DatabaseDetail.vue](../src/components/databasePage/DatabaseDetail.vue)(左侧表列表 + 右侧四视图)
### 4.1 表 / 字段的查询
| 功能 | 组件方法 | 接口函数 | 方法 | 路径 |
| --- | --- | --- | --- | --- |
| 表元数据列表(分页/无限滚动) | `getTablesList()` | `getTableAiList(params)` | GET | `/api/datasource/table/ailist` |
| 表详情(字段列表) | `executeSelectTable()` | `getTableDetail(id)` | GET | `/api/datasource/table/{id}/detail` |
| 表列表(基础) | — | `getTableList(data)` | GET | `/api/datasource/table/list` |
- `getTableAiList` 参数:`{ datasourceId, pageNum, pageSize }`(这里 `datasourceId` 实为「配置/库」id
- 切表有 Race Condition 防护:对比 `currentTableId` 拦截过期的详情请求。
### 4.2 库 / 表的增删改
| 功能 | 组件方法 | 接口函数 | 方法 | 路径 |
| --- | --- | --- | --- | --- |
| 修改数据库(改名) | `handleDatabaseSettingsConfirm()` | `putAlterDatabase(connectionId, data)` | PUT | `/api/datasource/connection/{connectionId}/alter_database` |
| 删除库(配置) | `handleDeleteDatabase()` | `deleteConnectionConfig(ids[])` | POST | `/api/datasource/config/deletes` |
| AI 智能建表(入口) | `createNewTable()` | 仅打开弹窗(内嵌 `CreateBuiltinDataSource` 的 addTable 模式) | — | — |
| 批量建表(提交) | `handleSubmitTables(tables)` | 循环 `postCreateTable(connectionId, data)` | POST | `/api/datasource/connection/{connectionId}/create_table` |
| 修改表结构 | `handleSaveTable()` | `putAlterTable(connectionId, data)` | PUT | `/api/datasource/connection/{connectionId}/alter_table` |
- 修改库后会重新 `getConnectionDetail(id)` 并 `emit('refresh', updatedDataSource)` 同步父级。
- 改表用增量 operation 标记字段:`ADD_COLUMN` / `MODIFY_COLUMN` / `DROP_COLUMN`(来自 TableDetailEditor
### 4.3 智能导入建表Excel
入口组件:[TableRecognition.vue](../src/components/databasePage/TableRecognition.vue)(两步向导)
```
步骤0 上传 Excel≤500KB+ 选环境
→ postImportDocumentPreview(connectionId, file, target) AI 识别前10条 → 表结构预览
步骤1 编辑确认表结构与数据
→ postImportDocumentConfirm(connectionId, data, {target}) 建表 + 插入数据
→ emit('complete')
```
| 功能 | 接口函数 | 方法 | 路径 |
| --- | --- | --- | --- |
| 文档导入预览AI识别 | `postImportDocumentPreview(connectionId, file, target)` | POST | `/api/datasource/connection/{connectionId}/import_document/preview` |
| 文档导入确认(建表+插数据) | `postImportDocumentConfirm(connectionId, data, params)` | POST | `/api/datasource/connection/{connectionId}/import_document/confirm` |
---
## 5. 内置表「数据行」的增删改查builtin 专属prod/test 双环境)
入口组件:[CustomizeDbTable.vue](../src/components/databasePage/CustomizeDbTable.vue)(包裹通用 `CommonDbTable`
所有接口都带 `target` 参数区分线上 / 调试环境。
| 功能 | 组件回调 | 接口函数 | 方法 | 路径 |
| --- | --- | --- | --- | --- |
| 查(分页拉数据) | `onFetch` | `getBuiltinTableData(tableId, {pageNum,pageSize,target})` | GET | `/api/datasource/connection/builtin/table/{tableId}` |
| 增(新增行) | `onAdd` | `postBuiltinTableRows(tableId, data, {target})` | POST | `/api/datasource/connection/builtin/table/{tableId}/rows` |
| 改(更新行) | `onEdit` | `putBuiltinTableRows(tableId, data, {target})` | PUT | `/api/datasource/connection/builtin/table/{tableId}/rows` |
| 删(删除行) | `onDelete` | `deleteBuiltinTableRows(tableId, {primaryKeys}, {target})` | DELETE | `/api/datasource/connection/builtin/table/{tableId}/rows` |
| 导出 Excel | `onExport` | `getBuiltinTableExportExcel(tableId, {target})` | GET | `/api/datasource/connection/builtin/table/{tableId}/export/excel` |
| 导入数据 | `onImport` → `handleImportComplete` | `postImportDocumentConfirm(...)` | POST | 见上 |
- `onFetch` 把后端二维数组 `content` 按列名映射成对象数组。
- `onEdit/onDelete` 用动态主键(`rowKey`,默认 `id`)拼装 `primaryKey` / `primaryKeys`。
- 删除入参结构:`{ primaryKeys: [ { 主键字段: 值 }, ... ] }`。
- 导出接口为 `responseType: 'blob'`含完整错误兜底blob 是 JSON 时解析出 msg
---
## 6. 智能问数技能skill与工具tool的增删改查
入口组件:[ChatDebugging.vue](../src/components/databasePage/ChatDebugging.vue) + [SqlControllerMsg.vue](../src/components/databasePage/SqlControllerMsg.vue)
### 6.1 接口清单
| 功能 | 接口函数 | 方法 | 路径 |
| --- | --- | --- | --- |
| 查询数据源配置(含 skillBool | `getConnectionConfig(id)` | GET | `/api/datasource/config/{id}` |
| 按数据源查技能 | `getSkillByDatasource(id)` | GET | `/api/datasource/skill/getByDatasource/{id}` |
| 按技能 ID 查工具列表 | `getSkillBySkillId(id)` | GET | `/api/datasource/skill/getBySkillId/{id}` |
| 创建技能 | `postSkillCreateOrGet(data)` | POST | `/api/datasource/skill/createOrGet` |
| 修改技能 | `putSkillUpdateOrGet(data)` | POST | `/api/datasource/skill/updateOrGet` |
| 确认/创建技能工具 | `postSqlSkillConfirmTools(data)` | POST | `/api/datasource/skill/confirmTools` |
| 修改工具名/描述 | `postSkillToolUpdateOrGet(data)` | POST | `/api/datasource/skill/tskilltool/updateOrGet` |
| 删除技能工具 | `postDeleteSkillTool(skillToolId)` | DELETE | `/api/datasource/skill/tskilltool/{skillToolId}` |
> 说明:`executeSql`POST `/api/datasource/sqlExecutionLog/testSqlWithSchema`)虽在 `server/database.ts` 中定义,但在 databasePage 模块内**未被任何组件调用**——它仅在工作流编辑器 [workflowPage/EditorMain.vue](../src/components/workflowPage/EditorMain.vue) 里使用。智能问数场景下SQL 由后端 AI 通过 SSE 流执行并直接返回结果,前端不再单独调用执行接口。
### 6.2 「把查询沉淀为工具」业务链路SqlControllerMsg 核心)
```
AI 返回 msgType=5 的 SQL 结果 → SqlControllerMsg 解析多视图
用户点「添加到工具」
→ 校验环境test 禁止)、是否选库、重复检测
→ 分两条路径:
┌ skillBool=true技能已存在
│ → postSqlSkillConfirmTools() 直接确认工具
└ skillBool=false技能不存在
→ postSkillCreateOrGet() 创建技能
→ putSkillUpdateOrGet() 写入 MCP 配置模板 lzwcai-mcp-sqlexecutor
→ postSqlSkillConfirmTools() 确认工具
→ eventBus.emit(RELOAD_SKILL_DATA, datasourceId)
→ ChatDebugging 监听到事件 → loadSkillData() 刷新左侧技能/工具列表
```
- 重复检测链路:`getConnectionConfig → getSkillByDatasource → getSkillBySkillId`,比对 `sqlTemplate`。
---
## 7. AI 补全训练
入口:[DatabaseDetail.vue](../src/components/databasePage/DatabaseDetail.vue)「AI补全管理」视图
| 功能 | 接口函数 | 方法 | 路径 |
| --- | --- | --- | --- |
| 训练任务列表 | `getAiTrainingList(data)` | GET | `/api/ai/training/list` |
| 按选中表创建训练 | `postAiTrainingCreateBySelected(data)` | POST | `/api/ai/training/createBySelected` |
| 训练任务详情 | `getAiTrainingDetail(id)` | GET | `/api/ai/training/{id}` |
- 训练状态:`0` 待训练 / `1` 训练中 / `2` 已完成 / `3` 失败。
---
## 8. 字段关联同步MQTTbuiltin 专属)
入口:[DatabaseDetail.vue](../src/components/databasePage/DatabaseDetail.vue)「字段关联管理」视图
| 功能 | 接口函数 | 方法 | 路径 |
| --- | --- | --- | --- |
| 同步配置列表 | `getMqttConfigList(params)` | GET | `/api/system/mqtt/config/list` |
| 配置详情 | `getMqttConfigDetail(configId)` | GET | `/api/system/mqtt/config/{configId}` |
| 目标表列表 | `getMqttConfigTableList()` | GET | `/api/system/mqtt/config/tableList` |
| 目标表字段列表 | `getMqttConfigTableColumns(tableName)` | GET | `/api/system/mqtt/config/tableColumns/{tableName}` |
| 新增配置 | `postMqttConfig(data)` | POST | `/api/system/mqtt/config` |
| 修改配置 | `putMqttConfig(data)` | PUT | `/api/system/mqtt/config` |
| 删除配置 | `deleteMqttConfig(ids)` | DELETE | `/api/system/mqtt/config/{ids}` |
| 刷新缓存 | `getMqttConfigRefreshCache()` | GET | `/api/system/mqtt/config/refreshCache` |
| 切换表订阅同步 | `postDatasourceSubscriptionToggle(data)` | POST | `/api/datasource/subscription/toggle` |
> ⚠️ 注意:提交配置时前端故意调换 `sourceTable` / `targetTable`,回显时再换回来(见 DatabaseDetail 的 `applyRelationConfigByTargetTable` 与 `loadRelationConfigByTableId`)。
---
## 9. API 密钥与权限的增删改查
入口组件:[DataSourceKeys.vue](../src/components/databasePage/DataSourceKeys.vue) + [DataSourceKeySetting.vue](../src/components/databasePage/DataSourceKeySetting.vue)
### 9.1 密钥 CRUD
| 功能 | 接口函数 | 方法 | 路径 |
| --- | --- | --- | --- |
| 密钥列表 | `getApiKeyList(data)` | GET | `/api/datasource/api_key/list` |
| 新增密钥 | `postApiKey(data)` | POST | `/api/datasource/api_key` |
| 修改密钥(含启停) | `putApiKey(data)` | PUT | `/api/datasource/api_key` |
| 删除密钥 | `deleteApiKey(ids)` | DELETE | `/api/datasource/api_key/{ids}` |
### 9.2 权限配置
| 功能 | 接口函数 | 方法 | 路径 |
| --- | --- | --- | --- |
| 查询密钥权限 | `getApiKeyPermission(apiKeyId)` | GET | `/api/datasource/api_key/permission/{apiKeyId}` |
| 批量授予权限 | `postApiKeyPermissionGrantBatch(data)` | POST | `/api/datasource/api_key/permission/grant_batch` |
权限配置走 `DataSourceKeySetting` 四步向导(数据源 → 数据库 → 数据表分三级权限connection / database / table。其中加载列表复用
| 步骤 | 接口函数 | 路径 |
| --- | --- | --- |
| 选数据源 | `getConnectionList` | `/api/datasource/connection/list` |
| 选数据库 | `getConnectionConfigList` | `/api/datasource/config/list` |
| 选数据表 | `getTableList` | `/api/datasource/table/list` |
`postApiKeyPermissionGrantBatch` 请求体batchDatas每项
```json
{
"apiKeyId": "密钥ID",
"batchDatas": [
{
"connectionId": "数据源ID",
"permissionLevel": "connection|database|table",
"permissionType": "read,write逗号拼接",
"databaseName": "库名database/table 级)",
"tableName": "表名table 级)"
}
]
}
```
---
## 10. 端到端业务链路总览
### 链路 A外置数据源「从创建到智能问数」
```
DataSourcePageMain → 添加远程数据源
→ CreateDataSource 4步:
testConnection → postConnectionDetail → getConnectionRealtimeStructure → postConnectionConfig
→ 列表出现卡片 → 点「业务执行」
→ ChatDebugging员工1001三栏 → DataSourcePlugIn 选库 → ChatBusiness 提问
→ 后端 AI 经 SSE 流执行 SQL 并返回结果 (msgType=5) → SqlControllerMsg 多视图渲染
→ 可「添加到工具」沉淀为技能
```
### 链路 B内置数据源「从建库建表到数据管理」
```
DataSourcePageMain → 添加内置数据源
→ CreateBuiltinDataSource:
postCreateBuiltinPostgreSQLConnection
→ postGenerateTable + 轮询 getAiTrainingDetailAI 建表结构)
→ postCreateDatabase → postCreateTable
→ DatabaseDetail 管理:
getTableAiList表列表→ getTableDetail字段
→ CustomizeDbTable 行级 CRUDprod/test
→ 智能导入 TableRecognitionpreview→confirm
→ 字段关联 MQTT / AI 补全训练 / 订阅
→ 业务执行员工1002智能问数同链路 A
```
### 链路 C对外开放API 密钥)
```
DataSourceKeys → postApiKey 建密钥
→ DataSourceKeySetting 配权限(连接→库→表三级)
→ postApiKeyPermissionGrantBatch 批量授权
→ ApiCallDocument 查看调用文档(/API_DOCUMENTATION.md
→ 外部系统凭密钥调用数据查询
```
---
## 附录:完整接口索引(按 server/database.ts 顺序)
| # | 函数 | 方法 | 路径 | 用途 |
| --- | --- | --- | --- | --- |
| 1 | `getTableDetail` | GET | `/api/datasource/table/{id}/detail` | 表详情 |
| 2 | `getConnectionList` | GET | `/api/datasource/connection/list` | 连接列表 |
| 3 | `getConnectionDetail` | GET | `/api/datasource/connection/{id}` | 连接详情 |
| 4 | `getConnectionInstanceDetail` | GET | `/api/datasource/connection/{id}` | 连接实例详情 |
| 5 | `getTableList` | GET | `/api/datasource/table/list` | 表列表 |
| 6 | `getMqttConfigList` | GET | `/api/system/mqtt/config/list` | MQTT配置列表 |
| 7 | `getMqttConfigDetail` | GET | `/api/system/mqtt/config/{configId}` | MQTT配置详情 |
| 8 | `getMqttConfigTableList` | GET | `/api/system/mqtt/config/tableList` | MQTT目标表 |
| 9 | `getMqttConfigTableColumns` | GET | `/api/system/mqtt/config/tableColumns/{tableName}` | MQTT表字段 |
| 10 | `postMqttConfig` | POST | `/api/system/mqtt/config` | 新增MQTT配置 |
| 11 | `putMqttConfig` | PUT | `/api/system/mqtt/config` | 改MQTT配置 |
| 12 | `deleteMqttConfig` | DELETE | `/api/system/mqtt/config/{ids}` | 删MQTT配置 |
| 13 | `getMqttConfigRefreshCache` | GET | `/api/system/mqtt/config/refreshCache` | 刷新缓存 |
| 14 | `getTableAiList` | GET | `/api/datasource/table/ailist` | 表元数据列表 |
| 15 | `testConnection` | POST | `/api/datasource/connection/test` | 测试连接 |
| 16 | `postConnectionDetail` | POST | `/api/datasource/connection` | 创建连接 |
| 17 | `postCreateBuiltinPostgreSQLConnection` | POST | `/api/datasource/connection/create_builtin_postgresql` | 创建内置PG |
| 18 | `postCreateDatabaseTable` | POST | `/api/datasource/connection/{id}/create_database_table` | 创建库+表 |
| 19 | `postCreateTable` | POST | `/api/datasource/connection/{id}/create_table` | 创建表 |
| 20 | `postCreateDatabase` | POST | `/api/datasource/connection/{id}/create_database` | 创建库 |
| 21 | `putAlterDatabase` | PUT | `/api/datasource/connection/{id}/alter_database` | 改库 |
| 22 | `putAlterTable` | PUT | `/api/datasource/connection/{id}/alter_table` | 改表 |
| 23 | `postGenerateTable` | POST | `/api/datasource/connection/generate_table` | AI生成表结构 |
| 24 | `putUpdateBuiltinDatabase` | PUT | `/api/datasource/connection/update_builtin_database` | 改内置PG |
| 25 | `putConnectionDetail` | PUT | `/api/datasource/connection` | 更新连接 |
| 26 | `deleteConnection` | DELETE | `/api/datasource/connection/{id}` | 删连接 |
| 27 | `deleteConnectionConfig` | POST | `/api/datasource/config/deletes` | 删配置 |
| 28 | `putConnectionConfig` | PUT | `/api/datasource/config` | 改库表配置 |
| 29 | `postConnectionConfig` | POST | `/api/datasource/config` | 建库表配置 |
| 30 | `getConnectionRealtimeStructure` | GET | `/api/datasource/connection/realtime/structure/{id}` | 实时库表结构 |
| 31 | `getSkillByDatasource` | GET | `/api/datasource/skill/getByDatasource/{id}` | 按源查技能 |
| 32 | `getSkillBySkillId` | GET | `/api/datasource/skill/getBySkillId/{id}` | 查工具列表 |
| 33 | `postSkillCreateOrGet` | POST | `/api/datasource/skill/createOrGet` | 建技能 |
| 34 | `putSkillUpdateOrGet` | POST | `/api/datasource/skill/updateOrGet` | 改技能 |
| 35 | `postDeleteSkillTool` | DELETE | `/api/datasource/skill/tskilltool/{id}` | 删工具 |
| 36 | `postSkillToolUpdateOrGet` | POST | `/api/datasource/skill/tskilltool/updateOrGet` | 改工具 |
| 37 | `getAiTrainingList` | GET | `/api/ai/training/list` | 训练列表 |
| 38 | `postAiTrainingCreateBySelected` | POST | `/api/ai/training/createBySelected` | 建训练 |
| 39 | `getAiTrainingDetail` | GET | `/api/ai/training/{id}` | 训练详情 |
| 40 | `putConnectionChangeStatus` | PUT | `/api/datasource/connection/changeStatus` | 启停连接 |
| 41 | `getConnectionConfig` | GET | `/api/datasource/config/{id}` | 查源配置 |
| 42 | `getConnectionConfigList` | GET | `/api/datasource/config/list` | 配置列表 |
| 43 | `postSqlSkillConfirmTools` | POST | `/api/datasource/skill/confirmTools` | 确认工具 |
| 44 | `getApiKeyList` | GET | `/api/datasource/api_key/list` | 密钥列表 |
| 45 | `postApiKey` | POST | `/api/datasource/api_key` | 建密钥 |
| 46 | `putApiKey` | PUT | `/api/datasource/api_key` | 改密钥 |
| 47 | `deleteApiKey` | DELETE | `/api/datasource/api_key/{ids}` | 删密钥 |
| 48 | `getApiKeyPermission` | GET | `/api/datasource/api_key/permission/{apiKeyId}` | 查权限 |
| 49 | `postApiKeyPermissionGrantBatch` | POST | `/api/datasource/api_key/permission/grant_batch` | 批量授权 |
| 50 | `postDatasourceSubscriptionToggle` | POST | `/api/datasource/subscription/toggle` | 订阅开关 |
| 51 | `getBuiltinTableData` | GET | `/api/datasource/connection/builtin/table/{tableId}` | 查内置表数据 |
| 52 | `postBuiltinTableRows` | POST | `/api/datasource/connection/builtin/table/{tableId}/rows` | 增行 |
| 53 | `putBuiltinTableRows` | PUT | `/api/datasource/connection/builtin/table/{tableId}/rows` | 改行 |
| 54 | `deleteBuiltinTableRows` | DELETE | `/api/datasource/connection/builtin/table/{tableId}/rows` | 删行 |
| 55 | `getBuiltinTableExportExcel` | GET | `/api/datasource/connection/builtin/table/{tableId}/export/excel` | 导出Excel |
| 56 | `postImportDocumentPreview` | POST | `/api/datasource/connection/{id}/import_document/preview` | 导入预览 |
| 57 | `postImportDocumentConfirm` | POST | `/api/datasource/connection/{id}/import_document/confirm` | 导入确认 |
| 58 | `executeSql` | POST | `/api/datasource/sqlExecutionLog/testSqlWithSchema` | 执行SQL |

View File

@@ -174,7 +174,7 @@
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| datasourceId | string | 是 | 数据源 ID | | connectionId | string | 是 | 数据源连接 ID(路径参数) |
| databaseName | string | 是 | 目标数据库名 | | databaseName | string | 是 | 目标数据库名 |
| tableName | string | 是 | 表名(小写字母+数字+下划线) | | tableName | string | 是 | 表名(小写字母+数字+下划线) |
| tableComment | string | 否 | 表注释 | | tableComment | string | 否 | 表注释 |
@@ -198,33 +198,29 @@
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| tableId | string | 是 | 表 ID | | connectionId | string | 是 | 数据源连接 ID路径参数 |
| columns | array | 是 | 字段变更数组 | | databaseName | string | 是 | 数据库名 |
| columns[].operation | string | 是 | ADD_COLUMN / MODIFY_COLUMN / DROP_COLUMN | | tableName | string | 是 | 表名 |
| columns[].columnName | string | 是 | 字段名 | | operations | array | 是 | 表结构变更操作数组 |
| columns[].columnType | string | | 字段类型ADD/MODIFY | | operations[].operation | string | | ADD_COLUMN / DROP_COLUMN / RENAME_COLUMN / ALTER_COLUMN_TYPE / SET_NOT_NULL / DROP_NOT_NULL / SET_DEFAULT / DROP_DEFAULT |
| columns[].columnLength | int | | 字段长度 | | operations[].column | object | | 列定义(根据 operation 不同包含不同字段,如 columnName/columnType/newColumnName 等) |
| columns[].isPrimaryKey | bool | 否 | 是否主键 | | tableComment | string | 否 | 新表注释 |
| columns[].isNullable | bool | 否 | 是否可空 |
| columns[].columnComment | string | 否 | 字段注释 |
| columns[].defaultValue | string | 否 | 默认值 |
| newTableName | string | 否 | 新表名(重命名) |
| newTableComment | string | 否 | 新表注释 |
**返回**:修改结果 **返回**:修改结果
--- ---
#### 12. `generate_table_by_description` #### 12. `generate_table_by_description`
- **用途**:通过自然语言描述让 AI 生成表结构 - **用途**:通过自然语言描述让 AI 生成表结构(异步任务)
- **对应前端**CreateBuiltinDataSource.vue AI 生成表结构 - **对应前端**CreateBuiltinDataSource.vue AI 生成表结构
- **对应 API**`postGenerateTable(data)` ✅ 已实现 — `POST /api/datasource/connection/generate_table` - **对应 API**`postGenerateTable(data)` ✅ 已实现 — `POST /api/datasource/connection/generate_table`
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| description | string | 是 | 业务场景描述至少6个字符 | | requirement | string | 是 | 业务需求描述 |
| databaseId | int | 否 | 关联的数据库 ID |
**返回**AI 生成的表结构(表名、表注释、字段列表含类型/主键/注释等) **返回**异步任务信息taskId、status需轮询任务状态获取最终生成的表结构
> **场景示例**:用户说"我需要一个商城系统,管理商品、分类和用户评价"AI 返回完整的表结构设计。 > **场景示例**:用户说"我需要一个商城系统,管理商品、分类和用户评价"AI 返回完整的表结构设计。
@@ -389,6 +385,18 @@
--- ---
#### 23.5 `revoke_api_key_permissions`
- **用途**:撤销/删除 API 密钥已授予的权限(按权限记录 ID
- **对应 API**:按现有 delete 风格推测为 `DELETE /api/datasource/api_key/permission/{ids}`,需后端真机验证
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| permissionIds | array[string] | 是 | 权限记录 ID 列表。先从 `get_api_key_permissions` 获取,取 `connectionPermissions` / `databasePermissions` / `tablePermissions` 中每项的 `id` |
**返回**:撤销结果
---
### 🤖 技能与工具管理 (内置数据源 AI 能力) ### 🤖 技能与工具管理 (内置数据源 AI 能力)
#### 24. `get_skill_by_datasource` #### 24. `get_skill_by_datasource`
@@ -437,12 +445,14 @@
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| skillId | string | 是 | 技能 ID | | skillId | string | 是 | 技能 ID |
| name | string | | 工具名称 | | tableIds | array | | 关联的表 ID 数组 |
| businessDescription | string | 是 | 业务描述 | | suggestions | array | 是 | SQL 工具建议数组(支持批量) |
| sqlTemplate | string | 是 | SQL 模板(支持 #{param} 参数占位) | | suggestions[].name | string | 是 | 工具名称 |
| sqlParams | string | | 参数 JSON Schema默认空对象 | | suggestions[].businessDescription | string | | 业务描述 |
| resultType | string | | single/list默认 list | | suggestions[].sqlTemplate | string | | SQL 模板(支持 #{param} 参数占位) |
| businessScenario | string | 否 | 业务场景描述 | | suggestions[].sqlParams | string/object | 否 | 参数 JSON Schema对象会自动序列化为字符串 |
| suggestions[].resultType | string | 否 | single/list默认 list |
| suggestions[].businessScenario | string | 否 | 业务场景描述 |
**返回**:工具创建结果 **返回**:工具创建结果

View File

@@ -7,6 +7,6 @@ from lzwcai_mcp_agile_db.server import main
import os import os
if __name__ == "__main__": if __name__ == "__main__":
os.environ["API_KEY"] = "Bearer eyJhbGciOiJIUzUxMiJ9.eyJ0b2tlbl90eXBlIjoiTE9HSU4iLCJsb2dpbl91c2VyX2tleSI6IjJiMDY0YzMzLTBiZWYtNDU0NC04NWY1LTRmNTFiOGMxMmI5NSJ9.Uv599TvlQvlTlwrnZGo3Tl2eLAvM0ldE9vpMI5jHxbTf4_tVSRA60rUNIV4LBiw6pt1r_xIi7aFcTRE2PeN5sg" os.environ["API_KEY"] = "Bearer eyJhbGciOiJIUzUxMiJ9.eyJ0b2tlbl90eXBlIjoiTE9HSU4iLCJsb2dpbl91c2VyX2tleSI6ImNlMDAwYjA4LWU0YTYtNGM2MS1hNzJiLWI3NTlmNmY1N2Q4NCJ9.jiNmGQZfL4-nSIFrLuaCt7mT5zj0FOojAVkLeHwPOroI5jBxodrCe1PSwGO1OHq5Ztb0tLEVZw2FFVj0OlTceQ"
os.environ["backendBaseUrl"] = "https://dempdemo.lzwcai.com" os.environ["backendBaseUrl"] = "http://192.168.2.236:8088"
main() main()

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "lzwcai-mcp-agile-db" name = "lzwcai-mcp-agile-db"
version = "0.1.3" version = "0.1.7"
description = "MCP server for database management platform with 33 tools for datasource, table, data, API key, and skill management" description = "MCP server for database management platform with 33 tools for datasource, table, data, API key, and skill management"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

View File

@@ -0,0 +1 @@
3.12

View File

@@ -0,0 +1,75 @@
# lzwcai-mcp-agile-db-third
AgileDB 数据源管理 MCP Server基于 `API_DOCUMENTATION.md` 将后端数据源/连接/DDL/DML/API 接口封装为 34 个 MCP 工具。
## 功能概述
本服务将后端 `datasource` 模块的 API 接口代理为标准 MCP 工具,分为两大类:
### 1. 数据源配置管理
- `list_datasource_configs`:查询数据源配置列表
- `get_datasource_config`:获取数据源配置详情
- `batch_create_datasource_configs`:批量创建数据源配置
- `replace_datasource_configs`:全量替换数据源配置
- `batch_update_datasource_configs`:批量修改数据源配置
- `delete_datasource_configs`:批量删除数据源配置
- `test_connection_config`:测试数据库连接
- `change_datasource_status`:修改数据源状态
- `export_datasource_configs`:导出数据源配置为 Excel
### 2. 数据库连接实例管理
- `list_connections` / `get_connection` / `create_connection` / `update_connection` / `delete_connection`:连接实例 CRUD
- `test_connection` / `change_connection_status`:连接测试与状态切换
- `realtime_structure` / `realtime_databases` / `realtime_tables`:实时查询库表结构
- `create_builtin_postgresql` / `update_builtin_database`:内置 PostgreSQL 连接管理
- `execute_sql`:执行原生 SQL
- `create_database` / `create_table` / `create_database_table` / `alter_database` / `alter_table`DDL 操作
- `generate_table`AI 生成表结构
- `import_document_preview` / `import_document_confirm`Excel/CSV 文档导入
- `builtin_table_data` / `builtin_table_insert` / `builtin_table_update` / `builtin_table_delete`:表数据 CRUD
## 环境配置
| 环境变量 | 说明 | 默认值 |
|----------|------|--------|
| `backendBaseUrl` | 后端 API 基础地址 | `http://lzwcai-demp-corp-manager:8086` |
| `datasourceApiKey` | 默认 `X-Datasource-API-Key`,可选 | 空 |
| `LOG_LEVEL` | 日志级别 | `INFO` |
## 安装与运行
使用 uv
```bash
uv sync
uv run python -m lzwcai_mcp_agile_db_third.main
```
或使用 pip
```bash
python -m venv .venv
.venv\Scripts\python -m pip install mcp httpx
.venv\Scripts\python -m lzwcai_mcp_agile_db_third.main
```
安装为命令后:
```bash
lzwcai-mcp-agile-db-third
```
## 使用 mcp CLI
```bash
mcp dev lzwcai_mcp_agile_db_third/main.py
```
## 注意事项
- 所有写操作(创建/修改/删除)会先落到 `prod` 环境;带 `target` 参数的工具可切换为 `test`
- `import_document_preview` 通过本地文件路径上传 Excel/CSV
- 执行删除类工具前,调用方应遵循安全确认原则,向用户展示影响范围并二次确认
- 日志文件中会对 `password``apiKey``token``secret` 等敏感字段进行脱敏

View File

@@ -0,0 +1,21 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
*.egg
# Virtual environments
.venv/
venv/
# Logs
logs/
*.log
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,75 @@
# lzwcai-mcp-agile-db-third
AgileDB 数据源管理 MCP Server基于 `API_DOCUMENTATION.md` 将后端数据源/连接/DDL/DML/API 接口封装为 34 个 MCP 工具。
## 功能概述
本服务将后端 `datasource` 模块的 API 接口代理为标准 MCP 工具,分为两大类:
### 1. 数据源配置管理
- `list_datasource_configs`:查询数据源配置列表
- `get_datasource_config`:获取数据源配置详情
- `batch_create_datasource_configs`:批量创建数据源配置
- `replace_datasource_configs`:全量替换数据源配置
- `batch_update_datasource_configs`:批量修改数据源配置
- `delete_datasource_configs`:批量删除数据源配置
- `test_connection_config`:测试数据库连接
- `change_datasource_status`:修改数据源状态
- `export_datasource_configs`:导出数据源配置为 Excel
### 2. 数据库连接实例管理
- `list_connections` / `get_connection` / `create_connection` / `update_connection` / `delete_connection`:连接实例 CRUD
- `test_connection` / `change_connection_status`:连接测试与状态切换
- `realtime_structure` / `realtime_databases` / `realtime_tables`:实时查询库表结构
- `create_builtin_postgresql` / `update_builtin_database`:内置 PostgreSQL 连接管理
- `execute_sql`:执行原生 SQL
- `create_database` / `create_table` / `create_database_table` / `alter_database` / `alter_table`DDL 操作
- `generate_table`AI 生成表结构
- `import_document_preview` / `import_document_confirm`Excel/CSV 文档导入
- `builtin_table_data` / `builtin_table_insert` / `builtin_table_update` / `builtin_table_delete`:表数据 CRUD
## 环境配置
| 环境变量 | 说明 | 默认值 |
|----------|------|--------|
| `backendBaseUrl` | 后端 API 基础地址 | `http://lzwcai-demp-corp-manager:8086` |
| `datasourceApiKey` | 默认 `X-Datasource-API-Key`,可选 | 空 |
| `LOG_LEVEL` | 日志级别 | `INFO` |
## 安装与运行
使用 uv
```bash
uv sync
uv run python -m lzwcai_mcp_agile_db_third.main
```
或使用 pip
```bash
python -m venv .venv
.venv\Scripts\python -m pip install mcp httpx
.venv\Scripts\python -m lzwcai_mcp_agile_db_third.main
```
安装为命令后:
```bash
lzwcai-mcp-agile-db-third
```
## 使用 mcp CLI
```bash
mcp dev lzwcai_mcp_agile_db_third/main.py
```
## 注意事项
- 所有写操作(创建/修改/删除)会先落到 `prod` 环境;带 `target` 参数的工具可切换为 `test`
- `import_document_preview` 通过本地文件路径上传 Excel/CSV
- 执行删除类工具前,调用方应遵循安全确认原则,向用户展示影响范围并二次确认
- 日志文件中会对 `password``apiKey``token``secret` 等敏感字段进行脱敏

View File

@@ -0,0 +1 @@
"""lzwcai_mcp_agile_db_third package."""

View File

@@ -0,0 +1,216 @@
"""MCP server entrypoint for Agile DB third-party datasource APIs."""
import asyncio
import json
import logging
from typing import Any, Dict, List, Optional
try:
from .tools import list_tools, get_tool
from .utils import DataSourceAPIClient, get_env_config, get_api_key
from .utils.logger_config import logger_config
except ImportError:
from tools import list_tools, get_tool
from utils import DataSourceAPIClient, get_env_config, get_api_key
from utils.logger_config import logger_config
from mcp.server import NotificationOptions, Server
from mcp.server.models import InitializationOptions
import mcp.types as types
mcp_logger = logger_config.setup_mcp_logging()
def _text_response(payload: Dict[str, Any]) -> List[types.TextContent]:
"""Build a JSON text response."""
return [
types.TextContent(
type="text",
text=json.dumps(payload, ensure_ascii=False, indent=2),
)
]
def _build_tool(tool_def: Dict[str, Any]) -> types.Tool:
"""Build an MCP Tool from a tool definition."""
return types.Tool(
name=tool_def["name"],
description=tool_def["description"],
inputSchema=tool_def["inputSchema"],
)
server = Server("lzwcai-mcp-agile-db-third")
_api_client: Optional[DataSourceAPIClient] = None
def _get_api_client() -> DataSourceAPIClient:
"""Get or create the default API client."""
global _api_client
if _api_client is None:
env = get_env_config()
_api_client = DataSourceAPIClient(base_url=env.get("backend_base_url"))
return _api_client
@server.list_tools()
async def handle_list_tools() -> List[types.Tool]:
"""List all available MCP tools."""
try:
mcp_logger.info("收到列出工具请求")
tools = [_build_tool(tool_def) for tool_def in list_tools()]
mcp_logger.info(f"成功生成 {len(tools)} 个 MCP 工具")
return tools
except Exception as e:
mcp_logger.error(f"列出工具失败: {e}", exc_info=True)
raise
@server.call_tool()
async def handle_call_tool(
name: str,
arguments: Optional[Dict[str, Any]],
) -> List[types.TextContent]:
"""Handle MCP tool invocation by proxying to the backend API."""
try:
mcp_logger.info(f"收到工具调用请求: {name}")
mcp_logger.debug(f"工具参数: {arguments}")
tool_def = get_tool(name)
if tool_def is None:
error_msg = f"未找到工具: {name}"
mcp_logger.warning(error_msg)
return _text_response({"error": error_msg})
# Validate required parameters
args = arguments or {}
schema = tool_def["inputSchema"]
required = schema.get("required", [])
missing = [key for key in required if key not in args or args[key] is None]
if missing:
error_msg = f"工具 {name} 缺少必填参数: {', '.join(missing)}"
mcp_logger.warning(error_msg)
return _text_response({"error": error_msg, "missing": missing})
# Categorize parameters
path_params: Dict[str, Any] = {}
query_params: Dict[str, Any] = {}
body_params: Dict[str, Any] = {}
file_path: Optional[str] = None
api_key: Optional[str] = None
categories = tool_def["paramCategories"]
for key, value in args.items():
if value is None:
continue
category = categories.get(key)
if category == "path":
path_params[key] = value
elif category == "query":
query_params[key] = value
elif category == "body":
body_params[key] = value
elif category == "file":
file_path = value
elif category == "header":
api_key = value
# Apply schema-defined defaults for parameters the caller omitted,
# so "有默认值的参数不填就用默认值"(如 datasourceType、target、分页
properties = schema.get("properties", {})
for key, prop in properties.items():
if "default" not in prop:
continue
if args.get(key) is not None:
continue
default_value = prop["default"]
category = categories.get(key)
if category == "path":
path_params[key] = default_value
elif category == "query":
query_params[key] = default_value
elif category == "body":
body_params[key] = default_value
elif category == "header" and not api_key:
api_key = default_value
# Fall back to the environment-configured API key when the tool call
# doesn't carry an explicit header value.
if not api_key:
api_key = get_api_key() or None
if api_key:
mcp_logger.info("工具调用未带 API Key已回退到环境变量 datasourceApiKey")
client = _get_api_client()
result = client.request(
method=tool_def["method"],
path_template=tool_def["path"],
path_params=path_params,
query_params=query_params,
body=body_params if body_params else None,
file_path=file_path,
api_key=api_key,
)
return _text_response(result)
except Exception as e:
error_msg = f"工具调用失败: {name}, 错误: {e}"
mcp_logger.error(error_msg, exc_info=True)
return _text_response({"error": error_msg})
async def async_main():
"""Async entry for the MCP server."""
try:
mcp_logger.info("=" * 60)
mcp_logger.info("正在启动 MCP 服务: lzwcai-mcp-agile-db-third")
mcp_logger.info("版本: 0.1.0")
mcp_logger.info("=" * 60)
env = get_env_config()
mcp_logger.info(f"环境配置 - Backend Base URL: {env.get('backend_base_url')}")
mcp_logger.info(f"环境配置 - API Key: {'已设置' if env.get('api_key') else '未设置'}")
mcp_logger.info("=" * 60)
from mcp.server.stdio import stdio_server
async with stdio_server() as (read_stream, write_stream):
mcp_logger.info("MCP 服务已启动,等待客户端连接...")
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name="lzwcai-mcp-agile-db-third",
server_version="0.1.2",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
)
mcp_logger.info("MCP 服务已关闭")
except Exception as e:
mcp_logger.error(f"MCP 服务运行失败: {e}", exc_info=True)
raise
def main():
"""Console entrypoint."""
try:
logger_config.setup_logging(
app_name="lzwcai_mcp_agile_db_third",
log_level=logging.INFO,
console_output=False,
)
mcp_logger.info("开始运行 MCP Agile DB Third 服务")
asyncio.run(async_main())
except KeyboardInterrupt:
mcp_logger.info("收到中断信号,正在关闭服务...")
except Exception as e:
mcp_logger.error(f"程序运行失败: {e}", exc_info=True)
raise
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,137 @@
"""Docker-based PostgreSQL mock for local self-testing.
Provides a throwaway Postgres container that the backend can connect to during
external-connection tests. The container is bound to 0.0.0.0:5432 on the Docker
host, so the host IP must be reachable from the backend server.
"""
import socket
import subprocess
import sys
import time
from typing import Optional
DEFAULT_IMAGE = "postgres:16-alpine"
DEFAULT_CONTAINER = "lzwcai_mcp_agile_db_third_mock_pg"
DEFAULT_PORT = 5432
DEFAULT_DATABASE = "postgres"
DEFAULT_USERNAME = "postgres"
DEFAULT_PASSWORD = "postgres"
def _run(args: list, check: bool = False, capture: bool = True) -> subprocess.CompletedProcess:
"""Run a shell command and return the result."""
return subprocess.run(
args,
check=check,
capture_output=capture,
text=True,
shell=False,
)
def host_ip(prefer_prefixes: Optional[list] = None) -> str:
"""Return a non-loopback IPv4 address of this machine.
Prefers addresses starting with the given prefixes so users can steer the
result toward the network segment that the backend can reach.
"""
prefer_prefixes = prefer_prefixes or ["192.168.", "10.", "172."]
hostname = socket.gethostname()
_, _, ips = socket.gethostbyname_ex(hostname)
# Prefer addresses matching a requested prefix, then any non-loopback.
for prefix in prefer_prefixes:
for ip in ips:
if ip.startswith(prefix) and not ip.startswith("127."):
return ip
for ip in ips:
if not ip.startswith("127."):
return ip
raise RuntimeError("Could not find a non-loopback IPv4 address on this host")
def is_running(container: str = DEFAULT_CONTAINER) -> bool:
"""Check whether the mock container is currently running."""
result = _run(["docker", "ps", "--filter", f"name={container}", "--format", "{{.Names}}"])
return container in result.stdout
def start(
container: str = DEFAULT_CONTAINER,
port: int = DEFAULT_PORT,
username: str = DEFAULT_USERNAME,
password: str = DEFAULT_PASSWORD,
database: str = DEFAULT_DATABASE,
image: str = DEFAULT_IMAGE,
) -> str:
"""Start the Postgres mock container if not already running.
Returns the host IP that should be supplied to backend connection tests.
"""
if is_running(container):
print(f"Mock DB container '{container}' is already running.")
return host_ip()
# Remove any stale container with the same name.
_run(["docker", "rm", "-f", container], check=False)
print(f"Starting mock Postgres container '{container}' (image={image})...")
_run(
[
"docker", "run", "-d",
"--name", container,
"-p", f"{port}:{port}",
"-e", f"POSTGRES_USER={username}",
"-e", f"POSTGRES_PASSWORD={password}",
"-e", f"POSTGRES_DB={database}",
image,
],
check=True,
)
print("Waiting for Postgres to become ready...")
deadline = time.time() + 30
while time.time() < deadline:
result = _run(
["docker", "exec", container, "pg_isready", "-U", username],
check=False,
)
if result.returncode == 0:
break
time.sleep(0.5)
else:
stop(container)
raise RuntimeError("Postgres mock failed to become ready within 30s")
ip = host_ip()
print(f"Mock Postgres is ready at {ip}:{port} (user={username}, password={password}, db={database})")
return ip
def stop(container: str = DEFAULT_CONTAINER) -> None:
"""Stop and remove the mock Postgres container."""
print(f"Stopping mock DB container '{container}'...")
_run(["docker", "stop", "-t", "5", container], check=False)
_run(["docker", "rm", "-f", container], check=False)
def main():
if len(sys.argv) < 2:
print("Usage: python -m lzwcai_mcp_agile_db_third.mock_db [start|stop|ip]")
sys.exit(1)
cmd = sys.argv[1].lower()
if cmd == "start":
start()
elif cmd == "stop":
stop()
elif cmd in ("ip", "host"):
print(host_ip())
elif cmd == "status":
print("running" if is_running() else "stopped")
else:
print(f"Unknown command: {cmd}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,41 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "lzwcai-mcp-agile-db-third"
version = "0.1.3"
description = "MCP server for Agile DB third-party datasource APIs"
readme = "README.md"
requires-python = ">=3.12"
license = {text = "MIT"}
authors = [
{name = "lzwcai", email = "your-email@example.com"},
]
keywords = ["mcp", "datasource", "database", "agile-db"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"httpx>=0.28.1",
"mcp[cli]>=1.10.1",
]
[project.scripts]
lzwcai-mcp-agile-db-third = "lzwcai_mcp_agile_db_third.main:main"
[tool.hatch.build.targets.wheel]
packages = ["lzwcai_mcp_agile_db_third"]
[tool.hatch.build.targets.wheel.force-include]
[tool.hatch.build]
exclude = [
"lzwcai_mcp_agile_db_third/logs/**",
"**/.__pycache__/**",
"lzwcai_mcp_agile_db_third/.gitignore",
".venv/**",
]

View File

@@ -0,0 +1,870 @@
"""MCP tool definitions for datasource API."""
from typing import Any, Dict, List
def _tool(
name: str,
description: str,
method: str,
path: str,
properties: Dict[str, Any],
required: List[str],
param_categories: Dict[str, str],
) -> Dict[str, Any]:
"""Build a tool definition dictionary."""
return {
"name": name,
"description": description,
"method": method,
"path": path,
"inputSchema": {
"type": "object",
"properties": properties,
"required": required,
},
"paramCategories": param_categories,
}
# 公共参数 schema 片段
_API_KEY_PROP = {
"apiKey": {
"type": "string",
"description": "可选的 X-Datasource-API-Key 权限密钥",
}
}
_TARGET_PROP = {
"target": {
"type": "string",
"description": "目标环境prod/test默认 prod",
"default": "prod",
}
}
_PAGE_PROPS = {
"pageNum": {"type": "integer", "description": "页码默认1", "default": 1},
"pageSize": {"type": "integer", "description": "每页数量默认10", "default": 10},
}
# =============================================================================
# 1. 数据源配置管理
# =============================================================================
LIST_DATASOURCE_CONFIGS = _tool(
name="list_datasource_configs",
description="查询数据源配置列表,支持分页和多条件筛选",
method="GET",
path="/datasource/config/list",
properties={
"datasourceName": {"type": "string", "description": "数据源名称(模糊查询)"},
"datasourceId": {"type": "integer", "description": "连接ID"},
"datasourceType": {"type": "string", "description": "数据库类型"},
"status": {"type": "integer", "description": "状态"},
"showTable": {"type": "boolean", "description": "是否显示表数量"},
**_PAGE_PROPS,
},
required=[],
param_categories={
"datasourceName": "query",
"datasourceId": "query",
"datasourceType": "query",
"status": "query",
"showTable": "query",
"pageNum": "query",
"pageSize": "query",
},
)
GET_DATASOURCE_CONFIG = _tool(
name="get_datasource_config",
description="获取指定数据源配置的详细信息",
method="GET",
path="/datasource/config/{id}",
properties={
"id": {"type": "integer", "description": "数据源配置ID"},
},
required=["id"],
param_categories={"id": "path"},
)
BATCH_CREATE_DATASOURCE_CONFIGS = _tool(
name="batch_create_datasource_configs",
description="批量创建数据源配置并同步表结构",
method="POST",
path="/datasource/config",
properties={
"connectionId": {"type": "integer", "description": "连接实例ID"},
"datasourceNamePrefix": {"type": "string", "description": "数据源名称前缀"},
"enterpriseId": {"type": "integer", "description": "企业ID"},
"status": {"type": "integer", "description": "状态"},
"remark": {"type": "string", "description": "备注信息"},
"syncTables": {"type": "boolean", "description": "是否同步表结构"},
"databases": {
"type": "array",
"description": "数据库与表列表",
"items": {
"type": "object",
"properties": {
"databaseName": {"type": "string"},
"tableNames": {"type": "array", "items": {"type": "string"}},
},
},
},
},
required=["connectionId", "databases"],
param_categories={
"connectionId": "body",
"datasourceNamePrefix": "body",
"enterpriseId": "body",
"status": "body",
"remark": "body",
"syncTables": "body",
"databases": "body",
},
)
REPLACE_DATASOURCE_CONFIGS = _tool(
name="replace_datasource_configs",
description="全量替换指定连接下的数据源配置(未传入的配置将被删除),请谨慎操作",
method="PUT",
path="/datasource/config",
properties={
"connectionId": {"type": "integer", "description": "连接实例ID"},
"datasourceNamePrefix": {"type": "string", "description": "数据源名称前缀"},
"enterpriseId": {"type": "integer", "description": "企业ID"},
"status": {"type": "integer", "description": "状态"},
"remark": {"type": "string", "description": "备注信息"},
"syncTables": {"type": "boolean", "description": "是否同步表结构"},
"databases": {
"type": "array",
"description": "数据库与表列表",
"items": {
"type": "object",
"properties": {
"databaseName": {"type": "string"},
"tableNames": {"type": "array", "items": {"type": "string"}},
},
},
},
},
required=["connectionId", "databases"],
param_categories={
"connectionId": "body",
"datasourceNamePrefix": "body",
"enterpriseId": "body",
"status": "body",
"remark": "body",
"syncTables": "body",
"databases": "body",
},
)
BATCH_UPDATE_DATASOURCE_CONFIGS = _tool(
name="batch_update_datasource_configs",
description="批量修改数据源配置并重新同步表结构",
method="PUT",
path="/datasource/config/batch",
properties={
"status": {"type": "integer", "description": "状态"},
"remark": {"type": "string", "description": "批量更新备注"},
"syncTables": {"type": "boolean", "description": "是否同步表结构"},
"datasources": {
"type": "array",
"description": "要更新的数据源配置列表",
"items": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"datasourceName": {"type": "string"},
"tableNames": {"type": "array", "items": {"type": "string"}},
},
},
},
},
required=["datasources"],
param_categories={
"status": "body",
"remark": "body",
"syncTables": "body",
"datasources": "body",
},
)
DELETE_DATASOURCE_CONFIGS = _tool(
name="delete_datasource_configs",
description="批量删除数据源配置",
method="POST",
path="/datasource/config/deletes",
properties={
"ids": {
"type": "array",
"description": "要删除的数据源配置ID列表",
"items": {"type": "integer"},
},
},
required=["ids"],
param_categories={"ids": "body"},
)
TEST_CONNECTION_CONFIG = _tool(
name="test_connection_config",
description="测试数据库连接是否正常",
method="POST",
path="/datasource/config/testConnectionConfig",
properties={
"host": {"type": "string", "description": "主机地址"},
"port": {"type": "integer", "description": "端口"},
"username": {"type": "string", "description": "用户名"},
"password": {"type": "string", "description": "密码"},
"datasourceType": {"type": "string", "description": "数据库类型,例如 PostgreSQL默认 PostgreSQL", "default": "PostgreSQL"},
},
required=["host", "port", "username", "password"],
param_categories={
"host": "body",
"port": "body",
"username": "body",
"password": "body",
"datasourceType": "body",
},
)
CHANGE_DATASOURCE_STATUS = _tool(
name="change_datasource_status",
description="修改数据源配置的启用/禁用状态",
method="PUT",
path="/datasource/config/changeStatus",
properties={
"id": {"type": "integer", "description": "数据源配置ID"},
"status": {"type": "integer", "description": "状态0正常/1停用"},
},
required=["id", "status"],
param_categories={"id": "body", "status": "body"},
)
EXPORT_DATASOURCE_CONFIGS = _tool(
name="export_datasource_configs",
description="导出数据源配置列表为Excel文件",
method="POST",
path="/datasource/config/export",
properties={
"datasourceName": {"type": "string", "description": "数据源名称(模糊查询)"},
"datasourceId": {"type": "integer", "description": "连接ID"},
"datasourceType": {"type": "string", "description": "数据库类型"},
"status": {"type": "integer", "description": "状态"},
},
required=[],
param_categories={
"datasourceName": "query",
"datasourceId": "query",
"datasourceType": "query",
"status": "query",
},
)
# =============================================================================
# 2. 数据库连接实例管理
# =============================================================================
LIST_CONNECTIONS = _tool(
name="list_connections",
description="查询数据库连接实例列表,支持分页和筛选",
method="GET",
path="/datasource/connection/list",
properties={
"datasourceName": {"type": "string", "description": "数据源名称(模糊查询)"},
"status": {"type": "integer", "description": "状态"},
"testStatus": {"type": "integer", "description": "测试状态0未测试/1成功/2失败"},
"sourceType": {"type": "string", "description": "连接来源builtin/external"},
**_PAGE_PROPS,
},
required=[],
param_categories={
"datasourceName": "query",
"status": "query",
"testStatus": "query",
"sourceType": "query",
"pageNum": "query",
"pageSize": "query",
},
)
GET_CONNECTION = _tool(
name="get_connection",
description="获取指定连接实例的详细信息",
method="GET",
path="/datasource/connection/{id}",
properties={
"id": {"type": "integer", "description": "连接实例ID"},
},
required=["id"],
param_categories={"id": "path"},
)
CREATE_CONNECTION = _tool(
name="create_connection",
description="创建新的数据库连接实例",
method="POST",
path="/datasource/connection",
properties={
"datasourceName": {"type": "string", "description": "连接名称"},
"datasourceType": {"type": "string", "description": "数据库类型,例如 PostgreSQL默认 PostgreSQL", "default": "PostgreSQL"},
"connectionType": {"type": "string", "description": "连接方式,例如 user_password默认 user_password", "default": "user_password"},
"host": {"type": "string", "description": "主机地址"},
"port": {"type": "integer", "description": "端口"},
"username": {"type": "string", "description": "用户名"},
"password": {"type": "string", "description": "密码"},
"status": {"type": "integer", "description": "状态"},
"remark": {"type": "string", "description": "备注信息"},
},
required=["datasourceName", "host", "port", "username", "password"],
param_categories={
"datasourceName": "body",
"datasourceType": "body",
"connectionType": "body",
"host": "body",
"port": "body",
"username": "body",
"password": "body",
"status": "body",
"remark": "body",
},
)
UPDATE_CONNECTION = _tool(
name="update_connection",
description="修改数据库连接实例信息",
method="PUT",
path="/datasource/connection",
properties={
"id": {"type": "integer", "description": "连接实例ID"},
"datasourceName": {"type": "string", "description": "连接名称"},
"host": {"type": "string", "description": "主机地址"},
"port": {"type": "integer", "description": "端口"},
"username": {"type": "string", "description": "用户名"},
"password": {"type": "string", "description": "密码"},
"remark": {"type": "string", "description": "更新备注"},
},
required=["id"],
param_categories={
"id": "body",
"datasourceName": "body",
"host": "body",
"port": "body",
"username": "body",
"password": "body",
"remark": "body",
},
)
DELETE_CONNECTION = _tool(
name="delete_connection",
description="删除指定的连接实例",
method="DELETE",
path="/datasource/connection/{id}",
properties={
"id": {"type": "integer", "description": "连接实例ID"},
},
required=["id"],
param_categories={"id": "path"},
)
TEST_CONNECTION = _tool(
name="test_connection",
description="测试数据库连接是否可用",
method="POST",
path="/datasource/connection/test",
properties={
"id": {"type": "integer", "description": "连接实例ID"},
"host": {"type": "string", "description": "主机地址"},
"port": {"type": "integer", "description": "端口"},
"username": {"type": "string", "description": "用户名"},
"password": {"type": "string", "description": "密码"},
"datasourceType": {"type": "string", "description": "数据库类型,例如 PostgreSQL默认 PostgreSQL", "default": "PostgreSQL"},
},
required=["host", "port", "username", "password"],
param_categories={
"id": "body",
"host": "body",
"port": "body",
"username": "body",
"password": "body",
"datasourceType": "body",
},
)
CHANGE_CONNECTION_STATUS = _tool(
name="change_connection_status",
description="修改连接实例的启用/禁用状态",
method="PUT",
path="/datasource/connection/changeStatus",
properties={
"id": {"type": "integer", "description": "连接实例ID"},
"status": {"type": "integer", "description": "状态0正常/1停用"},
},
required=["id", "status"],
param_categories={"id": "body", "status": "body"},
)
REALTIME_STRUCTURE = _tool(
name="realtime_structure",
description="实时查询连接下的所有数据库和表结构(直接连接数据库服务器查询)",
method="GET",
path="/datasource/connection/realtime/structure/{id}",
properties={
"id": {"type": "integer", "description": "连接实例ID"},
},
required=["id"],
param_categories={"id": "path"},
)
REALTIME_DATABASES = _tool(
name="realtime_databases",
description="实时查询连接下的所有数据库名称",
method="GET",
path="/datasource/connection/realtime/databases/{id}",
properties={
"id": {"type": "integer", "description": "连接实例ID"},
},
required=["id"],
param_categories={"id": "path"},
)
REALTIME_TABLES = _tool(
name="realtime_tables",
description="实时查询指定数据库下的所有表",
method="GET",
path="/datasource/connection/realtime/tables/{id}",
properties={
"id": {"type": "integer", "description": "连接实例ID"},
"databaseName": {"type": "string", "description": "数据库名称"},
},
required=["id", "databaseName"],
param_categories={"id": "path", "databaseName": "query"},
)
CREATE_BUILTIN_POSTGRESQL = _tool(
name="create_builtin_postgresql",
description="创建内置 PostgreSQL 数据库连接(使用配置文件中的连接信息)",
method="POST",
path="/datasource/connection/create_builtin_postgresql",
properties={
"datasourceName": {"type": "string", "description": "连接名称"},
"remark": {"type": "string", "description": "备注"},
},
required=["datasourceName"],
param_categories={"datasourceName": "body", "remark": "body"},
)
UPDATE_BUILTIN_DATABASE = _tool(
name="update_builtin_database",
description="修改内置 PostgreSQL 连接信息",
method="PUT",
path="/datasource/connection/update_builtin_database",
properties={
"connectionId": {"type": "integer", "description": "连接实例ID"},
"datasourceName": {"type": "string", "description": "新名称"},
"remark": {"type": "string", "description": "更新备注"},
},
required=["connectionId"],
param_categories={"connectionId": "body", "datasourceName": "body", "remark": "body"},
)
EXECUTE_SQL = _tool(
name="execute_sql",
description="在指定数据源上执行 SQL 语句,支持参数化查询和环境切换",
method="POST",
path="/datasource/connection/{datasourceId}/execute_sql",
properties={
"datasourceId": {"type": "integer", "description": "数据源配置ID"},
"target": {
"type": "string",
"description": "目标环境prod/test默认 prod",
"default": "prod",
},
"sql": {"type": "string", "description": "SQL 语句,使用 ? 占位符"},
"params": {
"type": "array",
"description": "SQL 参数列表",
"items": {},
},
"databaseName": {"type": "string", "description": "数据库名称"},
**_API_KEY_PROP,
},
required=["datasourceId", "sql"],
param_categories={
"datasourceId": "path",
"target": "query",
"sql": "body",
"params": "body",
"databaseName": "body",
"apiKey": "header",
},
)
CREATE_DATABASE = _tool(
name="create_database",
description="在指定连接上创建新数据库(目前仅支持 PostgreSQL",
method="POST",
path="/datasource/connection/{connectionId}/create_database",
properties={
"connectionId": {"type": "integer", "description": "连接实例ID"},
"databaseName": {"type": "string", "description": "数据库名称"},
"encoding": {"type": "string", "description": "字符编码,例如 UTF8"},
"owner": {"type": "string", "description": "所有者"},
**_API_KEY_PROP,
},
required=["connectionId", "databaseName"],
param_categories={
"connectionId": "path",
"databaseName": "body",
"encoding": "body",
"owner": "body",
"apiKey": "header",
},
)
CREATE_TABLE = _tool(
name="create_table",
description="在指定数据库中创建新表",
method="POST",
path="/datasource/connection/{connectionId}/create_table",
properties={
"connectionId": {"type": "integer", "description": "连接实例ID"},
"databaseName": {"type": "string", "description": "数据库名称"},
"tableName": {"type": "string", "description": "表名"},
"tableComment": {"type": "string", "description": "表注释"},
"columns": {
"type": "array",
"description": "列定义列表",
"items": {
"type": "object",
"properties": {
"columnName": {"type": "string"},
"columnType": {"type": "string"},
"columnLength": {"type": "integer"},
"isPrimaryKey": {"type": "boolean"},
"isNullable": {"type": "boolean"},
"columnComment": {"type": "string"},
},
},
},
**_API_KEY_PROP,
},
required=["connectionId", "databaseName", "tableName", "columns"],
param_categories={
"connectionId": "path",
"databaseName": "body",
"tableName": "body",
"tableComment": "body",
"columns": "body",
"apiKey": "header",
},
)
CREATE_DATABASE_TABLE = _tool(
name="create_database_table",
description="同时创建数据库和表(一次性操作)",
method="POST",
path="/datasource/connection/{connectionId}/create_database_table",
properties={
"connectionId": {"type": "integer", "description": "连接实例ID"},
"databaseName": {"type": "string", "description": "数据库名称"},
"encoding": {"type": "string", "description": "字符编码"},
"owner": {"type": "string", "description": "所有者"},
"tables": {
"type": "array",
"description": "要创建的表列表",
"items": {
"type": "object",
"properties": {
"tableName": {"type": "string"},
"tableComment": {"type": "string"},
"columns": {"type": "array"},
},
},
},
**_API_KEY_PROP,
},
required=["connectionId", "databaseName", "tables"],
param_categories={
"connectionId": "path",
"databaseName": "body",
"encoding": "body",
"owner": "body",
"tables": "body",
"apiKey": "header",
},
)
ALTER_DATABASE = _tool(
name="alter_database",
description="修改数据库属性(重命名、更改所有者、更改编码)",
method="PUT",
path="/datasource/connection/{connectionId}/alter_database",
properties={
"connectionId": {"type": "integer", "description": "连接实例ID"},
"databaseName": {"type": "string", "description": "原数据库名称"},
"newName": {"type": "string", "description": "新数据库名称"},
"newOwner": {"type": "string", "description": "新所有者"},
"newEncoding": {"type": "string", "description": "新字符编码"},
**_API_KEY_PROP,
},
required=["connectionId", "databaseName"],
param_categories={
"connectionId": "path",
"databaseName": "body",
"newName": "body",
"newOwner": "body",
"newEncoding": "body",
"apiKey": "header",
},
)
ALTER_TABLE = _tool(
name="alter_table",
description="修改表结构(添加列、删除列、重命名列、修改列类型等)",
method="PUT",
path="/datasource/connection/{connectionId}/alter_table",
properties={
"connectionId": {"type": "integer", "description": "连接实例ID"},
"databaseName": {"type": "string", "description": "数据库名称"},
"tableName": {"type": "string", "description": "表名"},
"tableComment": {"type": "string", "description": "表注释"},
"operations": {
"type": "array",
"description": "操作列表",
"items": {
"type": "object",
"properties": {
"operation": {"type": "string"},
"column": {"type": "object"},
},
},
},
**_API_KEY_PROP,
},
required=["connectionId", "databaseName", "tableName", "operations"],
param_categories={
"connectionId": "path",
"databaseName": "body",
"tableName": "body",
"tableComment": "body",
"operations": "body",
"apiKey": "header",
},
)
GENERATE_TABLE = _tool(
name="generate_table",
description="使用 AI 根据需求描述生成表结构(异步任务)",
method="POST",
path="/datasource/connection/generate_table",
properties={
"requirement": {"type": "string", "description": "需求描述"},
"databaseId": {"type": "integer", "description": "关联的数据库ID"},
},
required=["requirement"],
param_categories={"requirement": "body", "databaseId": "body"},
)
IMPORT_DOCUMENT_PREVIEW = _tool(
name="import_document_preview",
description="上传 Excel/CSV 文件AI 识别表结构并预览前 10 条数据",
method="POST",
path="/datasource/connection/{connectionId}/import_document/preview",
properties={
"connectionId": {"type": "integer", "description": "连接实例ID"},
"target": {
"type": "string",
"description": "目标环境prod/test默认 prod",
"default": "prod",
},
"filePath": {"type": "string", "description": "本地 Excel/CSV 文件路径"},
},
required=["connectionId", "filePath"],
param_categories={
"connectionId": "path",
"target": "query",
"filePath": "file",
},
)
IMPORT_DOCUMENT_CONFIRM = _tool(
name="import_document_confirm",
description="确认导入,创建表并插入数据",
method="POST",
path="/datasource/connection/{connectionId}/import_document/confirm",
properties={
"connectionId": {"type": "integer", "description": "连接实例ID"},
"target": {
"type": "string",
"description": "目标环境prod/test默认 prod",
"default": "prod",
},
"tableStructure": {
"type": "object",
"description": "表结构定义",
},
"allData": {
"type": "array",
"description": "完整导入数据",
},
**_API_KEY_PROP,
},
required=["connectionId", "tableStructure", "allData"],
param_categories={
"connectionId": "path",
"target": "query",
"tableStructure": "body",
"allData": "body",
"apiKey": "header",
},
)
BUILTIN_TABLE_DATA = _tool(
name="builtin_table_data",
description="根据表ID查询表结构和数据分页",
method="GET",
path="/datasource/connection/builtin/table/{tableId}",
properties={
"tableId": {"type": "integer", "description": "表ID"},
"pageNum": {"type": "integer", "description": "页码默认1"},
"pageSize": {"type": "integer", "description": "每页数量默认100"},
"target": {
"type": "string",
"description": "目标环境prod/test默认 prod",
"default": "prod",
},
},
required=["tableId"],
param_categories={
"tableId": "path",
"pageNum": "query",
"pageSize": "query",
"target": "query",
},
)
BUILTIN_TABLE_INSERT = _tool(
name="builtin_table_insert",
description="向指定表插入一条数据",
method="POST",
path="/datasource/connection/builtin/table/{tableId}/rows",
properties={
"tableId": {"type": "integer", "description": "表ID"},
"target": {
"type": "string",
"description": "目标环境prod/test默认 prod",
"default": "prod",
},
"data": {"type": "object", "description": "要插入的数据"},
},
required=["tableId", "data"],
param_categories={
"tableId": "path",
"target": "query",
"data": "body",
},
)
BUILTIN_TABLE_UPDATE = _tool(
name="builtin_table_update",
description="根据主键更新表数据",
method="PUT",
path="/datasource/connection/builtin/table/{tableId}/rows",
properties={
"tableId": {"type": "integer", "description": "表ID"},
"target": {
"type": "string",
"description": "目标环境prod/test默认 prod",
"default": "prod",
},
"data": {"type": "object", "description": "要更新的数据"},
"primaryKey": {"type": "object", "description": "主键条件"},
},
required=["tableId", "data", "primaryKey"],
param_categories={
"tableId": "path",
"target": "query",
"data": "body",
"primaryKey": "body",
},
)
BUILTIN_TABLE_DELETE = _tool(
name="builtin_table_delete",
description="根据主键批量删除表数据",
method="DELETE",
path="/datasource/connection/builtin/table/{tableId}/rows",
properties={
"tableId": {"type": "integer", "description": "表ID"},
"target": {
"type": "string",
"description": "目标环境prod/test默认 prod",
"default": "prod",
},
"primaryKeys": {
"type": "array",
"description": "主键条件列表",
"items": {"type": "object"},
},
},
required=["tableId", "primaryKeys"],
param_categories={
"tableId": "path",
"target": "query",
"primaryKeys": "body",
},
)
ALL_TOOLS = [
LIST_DATASOURCE_CONFIGS,
GET_DATASOURCE_CONFIG,
BATCH_CREATE_DATASOURCE_CONFIGS,
REPLACE_DATASOURCE_CONFIGS,
BATCH_UPDATE_DATASOURCE_CONFIGS,
DELETE_DATASOURCE_CONFIGS,
TEST_CONNECTION_CONFIG,
CHANGE_DATASOURCE_STATUS,
EXPORT_DATASOURCE_CONFIGS,
LIST_CONNECTIONS,
GET_CONNECTION,
CREATE_CONNECTION,
UPDATE_CONNECTION,
DELETE_CONNECTION,
TEST_CONNECTION,
CHANGE_CONNECTION_STATUS,
REALTIME_STRUCTURE,
REALTIME_DATABASES,
REALTIME_TABLES,
CREATE_BUILTIN_POSTGRESQL,
UPDATE_BUILTIN_DATABASE,
EXECUTE_SQL,
CREATE_DATABASE,
CREATE_TABLE,
CREATE_DATABASE_TABLE,
ALTER_DATABASE,
ALTER_TABLE,
GENERATE_TABLE,
IMPORT_DOCUMENT_PREVIEW,
IMPORT_DOCUMENT_CONFIRM,
BUILTIN_TABLE_DATA,
BUILTIN_TABLE_INSERT,
BUILTIN_TABLE_UPDATE,
BUILTIN_TABLE_DELETE,
]
TOOL_MAP = {tool["name"]: tool for tool in ALL_TOOLS}
def list_tools() -> List[Dict[str, Any]]:
"""Return all tool definitions."""
return ALL_TOOLS
def get_tool(name: str) -> Dict[str, Any]:
"""Return a tool definition by name."""
return TOOL_MAP.get(name)

View File

@@ -0,0 +1,14 @@
"""Utils package for lzwcai_mcp_agile_db_third."""
from .env_config import get_backend_base_url, get_api_key, get_env_config
from .api_client import DataSourceAPIClient, api_request
from .logger_config import logger_config
__all__ = [
"get_backend_base_url",
"get_api_key",
"get_env_config",
"DataSourceAPIClient",
"api_request",
"logger_config",
]

View File

@@ -0,0 +1,223 @@
"""Backend API client for datasource APIs."""
import base64
import logging
import mimetypes
import os
from typing import Any, Dict, Optional
from urllib.parse import urlencode
import httpx
try:
from .env_config import get_backend_base_url
except ImportError:
from env_config import get_backend_base_url
logger = logging.getLogger(__name__)
DEFAULT_TOKEN = (
""
)
_SENSITIVE_FIELDS = {"password", "apiKey", "token", "secret"}
def _mask_secret(value: Optional[str]) -> str:
"""Mask a secret for logging, keeping only a few leading/trailing chars."""
if not value:
return "<空>"
if len(value) <= 8:
return "***"
return f"{value[:4]}...{value[-4:]} (len={len(value)})"
class DataSourceAPIClient:
"""HTTP client for backend datasource APIs."""
def __init__(
self,
base_url: Optional[str] = None,
token: Optional[str] = None,
):
self.base_url = (base_url or get_backend_base_url()).rstrip("/")
# Prefer explicit token, then API_KEY env var, then built-in default.
resolved = token or os.environ.get("API_KEY") or DEFAULT_TOKEN
self.token = resolved.removeprefix("Bearer ").strip()
self.client = httpx.Client(timeout=120.0)
def close(self) -> None:
"""Close the underlying HTTP client."""
self.client.close()
def _get_headers(self, api_key: Optional[str] = None) -> Dict[str, str]:
headers = {
"Authorization": f"Bearer {self.token}",
"Accept": "application/json",
}
if api_key:
headers["X-Datasource-API-Key"] = api_key
return headers
@staticmethod
def _sanitize_value(value: Any) -> Any:
"""Redact sensitive nested values for logging."""
if isinstance(value, dict):
return {
k: "***" if k in _SENSITIVE_FIELDS else DataSourceAPIClient._sanitize_value(v)
for k, v in value.items()
}
if isinstance(value, list):
return [DataSourceAPIClient._sanitize_value(item) for item in value]
return value
@staticmethod
def _file_tuple(file_path: str) -> tuple:
"""Build a multipart file tuple with filename and content type."""
filename = os.path.basename(file_path)
content_type, _ = mimetypes.guess_type(filename)
if content_type is None:
content_type = "application/octet-stream"
return (filename, open(file_path, "rb"), content_type)
def request(
self,
method: str,
path_template: str,
path_params: Optional[Dict[str, Any]] = None,
query_params: Optional[Dict[str, Any]] = None,
body: Optional[Dict[str, Any]] = None,
file_path: Optional[str] = None,
api_key: Optional[str] = None,
) -> Dict[str, Any]:
"""Send a request and return JSON response."""
path_params = path_params or {}
query_params = query_params or {}
try:
url = self._build_url(path_template, path_params, query_params)
headers = self._get_headers(api_key)
safe_body = self._sanitize_value(body) if body else body
logger.info(f"调用后端 API: {method} {url}")
logger.info(
f"请求密钥 - X-Datasource-API-Key: {_mask_secret(api_key)}, "
f"Authorization token: {_mask_secret(self.token)}"
)
logger.debug(
f"path_params={path_params}, query_params={query_params}, "
f"body={safe_body}, file_path={file_path}"
)
method = method.upper()
if method == "GET":
response = self.client.get(url, headers=headers)
elif method == "DELETE":
if body is not None:
headers["Content-Type"] = "application/json"
response = self.client.request(method, url, headers=headers, json=body)
else:
response = self.client.delete(url, headers=headers)
elif file_path:
# Multipart upload
headers.pop("Content-Type", None)
file_tuple = self._file_tuple(file_path)
files = {"file": file_tuple}
try:
response = self.client.request(
method, url, headers=headers, data=body, files=files
)
finally:
file_tuple[1].close()
elif body is not None:
headers["Content-Type"] = "application/json"
response = self.client.request(method, url, headers=headers, json=body)
else:
response = self.client.request(method, url, headers=headers)
response.raise_for_status()
content_type = response.headers.get("content-type", "")
if "application/json" in content_type:
data = response.json()
else:
# Non-JSON response (e.g., Excel export), encode as base64
data = {
"success": True,
"contentType": content_type,
"fileBase64": base64.b64encode(response.content).decode("ascii"),
"message": "后端返回非 JSON 内容,已使用 base64 编码",
}
logger.info(f"后端 API 调用成功: {method} {url}")
logger.debug(f"响应: {data}")
return data
except httpx.TimeoutException as e:
error_msg = f"API 请求超时: {method} {path_template}"
logger.error(error_msg)
raise Exception(error_msg) from e
except httpx.HTTPStatusError as e:
error_msg = f"API 请求失败 (HTTP {e.response.status_code}): {e.response.text}"
logger.error(error_msg)
raise Exception(error_msg) from e
except httpx.RequestError as e:
error_msg = f"API 请求异常: {method} {path_template}, 错误: {e}"
logger.error(error_msg)
raise Exception(error_msg) from e
except Exception as e:
error_msg = f"处理 API 响应时出错: {e}"
logger.error(error_msg, exc_info=True)
raise Exception(error_msg) from e
def _build_url(
self,
path_template: str,
path_params: Dict[str, Any],
query_params: Dict[str, Any],
) -> str:
path = path_template
for key, value in path_params.items():
path = path.replace(f"{{{key}}}", str(value))
url = f"{self.base_url}{path}"
if query_params:
filtered = {k: v for k, v in query_params.items() if v is not None}
if filtered:
url = f"{url}?{urlencode(filtered)}"
return url
def api_request(
method: str,
path_template: str,
path_params: Optional[Dict[str, Any]] = None,
query_params: Optional[Dict[str, Any]] = None,
body: Optional[Dict[str, Any]] = None,
file_path: Optional[str] = None,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
token: Optional[str] = None,
) -> Dict[str, Any]:
"""Convenience wrapper for one-off API requests."""
client = DataSourceAPIClient(base_url=base_url, token=token)
try:
return client.request(
method=method,
path_template=path_template,
path_params=path_params,
query_params=query_params,
body=body,
file_path=file_path,
api_key=api_key,
)
finally:
client.close()
# Module-level default client
_default_client = DataSourceAPIClient()
def get_default_client() -> DataSourceAPIClient:
return _default_client

View File

@@ -0,0 +1,31 @@
"""环境变量配置模块"""
import os
def get_backend_base_url(default: str = "http://lzwcai-demp-corp-manager:8086") -> str:
"""
获取后端 API 基础 URL。
Environment Variables:
backendBaseUrl: 后端 API 基础 URL
"""
return os.environ.get("backendBaseUrl", default)
def get_api_key(default: str = "") -> str:
"""
获取默认 API 密钥X-Datasource-API-Key
Environment Variables:
datasourceApiKey: 默认 API 密钥
"""
return os.environ.get("datasourceApiKey", default)
def get_env_config() -> dict:
"""获取所有环境配置。"""
return {
"backend_base_url": get_backend_base_url(),
"api_key": get_api_key(),
}

View File

@@ -0,0 +1,85 @@
"""统一日志配置模块"""
import logging
import os
import sys
from pathlib import Path
class LoggerConfig:
"""日志配置管理类"""
def __init__(self, logs_dir: str = None):
if logs_dir:
self.logs_dir = Path(logs_dir)
else:
project_root = Path(__file__).parent.parent
self.logs_dir = project_root / "logs"
self.logs_dir.mkdir(parents=True, exist_ok=True)
self.log_format = "%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s"
self.date_format = "%Y-%m-%d %H:%M:%S"
self._initialized = False
def setup_logging(
self,
app_name: str = "lzwcai_mcp_agile_db_third",
log_level: int = logging.INFO,
console_output: bool = False,
) -> logging.Logger:
"""设置系统日志配置。"""
if self._initialized:
return logging.getLogger()
root_logger = logging.getLogger()
root_logger.setLevel(log_level)
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
formatter = logging.Formatter(self.log_format, self.date_format)
# 主日志文件
main_log_file = self.logs_dir / f"{app_name}.log"
file_handler = logging.FileHandler(main_log_file, encoding="utf-8")
file_handler.setLevel(log_level)
file_handler.setFormatter(formatter)
root_logger.addHandler(file_handler)
# 错误日志文件
error_log_file = self.logs_dir / f"{app_name}_error.log"
error_handler = logging.FileHandler(error_log_file, encoding="utf-8")
error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(formatter)
root_logger.addHandler(error_handler)
# MCP 使用 stdio 时,控制台日志必须输出到 stderr
if console_output:
console_handler = logging.StreamHandler(sys.stderr)
console_handler.setLevel(log_level)
console_handler.setFormatter(formatter)
root_logger.addHandler(console_handler)
self._initialized = True
root_logger.info(f"日志系统初始化完成 - 日志目录: {self.logs_dir}")
return root_logger
def setup_mcp_logging(self) -> logging.Logger:
"""设置 MCP 专用日志。"""
return self.create_component_logger("mcp_services", "mcp_services.log", logging.DEBUG)
def create_component_logger(
self, component_name: str, log_file: str = None, log_level: int = None
) -> logging.Logger:
"""为特定组件创建独立日志器。"""
logger = logging.getLogger(component_name)
if log_file:
component_log_file = self.logs_dir / log_file
handler = logging.FileHandler(component_log_file, encoding="utf-8")
handler.setFormatter(logging.Formatter(self.log_format, self.date_format))
if log_level is not None:
handler.setLevel(log_level)
logger.addHandler(handler)
return logger
logger_config = LoggerConfig()

View File

@@ -0,0 +1,17 @@
"""
Repository-local launcher for lzwcai-mcp-sqlexecutor.
"""
import os
def main():
# Keep local developer defaults without overriding explicit environment settings.
os.environ.setdefault("backendBaseUrl", "http://192.168.2.236:8088")
os.environ.setdefault("datasourceApiKey", "yBOHyhCSpExAoEleimSfhbRzsF6SDiPYdGGwowXG-Sk")
from lzwcai_mcp_agile_db_third.main import main
main()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,41 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "lzwcai-mcp-agile-db-third"
version = "0.1.5"
description = "MCP server for Agile DB third-party datasource APIs"
readme = "README.md"
requires-python = ">=3.12"
license = {text = "MIT"}
authors = [
{name = "lzwcai", email = "your-email@example.com"},
]
keywords = ["mcp", "datasource", "database", "agile-db"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"httpx>=0.28.1",
"mcp[cli]>=1.10.1",
]
[project.scripts]
lzwcai-mcp-agile-db-third = "lzwcai_mcp_agile_db_third.main:main"
[tool.hatch.build.targets.wheel]
packages = ["lzwcai_mcp_agile_db_third"]
[tool.hatch.build.targets.wheel.force-include]
[tool.hatch.build]
exclude = [
"lzwcai_mcp_agile_db_third/logs/**",
"**/.__pycache__/**",
"lzwcai_mcp_agile_db_third/.gitignore",
".venv/**",
]

View File

@@ -0,0 +1,418 @@
"""End-to-end self-test for all lzwcai_mcp_agile_db_third MCP tools.
Runs every tool through the real MCP handler (handle_call_tool) against the
backend configured below. Uses selftest_-prefixed throwaway resources and
cleans them up at the end. The destructive full-replace tool is skipped.
"""
import asyncio
import csv
import json
import os
import sys
import tempfile
import uuid
os.environ.setdefault("backendBaseUrl", "http://192.168.2.236:8088")
# Login token (Authorization Bearer) shared with the first-party AgileDB MCP server.
os.environ.setdefault(
"API_KEY",
"Bearer eyJhbGciOiJIUzUxMiJ9.eyJ0b2tlbl90eXBlIjoiTE9HSU4iLCJsb2dpbl91c2VyX2tleSI6"
"ImNlMDAwYjA4LWU0YTYtNGM2MS1hNzJiLWI3NTlmNmY1N2Q4NCJ9.jiNmGQZfL4-nSIFrLuaCt7mT"
"5zj0FOojAVkLeHwPOroI5jBxodrCe1PSwGO1OHq5Ztb0tLEVZw2FFVj0OlTceQ",
)
# Optional X-Datasource-API-Key for datasource-level permission checks (if enforced).
os.environ.setdefault("datasourceApiKey", "Mggkz34Yk8cbjUvCvQ-qeooNRg62WhSwwtxUUV6e0Pg")
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from lzwcai_mcp_agile_db_third.main import handle_call_tool # noqa: E402
from lzwcai_mcp_agile_db_third.mock_db import start as start_mock_db, stop as stop_mock_db # noqa: E402
from lzwcai_mcp_agile_db_third.tools import ALL_TOOLS # noqa: E402
RESULTS = [] # (name, status, detail)
TESTED = set()
async def call(name, args=None):
"""Invoke a tool through the MCP handler and return parsed JSON."""
TESTED.add(name)
try:
resp = await handle_call_tool(name, args or {})
return json.loads(resp[0].text)
except Exception as e: # noqa: BLE001
return {"_exception": repr(e)}
def summarize(data):
"""Extract a short (ok, detail) from a backend response."""
if not isinstance(data, dict):
return False, str(data)[:200]
if "_exception" in data:
return False, data["_exception"][:200]
if "error" in data:
return False, str(data["error"])[:200]
code = data.get("code")
msg = data.get("msg", "")
if code == 200:
return True, f"code=200 msg={msg}"
if code is not None:
return False, f"code={code} msg={msg}"
# list/paginated or already-unwrapped payloads
return True, json.dumps(data, ensure_ascii=False)[:160]
def record(name, data, mode="ok"):
"""Record a tool result.
mode="ok" -> PASS only when backend code==200
mode="roundtrip" -> PASS when the call round-trips (no exception and the
backend returned a structured response), regardless of
business code. Used for ops whose success depends on a
real reachable DB we can't guarantee in self-test.
"""
ok, detail = summarize(data)
if mode == "roundtrip":
exception = isinstance(data, dict) and "_exception" in data
passed = not exception
else:
passed = ok
status = "PASS" if passed else "FAIL"
RESULTS.append((name, status, detail))
print(f"[{status}] {name}: {detail}")
return data
def dig(data, *keys, default=None):
"""Safely walk nested dict keys."""
cur = data
for k in keys:
if not isinstance(cur, dict):
return default
cur = cur.get(k)
return cur if cur is not None else default
async def main():
PREFIX = f"selftest_{uuid.uuid4().hex[:8]}_"
state = {}
# ---- 1. read-only: connections & configs ----------------------------
print("\n=== 只读查询 ===")
conns = record("list_connections", await call("list_connections", {"pageSize": 5}))
# pick a builtin connection to exercise builtin/realtime tools
rows = conns.get("rows") or dig(conns, "data", "rows", default=[]) or []
builtin_id = None
any_conn_id = None
for r in rows:
cid = r.get("id")
if any_conn_id is None:
any_conn_id = cid
if r.get("sourceType") == "builtin" and builtin_id is None:
builtin_id = cid
state["builtin_id"] = builtin_id
state["any_conn_id"] = any_conn_id
print(f" -> builtin_id={builtin_id}, any_conn_id={any_conn_id}")
record("list_datasource_configs", await call("list_datasource_configs", {"pageSize": 5}))
if any_conn_id:
record("get_connection", await call("get_connection", {"id": any_conn_id}))
record("realtime_databases", await call("realtime_databases", {"id": any_conn_id}))
record("realtime_structure", await call("realtime_structure", {"id": any_conn_id}))
else:
for n in ("get_connection", "realtime_databases", "realtime_structure"):
RESULTS.append((n, "SKIP", "no connection available"))
# ---- 2. create a builtin PostgreSQL connection ----------------------
print("\n=== 创建内置连接 ===")
created = record(
"create_builtin_postgresql",
await call("create_builtin_postgresql", {
"datasourceName": PREFIX + "conn",
"remark": "self-test connection",
}),
)
conn_id = dig(created, "data", "id")
if conn_id is None and builtin_id:
conn_id = builtin_id # fall back to an existing builtin for downstream tools
state["conn_id"] = conn_id
print(f" -> conn_id={conn_id}")
if conn_id:
record("update_builtin_database", await call("update_builtin_database", {
"connectionId": conn_id,
"datasourceName": PREFIX + "conn_renamed",
"remark": "renamed by self-test",
}))
record("get_connection", await call("get_connection", {"id": conn_id}))
# ---- 3. external connection: test/create/update/status/delete -------
print("\n=== 外部连接测试(依赖真实可达的库,可能失败属正常)===")
# Try to spin up a local Docker Postgres mock. If Docker is unavailable or
# the backend cannot reach the host IP, fall back to 127.0.0.1 and keep the
# round-trip assertion (we still verify the tool round-trips).
mock_host = "127.0.0.1"
try:
mock_host = start_mock_db()
print(f" -> mock DB available at {mock_host}:5432")
except Exception as e: # noqa: BLE001
print(f" -> could not start mock DB, using 127.0.0.1:5432 ({e})")
# test_connection_config / test_connection rely on datasourceType default now
record(
"test_connection_config",
await call("test_connection_config", {
"host": mock_host, "port": 5432,
"username": "postgres", "password": "postgres",
}),
mode="roundtrip", # may still fail if backend cannot reach mock_host
)
# create_connection (external) — exercises datasourceType/connectionType defaults
ext = record(
"create_connection",
await call("create_connection", {
"datasourceName": PREFIX + "ext",
"host": mock_host, "port": 5432,
"username": "postgres", "password": "postgres",
"remark": "self-test external",
}),
mode="roundtrip",
)
ext_id = dig(ext, "data", "id")
state["ext_id"] = ext_id
print(f" -> ext_id={ext_id}")
if ext_id:
record("update_connection", await call("update_connection", {
"id": ext_id, "datasourceName": PREFIX + "ext_renamed",
"remark": "renamed",
}), mode="roundtrip")
record("test_connection", await call("test_connection", {
"id": ext_id, "host": mock_host, "port": 5432,
"username": "postgres", "password": "postgres",
}), mode="roundtrip")
record("change_connection_status", await call("change_connection_status", {
"id": ext_id, "status": 1,
}), mode="roundtrip")
else:
for n in ("update_connection", "test_connection", "change_connection_status"):
RESULTS.append((n, "SKIP", "no external connection id returned"))
# ---- 4. DDL on the builtin connection ------------------------------
print("\n=== DDL库/表 ===")
ddl_conn = conn_id or builtin_id
db_name = PREFIX + "db"
tbl_name = PREFIX + "users"
cols = [
{"columnName": "id", "columnType": "BIGINT", "isPrimaryKey": True,
"isNullable": False, "columnComment": "主键"},
{"columnName": "name", "columnType": "VARCHAR", "columnLength": 100,
"isNullable": False, "columnComment": "姓名"},
{"columnName": "age", "columnType": "INTEGER", "isNullable": True,
"columnComment": "年龄"},
]
if ddl_conn:
record("create_database", await call("create_database", {
"connectionId": ddl_conn, "databaseName": db_name,
"encoding": "UTF8",
}), mode="roundtrip")
record("create_table", await call("create_table", {
"connectionId": ddl_conn, "databaseName": db_name,
"tableName": tbl_name, "tableComment": "self-test 用户表",
"columns": cols,
}), mode="roundtrip")
record("realtime_tables", await call("realtime_tables", {
"id": ddl_conn, "databaseName": db_name,
}), mode="roundtrip")
record("create_database_table", await call("create_database_table", {
"connectionId": ddl_conn, "databaseName": PREFIX + "db2",
"encoding": "UTF8",
"tables": [{"tableName": PREFIX + "t2", "tableComment": "二号表",
"columns": cols}],
}), mode="roundtrip")
record("alter_table", await call("alter_table", {
"connectionId": ddl_conn, "databaseName": db_name,
"tableName": tbl_name,
"operations": [{"operation": "ADD_COLUMN", "column": {
"columnName": "email", "columnType": "VARCHAR",
"columnLength": 255, "isNullable": True, "columnComment": "邮箱"}}],
}), mode="roundtrip")
record("alter_database", await call("alter_database", {
"connectionId": ddl_conn, "databaseName": PREFIX + "db2",
"newName": PREFIX + "db2_renamed",
}), mode="roundtrip")
else:
for n in ("create_database", "create_table", "create_database_table",
"alter_table", "alter_database"):
RESULTS.append((n, "SKIP", "no builtin connection for DDL"))
# ---- 5. find the table id, exercise execute_sql + builtin CRUD ------
print("\n=== SQL 执行 + 内置表数据 CRUD ===")
# locate a datasource config id + table id for our table
cfgs = await call("list_datasource_configs", {"datasourceName": PREFIX, "pageSize": 20})
cfg_rows = cfgs.get("rows") or dig(cfgs, "data", "rows", default=[]) or []
datasource_id = cfg_rows[0].get("id") if cfg_rows else None
state["datasource_id"] = datasource_id
table_id = None
if ddl_conn:
detail = await call("get_connection", {"id": ddl_conn})
for ds in dig(detail, "data", "datasourceConfig", default=[]) or []:
for t in ds.get("tables", []) or []:
if str(t.get("tableName", "")).startswith(PREFIX):
table_id = t.get("tableId") or t.get("id")
break
if table_id:
break
state["table_id"] = table_id
print(f" -> datasource_id={datasource_id}, table_id={table_id}")
if datasource_id:
record("execute_sql", await call("execute_sql", {
"datasourceId": datasource_id,
"sql": "SELECT 1",
"databaseName": db_name,
}), mode="roundtrip")
else:
RESULTS.append(("execute_sql", "SKIP", "no datasource config id"))
if table_id:
record("builtin_table_insert", await call("builtin_table_insert", {
"tableId": table_id, "data": {"id": 1, "name": "张三", "age": 25},
}), mode="roundtrip")
record("builtin_table_data", await call("builtin_table_data", {
"tableId": table_id,
}), mode="roundtrip")
record("builtin_table_update", await call("builtin_table_update", {
"tableId": table_id, "data": {"name": "李四", "age": 30},
"primaryKey": {"id": 1},
}), mode="roundtrip")
record("builtin_table_delete", await call("builtin_table_delete", {
"tableId": table_id, "primaryKeys": [{"id": 1}],
}), mode="roundtrip")
else:
for n in ("builtin_table_insert", "builtin_table_data",
"builtin_table_update", "builtin_table_delete"):
RESULTS.append((n, "SKIP", "no table id found"))
# ---- 6. AI generate + document import (preview/confirm) -------------
print("\n=== AI 生成 + 文档导入 ===")
record("generate_table", await call("generate_table", {
"requirement": "一个简单的待办事项表,含标题、状态、创建时间",
"databaseId": datasource_id,
}), mode="roundtrip")
# build a tiny CSV for import preview
csv_path = os.path.join(tempfile.gettempdir(), "selftest_import.csv")
with open(csv_path, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["name", "age", "gender"])
w.writerow(["张三", "25", "male"])
w.writerow(["李四", "30", "female"])
preview = None
if ddl_conn:
preview = record("import_document_preview", await call("import_document_preview", {
"connectionId": ddl_conn, "filePath": csv_path,
}), mode="roundtrip")
ts = dig(preview, "data", "tableStructure")
all_data = dig(preview, "data", "allData")
if ts and all_data:
record("import_document_confirm", await call("import_document_confirm", {
"connectionId": ddl_conn, "tableStructure": ts, "allData": all_data,
}), mode="roundtrip")
else:
RESULTS.append(("import_document_confirm", "SKIP",
"preview returned no tableStructure/allData"))
else:
for n in ("import_document_preview", "import_document_confirm"):
RESULTS.append((n, "SKIP", "no builtin connection"))
# ---- 7. datasource config batch ops --------------------------------
print("\n=== 数据源配置批量操作 ===")
if ddl_conn:
record("batch_create_datasource_configs", await call(
"batch_create_datasource_configs", {
"connectionId": ddl_conn,
"datasourceNamePrefix": PREFIX + "cfg",
"syncTables": False,
"databases": [{"databaseName": db_name, "tableNames": []}],
}), mode="roundtrip")
else:
RESULTS.append(("batch_create_datasource_configs", "SKIP", "no connection"))
if datasource_id:
record("get_datasource_config", await call("get_datasource_config", {
"id": datasource_id}), mode="roundtrip")
record("change_datasource_status", await call("change_datasource_status", {
"id": datasource_id, "status": 1}), mode="roundtrip")
record("batch_update_datasource_configs", await call(
"batch_update_datasource_configs", {
"syncTables": False,
"datasources": [{"id": datasource_id, "tableNames": []}],
}), mode="roundtrip")
else:
for n in ("get_datasource_config", "change_datasource_status",
"batch_update_datasource_configs"):
RESULTS.append((n, "SKIP", "no datasource config id"))
record("export_datasource_configs", await call("export_datasource_configs", {
"datasourceName": PREFIX}), mode="roundtrip")
# replace_datasource_configs intentionally skipped (destructive)
RESULTS.append(("replace_datasource_configs", "SKIP",
"destructive full-replace, skipped by design"))
# ---- 8. cleanup -----------------------------------------------------
print("\n=== 清理 selftest_ 资源 ===")
# delete selftest datasource configs
cfgs2 = await call("list_datasource_configs", {"datasourceName": PREFIX, "pageSize": 50})
cfg_rows2 = cfgs2.get("rows") or dig(cfgs2, "data", "rows", default=[]) or []
del_ids = [r.get("id") for r in cfg_rows2 if r.get("id") is not None]
if del_ids:
record("delete_datasource_configs", await call("delete_datasource_configs", {
"ids": del_ids}), mode="roundtrip")
else:
RESULTS.append(("delete_datasource_configs", "SKIP", "nothing to delete"))
# delete selftest connections
deleted_conn = False
for cid in (state.get("ext_id"), conn_id):
if cid:
record("delete_connection", await call("delete_connection", {"id": cid}),
mode="roundtrip")
deleted_conn = True
if not deleted_conn:
RESULTS.append(("delete_connection", "SKIP", "no selftest connection to delete"))
try:
os.remove(csv_path)
except OSError:
pass
try:
stop_mock_db()
except Exception as e: # noqa: BLE001
print(f" -> failed to stop mock DB: {e}")
# ---- summary --------------------------------------------------------
print("\n" + "=" * 60)
print("自测结果汇总")
print("=" * 60)
all_names = {t["name"] for t in ALL_TOOLS}
counts = {"PASS": 0, "FAIL": 0, "SKIP": 0}
for name, status, detail in RESULTS:
counts[status] = counts.get(status, 0) + 1
for name, status, detail in RESULTS:
print(f" [{status}] {name}: {detail}")
untested = all_names - TESTED
print("-" * 60)
print(f"工具总数: {len(all_names)} 覆盖: {len(TESTED)} 未触达: {sorted(untested)}")
print(f"PASS={counts['PASS']} FAIL={counts['FAIL']} SKIP={counts['SKIP']}")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,4 @@
[[index]]
name = "pypi"
url = "https://pypi.org/simple/"
default = true

View File

@@ -94,3 +94,11 @@
2026-03-18 18:16:52 - lzwcai_mcp_api_converter.src.util.logger_config - INFO - [logger_config.py:220] - 文件输出: True 2026-03-18 18:16:52 - lzwcai_mcp_api_converter.src.util.logger_config - INFO - [logger_config.py:220] - 文件输出: True
2026-03-18 18:16:52 - lzwcai_mcp_api_converter.src.util.logger_config - INFO - [logger_config.py:221] - 文件轮转: 最大10MB, 保留5个备份 2026-03-18 18:16:52 - lzwcai_mcp_api_converter.src.util.logger_config - INFO - [logger_config.py:221] - 文件轮转: 最大10MB, 保留5个备份
2026-03-18 18:16:52 - lzwcai_mcp_api_converter.src.util.logger_config - INFO - [logger_config.py:222] - ================================================================================ 2026-03-18 18:16:52 - lzwcai_mcp_api_converter.src.util.logger_config - INFO - [logger_config.py:222] - ================================================================================
2026-06-16 10:55:20 - lzwcai_mcp_api_converter.src.util.logger_config - INFO - [logger_config.py:215] - ================================================================================
2026-06-16 10:55:20 - lzwcai_mcp_api_converter.src.util.logger_config - INFO - [logger_config.py:216] - 日志系统初始化完成 - 2026-06-16 10:55:20
2026-06-16 10:55:20 - lzwcai_mcp_api_converter.src.util.logger_config - INFO - [logger_config.py:217] - 日志级别: INFO
2026-06-16 10:55:20 - lzwcai_mcp_api_converter.src.util.logger_config - INFO - [logger_config.py:218] - 日志文件: E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcp_api_converter\lzwcai_mcp_api_converter.log
2026-06-16 10:55:20 - lzwcai_mcp_api_converter.src.util.logger_config - INFO - [logger_config.py:219] - 控制台输出: False
2026-06-16 10:55:20 - lzwcai_mcp_api_converter.src.util.logger_config - INFO - [logger_config.py:220] - 文件输出: True
2026-06-16 10:55:20 - lzwcai_mcp_api_converter.src.util.logger_config - INFO - [logger_config.py:221] - 文件轮转: 最大10MB, 保留5个备份
2026-06-16 10:55:20 - lzwcai_mcp_api_converter.src.util.logger_config - INFO - [logger_config.py:222] - ================================================================================

View File

@@ -0,0 +1,36 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
env/
ENV/
.venv/
# IDE
.vscode/
.idea/
*.swp
*.swo
# Project outputs
_out/

View File

@@ -0,0 +1,159 @@
# lzwcai-mcpskills-generate-reports
用户提供 **docx 模板 + JSON 数据**,本包负责渲染成 docx并可选做样式迁移。
本包**不内置模板**,模板完全由调用方维护。
## 安装
```powershell
cd lzwcai_mcpskills_generate_reports
pip install -e .
```
## Python API
### 渲染文档
```python
from lzwcai_mcpskills_generate_reports import generate
out_path = generate(
data="data.json", # dict 或 JSON 文件路径
template="./模板.docx", # 用户自己的 docx 模板路径
out_path="_out/报价方案.docx",
style_ref="./用户样式.docx", # 可选:把用户模板的 theme/字体套过来
)
```
### 扫描模板占位符
```python
from lzwcai_mcpskills_generate_reports import scan_template
result = scan_template("./模板.docx")
print(result)
# {
# "placeholders": ["project_title", "contact_person", "equipments", ...],
# "blocks": [
# {"type": "for", "iterator": "eq", "variable": "equipments"},
# {"type": "if", "condition": "show_layout"},
# ...
# ]
# }
```
## 命令行
```powershell
# 渲染
generate-report generate --template ./模板.docx --data data.json --out _out/报价方案.docx
# 扫描占位符
generate-report scan --template ./模板.docx
# 样式迁移
generate-report generate --template ./模板.docx --data data.json --style-ref ./用户样式.docx --out _out/报价方案_定制.docx
```
## MCP Server
本包同时提供 MCP Serverstdio 模式),把渲染引擎暴露成 3 个 MCP 工具:
| 工具 | 说明 | 必填参数 |
|------|------|----------|
| `generate_report` | 模板 + 数据 → 渲染输出 docx返回输出文件绝对路径 | `template`, `data`, `out`(可选 `style_ref` |
| `scan_template` | 扫描模板占位符与 for/if 块结构 | `template` |
| `validate_report_data` | 校验数据契约(不渲染) | `data` |
其中 `data` 既可以传 JSON 对象,也可以传指向 JSON 文件的路径字符串。
### 启动
```powershell
# 安装后用 console script 启动
lzwcai-mcpskills-generate-reports
# 或直接运行入口模块
python main.py
```
### MCP 客户端配置示例
```json
{
"mcpServers": {
"generate-reports": {
"command": "lzwcai-mcpskills-generate-reports"
}
}
}
```
> stdio 模式下 stdout 被 MCP 协议占用,所有日志写入 `logs/` 目录与 stderr。
## 数据契约QuoteData
```json
{
"project_title": "大米罐装线",
"contact_person": "张经理",
"contact_phone": "138-0000-0000",
"requirements": ["要求1", "要求2"],
"layout_image": "",
"layout_title": "整线布局尺寸图",
"equipments": [
{
"index": "四",
"name": "自动理瓶机",
"images": [""],
"features": [{"title": "特点", "lines": ["说明"]}],
"params": [{"k": "材料", "v": "不锈钢"}]
}
],
"quote_items": [
{"name": "设备名", "qty": "一套", "image": "", "desc": "说明", "price": "面议"}
]
}
```
图片字段约定:路径/URL → 真实图;`""` → 默认占位图;`None` → 不显示。
## 目录结构
```
lzwcai_mcpskills_generate_reports/
├── pyproject.toml
├── README.md
├── templates/ # 用户模板(示例,不在包内)
│ └── standard/
│ ├── template.docx
│ └── meta.json
├── samples/ # 示例数据(不在包内)
│ └── sample_data.json
└── lzwcai_mcpskills_generate_reports/ # Python 包
├── __init__.py # 公共 API 入口
├── cli.py # 命令行
├── pipeline.py # 总入口
├── schema.py # 数据契约 + 校验器
├── render_quote.py # 渲染引擎
├── style_transfer.py # 样式迁移
└── template_scanner.py # 模板占位符扫描
```
## 模板约定
- 使用 [Jinja2](https://jinja.palletsprojects.com/) 语法写占位符,如 `{{ project_title }}``{% for eq in equipments %}`
- 模板文件旁的 `meta.json`(可选)声明图片字段宽度,例如:
```json
{
"image_fields": {
"layout_image": {"width_mm": 160},
"equipments[].images[]": {"width_mm": 120},
"quote_items[].image": {"width_mm": 30}
}
}
```
- BUSINESS 逻辑(内置模板维护、模板市场等)交给包外的上层系统处理。

View File

@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
"""
lzwcai-mcpskills-generate-reports
纯渲染引擎,不内置模板。
对外暴露的公共 API
- generate: 数据 + 模板路径 -> docx核心入口
- scan_template: 模板路径 -> 占位符 JSON
- validate: 校验数据契约
- normalize: 归一化数据
- transplant_style: 将用户模板样式迁移到结果文档
"""
__version__ = "0.1.0"
from .pipeline import generate
from .template_scanner import scan_template
from .schema import validate, normalize, DEFAULTS
from .style_transfer import transplant_style
__all__ = [
"generate",
"scan_template",
"validate",
"normalize",
"transplant_style",
"DEFAULTS",
"__version__",
]

View File

@@ -0,0 +1,122 @@
# -*- coding: utf-8 -*-
"""
cli.py公共入口。
程序调用(推荐):
from lzwcai_mcpskills_generate_reports.cli import generate_report, scan_report
generate_report(
template="./模板.docx",
data={"project_name": "x", ...},
out="_out/a.docx",
)
scan_report(template="./模板.docx")
命令行:
generate-report generate --template ./模板.docx --data data.json --out _out/a.docx
generate-report scan --template ./模板.docx
"""
import argparse
import json
import os
import sys
try:
sys.stdout.reconfigure(encoding="utf-8")
except (AttributeError, OSError):
pass
from lzwcai_mcpskills_generate_reports import generate, scan_template
def _load_data(data):
"""把 data 归一化为 dict支持 dict 或 JSON 文件路径字符串。"""
if isinstance(data, str):
if not os.path.isfile(data):
raise FileNotFoundError(f"数据文件不存在: {data}")
with open(data, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise TypeError(f"data 必须是 dict 或 JSON 文件路径字符串,实际类型: {type(data).__name__}")
return data
def generate_report(template, data, out, style_ref=None):
"""生成 docx 报告。
参数:
template: 模板 docx 文件路径
data: dict 数据 或 JSON 文件路径字符串
out: 输出 docx 文件路径
style_ref: 用户样式参考 docx 路径(可选)
返回:
dict: {"output": 输出文件绝对路径}
"""
data = _load_data(data)
out = generate(data=data, template=template, out_path=out, style_ref=style_ref)
return {"output": out}
def scan_report(template):
"""扫描模板占位符。
参数:
template: 模板 docx 文件路径
返回:
dict: 占位符扫描结果
"""
return scan_template(template)
def _build_arg_parser():
parser = argparse.ArgumentParser(
description="docx 模板渲染与占位符扫描",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
generate-report generate --template ./模板.docx --data data.json --out _out/a.docx
generate-report generate --template ./模板.docx --data data.json --style-ref 用户样式.docx --out _out/b.docx
generate-report scan --template ./模板.docx
""",
)
sub = parser.add_subparsers(dest="command", required=True)
gen_parser = sub.add_parser("generate", help="渲染生成 docx")
gen_parser.add_argument("--template", required=True, help="模板 docx 文件路径")
gen_parser.add_argument("--data", required=True, help="数据 JSON 文件路径")
gen_parser.add_argument("--out", required=True, help="输出 docx 文件路径")
gen_parser.add_argument("--style-ref", default=None, help="用户上传的样式参考 docx可选")
scan_parser = sub.add_parser("scan", help="扫描模板占位符")
scan_parser.add_argument("--template", required=True, help="模板 docx 文件路径")
return parser
def main():
"""命令行入口console_scripts 调用)。"""
parser = _build_arg_parser()
args = parser.parse_args()
try:
if args.command == "generate":
result = generate_report(
template=args.template,
data=args.data,
out=args.out,
style_ref=args.style_ref,
)
print(f"生成成功: {result['output']}")
elif args.command == "scan":
result = scan_report(template=args.template)
print(json.dumps(result, ensure_ascii=False, indent=2))
except Exception as e:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,37 @@
2026-06-16 16:27:35 - root - INFO - [logger_config.py:95] - 日志系统初始化完成 - 日志目录: E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcpskills_generate_reports\lzwcai_mcpskills_generate_reports\logs
2026-06-16 16:27:35 - mcp.server.lowlevel.server - DEBUG - [server.py:162] - Initializing server 'lzwcai_mcpskills_generate_reports'
2026-06-16 16:27:35 - mcp.server.lowlevel.server - DEBUG - [server.py:439] - Registering handler for ListToolsRequest
2026-06-16 16:27:35 - mcp.server.lowlevel.server - DEBUG - [server.py:519] - Registering handler for CallToolRequest
2026-06-16 16:27:35 - asyncio - DEBUG - [proactor_events.py:634] - Using proactor: IocpProactor
2026-06-16 16:27:35 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:129] - 收到 ListTools 请求,返回 3 个工具
2026-06-16 16:27:35 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:169] - 收到 CallTool 请求: name=validate_report_data, arguments={"data": "samples\\sample_data.json"}
2026-06-16 16:27:35 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:183] - 工具执行成功: validate_report_data
2026-06-16 16:27:35 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:169] - 收到 CallTool 请求: name=scan_template, arguments={"template": "templates\\standard\\template.docx"}
2026-06-16 16:27:35 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:183] - 工具执行成功: scan_template
2026-06-16 16:28:01 - root - INFO - [logger_config.py:95] - 日志系统初始化完成 - 日志目录: E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcpskills_generate_reports\lzwcai_mcpskills_generate_reports\logs
2026-06-16 16:28:01 - mcp.server.lowlevel.server - DEBUG - [server.py:162] - Initializing server 'lzwcai_mcpskills_generate_reports'
2026-06-16 16:28:01 - mcp.server.lowlevel.server - DEBUG - [server.py:439] - Registering handler for ListToolsRequest
2026-06-16 16:28:01 - mcp.server.lowlevel.server - DEBUG - [server.py:519] - Registering handler for CallToolRequest
2026-06-16 16:28:01 - asyncio - DEBUG - [proactor_events.py:634] - Using proactor: IocpProactor
2026-06-16 16:28:01 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:129] - 收到 ListTools 请求,返回 3 个工具
2026-06-16 16:28:01 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:169] - 收到 CallTool 请求: name=validate_report_data, arguments={"data": "samples\\sample_data.json"}
2026-06-16 16:28:01 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:183] - 工具执行成功: validate_report_data
2026-06-16 16:28:01 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:169] - 收到 CallTool 请求: name=scan_template, arguments={"template": "templates\\standard\\template.docx"}
2026-06-16 16:28:01 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:183] - 工具执行成功: scan_template
2026-06-16 16:28:01 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:169] - 收到 CallTool 请求: name=generate_report, arguments={"template": "templates\\standard\\template.docx", "data": "samples\\sample_data.json", "out": "_out\\mcp_test.docx"}
2026-06-16 16:28:20 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:183] - 工具执行成功: generate_report
2026-06-17 10:11:02 - root - INFO - [logger_config.py:95] - 日志系统初始化完成 - 日志目录: E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcpskills_generate_reports\lzwcai_mcpskills_generate_reports\logs
2026-06-17 10:11:02 - mcp.server.lowlevel.server - DEBUG - [server.py:162] - Initializing server 'lzwcai_mcpskills_generate_reports'
2026-06-17 10:11:02 - mcp.server.lowlevel.server - DEBUG - [server.py:439] - Registering handler for ListToolsRequest
2026-06-17 10:11:02 - mcp.server.lowlevel.server - DEBUG - [server.py:519] - Registering handler for CallToolRequest
2026-06-17 10:11:02 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:215] - ==================================================
2026-06-17 10:11:02 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:216] - lzwcai-mcpskills-generate-reports MCP Server 启动
2026-06-17 10:11:02 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:217] - ==================================================
2026-06-17 10:11:02 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:218] - 开始运行 MCP Server (stdio 模式)
2026-06-17 10:11:02 - asyncio - DEBUG - [proactor_events.py:634] - Using proactor: IocpProactor
2026-06-17 10:11:02 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: root=InitializedNotification(method='notifications/initialized', params=None, jsonrpc='2.0')
2026-06-17 10:11:03 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: <mcp.shared.session.RequestResponder object at 0x000001E55CC2C2C0>
2026-06-17 10:11:03 - mcp.server.lowlevel.server - INFO - [server.py:720] - Processing request of type ListToolsRequest
2026-06-17 10:11:03 - mcp.server.lowlevel.server - DEBUG - [server.py:723] - Dispatching request of type ListToolsRequest
2026-06-17 10:11:03 - lzwcai_mcpskills_generate_reports.server - INFO - [server.py:129] - 收到 ListTools 请求,返回 3 个工具
2026-06-17 10:11:03 - mcp.server.lowlevel.server - DEBUG - [server.py:790] - Response sent

View File

@@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
"""
pipeline.py总入口 — 串起 数据校验 -> 渲染 -> (可选)样式迁移。
注意:本包不维护内置模板。调用方需要自己准备 .docx 模板文件,
并通过 template 参数传入路径。
"""
import os
import json
from .schema import validate, normalize
from .render_quote import render
from .style_transfer import transplant_style
def _load_data(data):
"""把 data 归一化为 dict支持 dict 或 JSON 文件路径。"""
if isinstance(data, str):
if not os.path.isfile(data):
raise FileNotFoundError(f"数据文件不存在: {data}")
with open(data, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise TypeError(f"data 必须是 dict 或 JSON 文件路径,实际类型: {type(data).__name__}")
return data
def _load_meta_for_template(template_path):
"""如果模板同目录下存在 meta.json则读取否则返回空 dict。"""
meta_file = os.path.join(os.path.dirname(template_path), "meta.json")
if os.path.isfile(meta_file):
with open(meta_file, "r", encoding="utf-8") as f:
return json.load(f)
return {}
def generate(data, template, out_path, style_ref=None):
"""生成报价文档。
参数:
data: QuoteData dict或 JSON 文件路径字符串)
template: 模板 docx 文件路径
out_path: 输出 docx 路径
style_ref: 用户上传的样式参考 docx 路径(可选)
返回:
生成的 docx 绝对路径
"""
data = _load_data(data)
if not isinstance(template, str):
raise TypeError(f"template 必须是文件路径字符串,实际类型: {type(template).__name__}")
if not os.path.isfile(template):
raise FileNotFoundError(f"模板文件不存在: {template}")
template_path = os.path.abspath(template)
# 归一化 + 校验
data = normalize(data)
errs = validate(data)
if errs:
raise ValueError("数据校验失败:\n" + "\n".join(errs))
# 确保输出目录存在
out_dir = os.path.dirname(os.path.abspath(out_path))
if out_dir:
os.makedirs(out_dir, exist_ok=True)
# 渲染(优先读取同目录 meta.json 作为图片配置)
meta = _load_meta_for_template(template_path)
render(data, out_path, template_path, meta=meta)
# 可选样式迁移
if style_ref:
if not os.path.isfile(style_ref):
raise FileNotFoundError(f"样式参考文件不存在: {style_ref}")
transplant_style(out_path, style_ref, out_path)
return os.path.abspath(out_path)

View File

@@ -0,0 +1,161 @@
# -*- coding: utf-8 -*-
"""
render_quote.py渲染引擎 — data + 模板路径 -> docx。
从 reports4 移植,做了两处通用化改造:
1. 模板路径参数化: render(data, out_path, template_path)
2. 图片字段配置驱动: 读 meta.json 的 image_fields 声明,不再写死字段名
"""
import copy
import os
import ssl
import sys
import tempfile
import urllib.request
import json
try:
sys.stdout.reconfigure(encoding="utf-8")
except (AttributeError, OSError):
pass
from docxtpl import DocxTemplate, InlineImage
from docx.shared import Mm
def _resolve_image_path(src, tmp_files):
"""把图片字段值解析为本地文件路径。
下载成功的临时文件会记录到 tmp_files由调用方在渲染结束后统一清理。
"""
def _download(url):
try:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
data = urllib.request.urlopen(req, context=ctx, timeout=30).read()
ext = os.path.splitext(url.split("?")[0])[1] or ".png"
fd, path = tempfile.mkstemp(suffix=ext)
os.write(fd, data)
os.close(fd)
tmp_files.append(path)
return path
except Exception as e:
print(f"[warn] 下载图片失败({url}): {e}")
return None
if src is None:
return ""
if src == "":
return ""
if isinstance(src, str) and src.lower().startswith(("http://", "https://")):
return _download(src) or ""
if os.path.exists(src):
return src
print(f"[warn] 找不到图片({src}),跳过")
return ""
def _make_inline_image(tpl, src, width_mm, tmp_files):
"""创建 InlineImagesrc 为 "" 或 None 时不创建(返回 None"""
resolved = _resolve_image_path(src, tmp_files)
if resolved == "":
return None
return InlineImage(tpl, resolved, width=Mm(width_mm))
def _resolve_field_path(data, path):
"""根据字段路径提取所有目标值。
支持: "root_field" / "list[].field" / "list[].field[]"
"""
results = []
parts = path.split(".")
if len(parts) == 1:
key = parts[0]
if key in data:
results.append((data, key))
return results
# 嵌套列表: "list[].field[]" —— 必须先于单层判断,否则会被 parts[0] 分支误捕获
if len(parts) == 2 and "[]" in parts[1]:
list_name = parts[0].replace("[]", "")
field = parts[1].replace("[]", "")
for item in data.get(list_name, []):
lst = item.get(field, [])
if isinstance(lst, list):
for idx in range(len(lst)):
results.append((lst, idx))
return results
if len(parts) == 2 and "[]" in parts[0]:
list_name = parts[0].replace("[]", "")
field = parts[1]
for item in data.get(list_name, []):
if field in item:
results.append((item, field))
return results
if len(parts) == 2:
list_name = parts[0]
field = parts[1]
for item in data.get(list_name, []):
if field in item:
results.append((item, field))
return results
return results
def _fill_images_from_meta(tpl, data, meta, tmp_files):
"""根据 meta.json 中的 image_fields 声明填充所有图片字段。"""
image_fields = meta.get("image_fields", {})
for path, width_meta in image_fields.items():
width_mm = width_meta.get("width_mm", 50)
refs = _resolve_field_path(data, path)
for container, key in refs:
val = container[key]
if val is None:
container[key] = ""
elif isinstance(val, list):
container[key] = [
_make_inline_image(tpl, x, width_mm, tmp_files) if x is not None else None
for x in val
]
container[key] = [img for img in container[key] if img is not None]
else:
img = _make_inline_image(tpl, val, width_mm, tmp_files)
container[key] = img if img else ""
return data
def render(data, out_path, template_path, meta=None):
"""渲染报价文档。"""
if meta is None:
meta_dir = os.path.dirname(template_path)
meta_file = os.path.join(meta_dir, "meta.json")
if os.path.isfile(meta_file):
with open(meta_file, "r", encoding="utf-8") as f:
meta = json.load(f)
else:
meta = {}
# 深拷贝:避免修改调用方传入的数据,同时把字符串字段替换成 InlineImage
data = copy.deepcopy(dict(data))
tpl = DocxTemplate(template_path)
tmp_files = []
try:
data = _fill_images_from_meta(tpl, data, meta, tmp_files)
tpl.render(data)
tpl.save(out_path)
finally:
for p in tmp_files:
try:
os.remove(p)
except OSError:
pass
return out_path

View File

@@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
"""
schema.pyQuoteData 数据契约、校验器、归一化。
所有模板共用同一契约,沿用 reports4 的 SAMPLE 字段结构。
"""
import copy
# ── 缺省值表 ──────────────────────────────────────────────
DEFAULTS = {
"layout_title": "整线布局尺寸图",
"show_layout": True,
}
# 中文数字扩展(用于缺 index 时自动补)
_CN_DIGITS = [
"", "", "", "", "", "", "", "", "", "",
"十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十",
]
# 设备之前的固定章节数:一 公司简介、二 客户要求及分析、三 布局图
_SECTION_OFFSET = 3
def validate(data):
"""校验 QuoteData 结构,返回错误信息列表(空=通过)。
检查:
- 必填顶层字段: project_title, contact_person, contact_phone, requirements
- equipments 元素: 必须有 name
- quote_items 元素: 必须有 name
- features 元素: 必须有 title 和 lines
- params 元素: 必须有 k 和 v
"""
errors = []
for field in ("project_title", "contact_person", "contact_phone", "requirements"):
val = data.get(field)
if val is None or (isinstance(val, str) and val.strip() == ""):
errors.append(f"必填字段缺失或为空: {field}")
# equipments
eqs = data.get("equipments", [])
if not isinstance(eqs, list):
errors.append("equipments 必须为列表")
else:
for i, eq in enumerate(eqs):
if not isinstance(eq, dict):
errors.append(f"equipments[{i}] 必须为对象")
continue
if not eq.get("name"):
errors.append(f"equipments[{i}] 缺少 name")
# features 子元素
for j, feat in enumerate(eq.get("features", [])):
if not isinstance(feat, dict):
errors.append(f"equipments[{i}].features[{j}] 必须为对象")
continue
if not feat.get("title"):
errors.append(f"equipments[{i}].features[{j}] 缺少 title")
if not feat.get("lines") or not isinstance(feat.get("lines"), list):
errors.append(f"equipments[{i}].features[{j}] 缺少 lines 或类型错误")
# params 子元素
for j, p in enumerate(eq.get("params", [])):
if not isinstance(p, dict):
errors.append(f"equipments[{i}].params[{j}] 必须为对象")
continue
if not p.get("k") or not p.get("v"):
errors.append(f"equipments[{i}].params[{j}] 缺少 k 或 v")
# quote_items
items = data.get("quote_items", [])
if not isinstance(items, list):
errors.append("quote_items 必须为列表")
else:
for i, it in enumerate(items):
if not isinstance(it, dict):
errors.append(f"quote_items[{i}] 必须为对象")
continue
if not it.get("name"):
errors.append(f"quote_items[{i}] 缺少 name")
return errors
def normalize(data):
"""归一化数据填缺省值、requirements list->str、index 缺失时自动补。
不修改入参,返回新副本。
"""
d = copy.deepcopy(data)
# requirements: list -> str
req = d.get("requirements")
if isinstance(req, (list, tuple)):
d["requirements"] = "\n".join(str(x) for x in req)
# 缺省值
for k, v in DEFAULTS.items():
if k not in d:
d[k] = v
# equipment index 自动补(从"四"开始,前面三节是公司简介/客户要求/布局图)
for i, eq in enumerate(d.get("equipments", [])):
if not eq.get("index"):
idx = i + 1 + _SECTION_OFFSET
eq["index"] = _CN_DIGITS[idx - 1] if idx <= len(_CN_DIGITS) else str(idx)
# quote_items 缺 image 给空串
for it in d.get("quote_items", []):
it.setdefault("image", "")
# equipments 缺 images 给 [""]
for eq in d.get("equipments", []):
eq.setdefault("images", [""])
# 计算后续章节的动态序号:报价表 & 售后服务
eq_count = len(d.get("equipments", []))
quote_idx = eq_count + _SECTION_OFFSET + 1
after_sales_idx = eq_count + _SECTION_OFFSET + 2
d["section_quote_table"] = _CN_DIGITS[quote_idx - 1] if quote_idx <= len(_CN_DIGITS) else str(quote_idx)
d["section_after_sales"] = _CN_DIGITS[after_sales_idx - 1] if after_sales_idx <= len(_CN_DIGITS) else str(after_sales_idx)
return d

View File

@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""
lzwcai-mcpskills-generate-reports MCP Server
把 docx 模板渲染引擎封装成 MCP 工具,提供三个工具:
- generate_report: 数据 + 模板路径 -> 渲染输出 docx
- scan_template: 扫描模板占位符 / for / if 块
- validate_report_data: 校验数据契约(不渲染)
stdio 模式运行;所有日志走 stderrstdout 留给 MCP 协议。
"""
import os
import json
import logging
import anyio
import mcp.types as types
from mcp.server import NotificationOptions, Server
from mcp.server.models import InitializationOptions
from mcp.server.stdio import stdio_server
try:
from .utils.logger_config import setup_system_logging, get_logger
from . import generate, scan_template, validate, normalize
except ImportError:
from lzwcai_mcpskills_generate_reports.utils.logger_config import (
setup_system_logging, get_logger,
)
from lzwcai_mcpskills_generate_reports import (
generate, scan_template, validate, normalize,
)
# 初始化日志系统
setup_system_logging(app_name="lzwcai_mcpskills_generate_reports", log_level=logging.DEBUG)
logger = get_logger(__name__)
# 初始化 MCP Server
server = Server("lzwcai_mcpskills_generate_reports")
def _load_data(data):
"""把 data 归一化为 dict支持 dict 或 JSON 文件路径字符串。"""
if isinstance(data, str):
if not os.path.isfile(data):
raise FileNotFoundError(f"数据文件不存在: {data}")
with open(data, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise TypeError(
f"data 必须是对象或 JSON 文件路径字符串,实际类型: {type(data).__name__}"
)
return data
# ── 工具定义 ──────────────────────────────────────────────
_DATA_DESC = "报价数据:可以是 JSON 对象,或指向 JSON 文件的路径字符串"
TOOL_DEFS = [
types.Tool(
name="generate_report",
description=(
"用 docx 模板 + 结构化数据渲染生成报价文档 docx返回输出文件绝对路径。"
"模板由调用方提供,本工具不内置模板。"
),
inputSchema={
"type": "object",
"properties": {
"template": {
"type": "string",
"description": "模板 docx 文件路径(必填)",
},
"data": {
"type": ["object", "string"],
"description": _DATA_DESC + "(必填)",
},
"out": {
"type": "string",
"description": "输出 docx 文件路径(必填)",
},
"style_ref": {
"type": "string",
"description": "用户上传的样式参考 docx 路径(可选),会把其 theme/字体套到结果文档",
},
},
"required": ["template", "data", "out"],
},
),
types.Tool(
name="scan_template",
description=(
"扫描 docx 模板中的 Jinja2 占位符,返回需要外部提供的顶层变量列表和 for/if 块结构。"
"用于在渲染前了解模板需要哪些数据字段。"
),
inputSchema={
"type": "object",
"properties": {
"template": {
"type": "string",
"description": "模板 docx 文件路径(必填)",
},
},
"required": ["template"],
},
),
types.Tool(
name="validate_report_data",
description=(
"校验报价数据是否符合契约必填字段、equipments/quote_items/features/params 结构),"
"不渲染文档。返回校验结果和错误列表。"
),
inputSchema={
"type": "object",
"properties": {
"data": {
"type": ["object", "string"],
"description": _DATA_DESC + "(必填)",
},
},
"required": ["data"],
},
),
]
@server.list_tools()
async def handle_list_tools() -> list[types.Tool]:
"""列出所有可用工具"""
logger.info(f"收到 ListTools 请求,返回 {len(TOOL_DEFS)} 个工具")
return TOOL_DEFS
# ── 工具实现(同步函数,放线程池执行)──────────────────────
def _do_generate_report(arguments: dict) -> dict:
template = arguments["template"]
data = arguments["data"]
out = arguments["out"]
style_ref = arguments.get("style_ref")
data = _load_data(data)
out_path = generate(data=data, template=template, out_path=out, style_ref=style_ref)
return {"output": out_path}
def _do_scan_template(arguments: dict) -> dict:
return scan_template(arguments["template"])
def _do_validate(arguments: dict) -> dict:
data = _load_data(arguments["data"])
norm = normalize(data)
errors = validate(norm)
return {"valid": not errors, "errors": errors}
_HANDLERS = {
"generate_report": _do_generate_report,
"scan_template": _do_scan_template,
"validate_report_data": _do_validate,
}
@server.call_tool()
async def handle_call_tool(
name: str,
arguments: dict | None,
) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
"""调用工具"""
logger.info(
f"收到 CallTool 请求: name={name}, "
f"arguments={json.dumps(arguments, ensure_ascii=False) if arguments else 'None'}"
)
handler = _HANDLERS.get(name)
if handler is None:
logger.error(f"未找到工具: {name}")
raise ValueError(f"未知工具: {name}")
args = arguments or {}
try:
# 渲染/扫描是同步阻塞的 CPU/IO 任务,放线程池避免霸占 event loop
result = await anyio.to_thread.run_sync(handler, args)
logger.info(f"工具执行成功: {name}")
except Exception as e:
logger.error(f"工具执行失败: {name}: {e}", exc_info=True)
result = {"error": str(e), "tool_name": name}
return [
types.TextContent(
type="text",
text=json.dumps(result, ensure_ascii=False, indent=2),
)
]
async def run_server():
"""运行 MCP Server (stdio 模式)"""
async with stdio_server() as streams:
await server.run(
streams[0],
streams[1],
InitializationOptions(
server_name="lzwcai_mcpskills_generate_reports",
server_version="0.1.0",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
)
def main():
"""主入口"""
logger.info("=" * 50)
logger.info("lzwcai-mcpskills-generate-reports MCP Server 启动")
logger.info("=" * 50)
logger.info("开始运行 MCP Server (stdio 模式)")
anyio.run(run_server)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,183 @@
# -*- coding: utf-8 -*-
"""
style_transfer.py从用户上传的 docx 抽取视觉样式theme1.xml + styles.xml 的默认字体引用),
套到内置骨架渲染结果上,产出"风格接近用户模板"的文档。
Level 1 (MVP):整体替换 theme + 默认字体引用,不整体覆盖 styles避免版式崩
"""
import os
import zipfile
import tempfile
import shutil
import xml.etree.ElementTree as ET
# Word OOXML 命名空间
_NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
}
# 主题字体引用中 major/minor 属性的前缀
_THEME_PREFIXES = ("majorHAnsi", "minorHAnsi", "majorEastAsia", "minorEastAsia",
"majorCs", "minorCs", "major", "minor")
def _unzip_docx(docx_path, target_dir):
"""解压 docxzip到目标目录。"""
with zipfile.ZipFile(docx_path, "r") as z:
z.extractall(target_dir)
def _zip_docx(source_dir, docx_path):
"""将目录内容打包为 docxzip"""
with zipfile.ZipFile(docx_path, "w", zipfile.ZIP_DEFLATED) as z:
for root, _dirs, files in os.walk(source_dir):
for fname in files:
full = os.path.join(root, fname)
arcname = os.path.relpath(full, source_dir)
z.write(full, arcname)
def _safe_xml_read(xml_path):
"""安全读取 XML 文件,返回 ElementTree 和根元素。失败返回 (None, None)。"""
try:
tree = ET.parse(xml_path)
return tree, tree.getroot()
except Exception:
return None, None
def _copy_theme(src_dir, dst_dir):
"""将 src 的 word/theme/theme1.xml 复制到 dst。"""
src_theme = os.path.join(src_dir, "word", "theme", "theme1.xml")
dst_theme = os.path.join(dst_dir, "word", "theme", "theme1.xml")
if os.path.isfile(src_theme):
os.makedirs(os.path.dirname(dst_theme), exist_ok=True)
shutil.copy2(src_theme, dst_theme)
return True
return False
def _copy_font_table(src_dir, dst_dir):
"""将 src 的 word/fontTable.xml 复制到 dst。"""
src_ft = os.path.join(src_dir, "word", "fontTable.xml")
dst_ft = os.path.join(dst_dir, "word", "fontTable.xml")
if os.path.isfile(src_ft):
shutil.copy2(src_ft, dst_ft)
return True
return False
def _merge_default_fonts(src_styles_path, dst_styles_path):
"""合并默认字体引用docDefaults 中的 rFonts从 src 到 dst。
不整体覆盖 styles.xml只替换 docDefaults 里的字体引用。
"""
if not os.path.isfile(src_styles_path) or not os.path.isfile(dst_styles_path):
return False
_, src_root = _safe_xml_read(src_styles_path)
dst_tree, dst_root = _safe_xml_read(dst_styles_path)
if src_root is None or dst_root is None:
return False
w_ns = _NS["w"]
# 找 src 的 docDefaults
src_doc_defaults = src_root.find(f".//{{{w_ns}}}docDefaults")
if src_doc_defaults is None:
return False
src_rpr = src_doc_defaults.find(f"{{{w_ns}}}rPrDefault/{{{w_ns}}}rFonts")
if src_rpr is None:
return False
# 找 dst 的 docDefaults
dst_doc_defaults = dst_root.find(f".//{{{w_ns}}}docDefaults")
if dst_doc_defaults is None:
return False
dst_rpr = dst_doc_defaults.find(f"{{{w_ns}}}rPrDefault/{{{w_ns}}}rFonts")
if dst_rpr is None:
# 如果 dst 没有 rFonts创建并追加
rpr_default = dst_doc_defaults.find(f"{{{w_ns}}}rPrDefault")
if rpr_default is None:
rpr_default = ET.SubElement(dst_doc_defaults, f"{{{w_ns}}}rPrDefault")
rpr_default.append(src_rpr)
else:
# 替换 rFonts 的属性(主题字体引用)
for attr_name, attr_val in src_rpr.attrib.items():
if any(attr_name.endswith(prefix) for prefix in _THEME_PREFIXES):
dst_rpr.set(attr_name, attr_val)
# 保存
dst_tree.write(dst_styles_path, xml_declaration=True, encoding="UTF-8", standalone="yes")
return True
def transplant_style(result_path, user_template_path, out_path):
"""将用户模板的视觉样式移植到结果文档上。
参数:
result_path: 内置骨架渲染出的 docx 路径
user_template_path: 用户上传的样式参考 docx
out_path: 输出路径(可与 result_path 相同,原地覆盖)
"""
# 校验用户模板可打开
try:
from docx import Document
Document(user_template_path)
except Exception as e:
print(f"[warn] 用户上传模板无法打开,跳过样式迁移: {e}")
if out_path != result_path:
shutil.copy2(result_path, out_path)
return out_path
# 在打包覆盖前先记录原文档段落数out_path 可能 == result_path原地覆盖后就读不到原值了
src_count = None
try:
from docx import Document
src_count = len(Document(result_path).paragraphs)
except Exception as e:
print(f"[warn] 读取原文档段落数失败,迁移后将跳过段落数校验: {e}")
# 创建临时工作目录
with tempfile.TemporaryDirectory() as tmpdir:
src_dir = os.path.join(tmpdir, "src") # 用户上传模板
dst_dir = os.path.join(tmpdir, "dst") # 渲染结果
os.makedirs(src_dir)
os.makedirs(dst_dir)
# 解压
_unzip_docx(user_template_path, src_dir)
_unzip_docx(result_path, dst_dir)
# 1) 替换 theme1.xml
_copy_theme(src_dir, dst_dir)
# 2) 替换 fontTable.xml
_copy_font_table(src_dir, dst_dir)
# 3) 合并默认字体引用
src_styles = os.path.join(src_dir, "word", "styles.xml")
dst_styles = os.path.join(dst_dir, "word", "styles.xml")
_merge_default_fonts(src_styles, dst_styles)
# 重新打包
_zip_docx(dst_dir, out_path)
# 校验:能正常打开且段落数与迁移前一致
try:
from docx import Document
dst_count = len(Document(out_path).paragraphs)
if src_count is None:
print(f"[ok] 样式迁移完成(无原始段落数可比对),输出段落数: {dst_count}")
elif dst_count == 0 or dst_count != src_count:
print(f"[warn] 样式迁移后段落数不一致 (src={src_count}, dst={dst_count}),可能损坏")
else:
print(f"[ok] 样式迁移完成,段落数一致: {src_count}")
except Exception as e:
print(f"[error] 样式迁移后文档无法打开: {e}")
return out_path

View File

@@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
"""
template_scanner.py扫描 docx 模板中的 Jinja2 占位符。
只读模板,不渲染;返回模板里要求外部提供的数据字段清单。
"""
import re
import zipfile
import xml.etree.ElementTree as ET
from jinja2 import Environment, meta
from jinja2 import nodes as jinja_nodes
def _iter_docx_text(docx_path):
"""遍历 docx 中所有 XML 文本节点,产出原始字符串片段。"""
with zipfile.ZipFile(docx_path, "r") as z:
for name in z.namelist():
# 只关心 word 主文档、页眉、页脚
if not (name.startswith("word/document") or name.startswith("word/header") or name.startswith("word/footer")):
continue
data = z.read(name)
try:
root = ET.fromstring(data)
except ET.ParseError:
continue
# w:t 节点存放文本
for elem in root.iter():
if elem.tag.endswith("}t"):
if elem.text:
yield elem.text
def _extract_source(docx_path):
"""把 docx 所有文本片段拼成一段连续的源文本,便于 Jinja2 解析。"""
return "\n".join(_iter_docx_text(docx_path))
def _normalize_docxtpl_tags(source):
"""把 docxtpl 段落/行/单元格级标签 {%p ... %} {%tr ... %} {%tc ... %} 还原为 {% ... %}。
docxtpl 用 {%p、{%tr、{%tc 控制块作用于段落、表格行、表格单元格;
扫描占位符时不需要这些粒度信息,统一成标准 Jinja2 标签即可解析。
"""
return re.sub(r"{%\s*(?:p|tr|tc)\s+", "{% ", source)
def _walk_blocks(node, result=None):
"""遍历 Jinja2 AST收集 For / If 块信息。"""
if result is None:
result = []
if node is None:
return result
if isinstance(node, jinja_nodes.For):
iter_name = _expression_name(node.iter)
target_name = _expression_name(node.target)
result.append({
"type": "for",
"iterator": target_name,
"variable": iter_name,
})
for child in node.body:
_walk_blocks(child, result)
for child in node.else_ or []:
_walk_blocks(child, result)
return result
if isinstance(node, jinja_nodes.If):
test_name = _expression_name(node.test)
result.append({
"type": "if",
"condition": test_name,
})
for child in node.body:
_walk_blocks(child, result)
for child in node.else_ or []:
_walk_blocks(child, result)
return result
if hasattr(node, "body"):
for child in node.body:
_walk_blocks(child, result)
if hasattr(node, "else_") and node.else_:
for child in node.else_:
_walk_blocks(child, result)
return result
def _expression_name(expr):
"""把 Jinja2 表达式尽量还原为可读的字符串。"""
if expr is None:
return None
if isinstance(expr, jinja_nodes.Name):
return expr.name
if isinstance(expr, jinja_nodes.Const):
return str(expr.value)
if isinstance(expr, jinja_nodes.Getattr):
return f"{_expression_name(expr.node)}.{expr.attr}"
if isinstance(expr, jinja_nodes.Getitem):
return f"{_expression_name(expr.node)}[{_expression_name(expr.arg)}]"
return str(expr)
def scan_template(template_path):
"""扫描 docx 模板,返回占位符信息 JSON。
返回:
{
"placeholders": ["project_title", "equipments", ...], # 需要外部提供的最顶层变量
"blocks": [
{"type": "for", "iterator": "eq", "variable": "equipments"},
{"type": "if", "condition": "show_layout"},
...
]
}
"""
source = _normalize_docxtpl_tags(_extract_source(template_path))
if not source.strip():
return {"placeholders": [], "blocks": []}
env = Environment()
ast = env.parse(source)
variables = sorted(meta.find_undeclared_variables(ast))
blocks = _walk_blocks(ast)
return {
"placeholders": variables,
"blocks": blocks,
}

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
"""Utils package for lzwcai_mcpskills_generate_reports"""
from .logger_config import setup_system_logging, get_logger
__all__ = ["setup_system_logging", "get_logger"]

View File

@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
"""
统一日志配置模块
提供系统级别的日志配置和管理
注意MCP 协议使用 stdio 通信时stdout 被协议占用,
所有日志必须输出到 stderr 或文件,绝不能写 stdout。
"""
import os
import sys
import logging
from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler
from pathlib import Path
class LoggerConfig:
"""日志配置管理类"""
def __init__(self, logs_dir: str = None):
if logs_dir:
self.logs_dir = Path(logs_dir)
else:
project_root = Path(__file__).parent.parent
self.logs_dir = project_root / "logs"
self.logs_dir.mkdir(exist_ok=True)
self.log_format = '%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s'
self.date_format = '%Y-%m-%d %H:%M:%S'
self.log_level = self._get_log_level_from_env()
self._initialized = False
def _get_log_level_from_env(self) -> int:
log_level_str = os.getenv('LOG_LEVEL', 'INFO').upper()
level_mapping = {
'DEBUG': logging.DEBUG,
'INFO': logging.INFO,
'WARNING': logging.WARNING,
'WARN': logging.WARNING,
'ERROR': logging.ERROR,
'CRITICAL': logging.CRITICAL,
'FATAL': logging.CRITICAL,
}
return level_mapping.get(log_level_str, logging.INFO)
def setup_logging(self,
app_name: str = "lzwcai_mcpskills_generate_reports",
log_level: int = logging.INFO,
max_file_size: int = 10 * 1024 * 1024,
backup_count: int = 5,
console_output: bool = True) -> logging.Logger:
if self._initialized:
return logging.getLogger()
root_logger = logging.getLogger()
root_logger.setLevel(log_level)
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
formatter = logging.Formatter(self.log_format, self.date_format)
# 1. 主日志文件 - 按大小滚动
main_log_file = self.logs_dir / f"{app_name}.log"
file_handler = RotatingFileHandler(
main_log_file, maxBytes=max_file_size,
backupCount=backup_count, encoding='utf-8',
)
file_handler.setLevel(log_level)
file_handler.setFormatter(formatter)
root_logger.addHandler(file_handler)
# 2. 错误日志文件
error_log_file = self.logs_dir / f"{app_name}_error.log"
error_handler = RotatingFileHandler(
error_log_file, maxBytes=max_file_size,
backupCount=backup_count, encoding='utf-8',
)
error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(formatter)
root_logger.addHandler(error_handler)
# 3. 控制台输出到 stderrstdio 模式下 stdout 被 MCP 协议占用)
if console_output:
console_handler = logging.StreamHandler(sys.stderr)
console_handler.setLevel(log_level)
console_handler.setFormatter(logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
self.date_format,
))
root_logger.addHandler(console_handler)
self._initialized = True
root_logger.info(f"日志系统初始化完成 - 日志目录: {self.logs_dir}")
return root_logger
def get_module_logger(self, module_name: str) -> logging.Logger:
return logging.getLogger(module_name)
# 全局日志配置实例
logger_config = LoggerConfig()
def setup_system_logging(app_name: str = "lzwcai_mcpskills_generate_reports",
log_level: int = logging.INFO) -> logging.Logger:
return logger_config.setup_logging(app_name, log_level)
def get_logger(name: str) -> logging.Logger:
return logger_config.get_module_logger(name)

View File

@@ -0,0 +1,10 @@
"""
Entry point for lzwcai-mcpskills-generate-reports
Runs the MCP server (stdio mode) for docx report generation.
"""
from lzwcai_mcpskills_generate_reports.server import main
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "lzwcai-mcpskills-generate-reports"
version = "0.1.0"
description = "Render styled quotation documents from user-supplied docx templates and structured data"
readme = "README.md"
requires-python = ">=3.12"
keywords = ["docx", "quotation", "report", "template", "jinja2"]
authors = [
{ name = "LzwCai", email = "lzwcai@example.com" },
]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"docxtpl>=0.16.0",
"python-docx>=1.1.0",
"Jinja2>=3.1.0",
"mcp>=1.0.0",
"anyio>=4.0.0",
]
[project.scripts]
generate-report = "lzwcai_mcpskills_generate_reports.cli:main"
lzwcai-mcpskills-generate-reports = "lzwcai_mcpskills_generate_reports.server:main"
[project.urls]
Homepage = "https://github.com/lzwcai/lzwcai-mcpskills-generate-reports"
Repository = "https://github.com/lzwcai/lzwcai-mcpskills-generate-reports"
[tool.setuptools.packages.find]
where = ["."]
include = ["lzwcai_mcpskills_generate_reports*"]
exclude = ["tests*", "_out*", "templates*", "samples*"]

View File

@@ -0,0 +1,412 @@
{
"project_title": "大米圆形纸罐全自动灌装包装整线项目",
"contact_person": "张卫国经理",
"contact_phone": "138-1568-9632",
"contact_company": "XX粮油食品有限公司",
"requirements": [
"包装物料成品精制大米流动性颗粒物料单罐净重规格250g、500g、1000g三档快速切换。",
"罐体参数:圆形食品级纸罐,配套易拉预封口+外旋螺纹盖双重密封纸罐固定外径100mm10cm高度分3档60mm(6cm)/120mm(12cm)/220mm(22cm),同线兼容三规格自动换型无需更换夹具。",
"额定包装速度以500g标准罐计稳定产能≥20罐/分钟连续24h不间断运行无卡罐、漏装。",
"整线标配:大米定量称重灌装机、罐身输送线、自动上盖机、四轮旋盖机、成品出料输送机;",
"客户选配完整模组:在线金属检测机(带自动剔除机构)+重量复检机(不合格自动分流剔除)+后端自动开箱机+折盖封箱一体机+机器人码垛单元;",
"全线材质要求物料接触部位304不锈钢符合食品QS/SC生产卫生规范支持水洗清洁。"
],
"layout_image": "https://dscache.tencent-cloud.cn/upload/nodir/rice-can-line-layout-202605.png",
"layout_title": "大米纸罐灌装整线平面布局尺寸总图",
"equipments": [
{
"name": "Z型大米颗粒上料提升机",
"images": [
"https://dscache.tencent-cloud.cn/upload/nodir/z-conveyor-01.png",
"https://dscache.tencent-cloud.cn/upload/nodir/z-conveyor-detail.png"
],
"features": [
{
"title": "粮食专用密封提升",
"lines": [
"封闭式料斗输送,无大米撒料、扬尘,车间粉尘达标;适配大米、杂粮等颗粒原料连续上料。",
"料斗加厚304不锈钢耐磨抗冲击不易积粮霉变便于高压水枪冲洗。"
]
},
{
"title": "变频调速稳定可控",
"lines": [
"独立变频电机调速,上料流量与灌装主机信号联动匹配,不会断料或溢料。",
"低噪音链条传动,连续运行故障率低,维护简单,可长期满负荷生产。"
]
}
],
"params": [
{
"k": "有效提升高度",
"v": "3.6m(支持现场按需加长定制)"
},
{
"k": "输送速度可调范围",
"v": "0~16m/min"
},
{
"k": "最大输送产能",
"v": "7.2m³/h满足25罐/分钟灌装余量"
},
{
"k": "驱动总功率",
"v": "0.55kW"
},
{
"k": "供电制式",
"v": "380V三相 50Hz"
},
{
"k": "整机净重",
"v": "385kg"
},
{
"k": "物料接触材质",
"v": "304食品级不锈钢"
}
]
},
{
"name": "多头称重式大米灌装机",
"images": [
"https://dscache.tencent-cloud.cn/upload/nodir/rice-filling-machine.png"
],
"features": [
{
"title": "高精度称重灌装",
"lines": [
"二级快慢加料250g/500g/1000g重量参数触摸屏一键调用单罐称重误差≤±0.8g。",
"独立称重料斗,不受物料料位高低影响,长时间灌装重量一致性稳定。"
]
},
{
"title": "食品级卫生设计",
"lines": [
"料仓、下料口快拆结构,无需工具即可拆卸清洗,无死角存粮。",
"整机带防尘外封板,避免蚊虫、杂物混入大米成品。"
]
}
],
"params": [
{
"k": "灌装量程",
"v": "100~1200g可调"
},
{
"k": "额定产能",
"v": "22~28罐/分钟500g规格"
},
{
"k": "允许罐型外径",
"v": "60~120mm"
},
{
"k": "整机功率",
"v": "1.2kW"
},
{
"k": "外形长宽高",
"v": "1450×920×1850mm"
},
{
"k": "整机重量",
"v": "420kg"
},
{
"k": "操作界面",
"v": "7寸触摸屏+PLC全自动控制"
}
]
},
{
"name": "自动纸罐理罐机",
"images": [
"https://dscache.tencent-cloud.cn/upload/nodir/can-sorter.png"
],
"features": [
{
"title": "圆形纸罐定向排序",
"lines": [
"自动散乱上罐、扶正定位、有序输送杜绝倒罐、卡罐适配本次100mm外径纸罐三高度规格。",
"定位挡板手摇快速调节换型时间≤3分钟。"
]
},
{
"title": "整机耐用易维护",
"lines": [
"机架304不锈钢输送带独立无极变频调速可和灌装主机速度同步联动。",
"机械结构简洁,易损件少,车间操作工可独立日常检修。"
]
}
],
"params": [
{
"k": "主体材质",
"v": "304不锈钢机架+食品级PU输送带"
},
{
"k": "整机外形尺寸",
"v": "1020×810×1220mm"
},
{
"k": "最大处理产能",
"v": "30~50罐/分钟"
},
{
"k": "理罐转盘直径",
"v": "800mm"
},
{
"k": "适配罐体外径",
"v": "60~200mm"
},
{
"k": "工作电压",
"v": "单相220V50Hz"
},
{
"k": "整机重量",
"v": "145kg"
}
]
},
{
"name": "自动上盖机+单头四轮旋盖一体机",
"images": [
"https://dscache.tencent-cloud.cn/upload/nodir/capping-twist-machine.png"
],
"features": [
{
"title": "自动送盖+定位旋盖一体化",
"lines": [
"料仓自动整理螺纹外盖,分盖、落盖精准套入罐口,四轮橡胶轮柔性夹紧旋紧,不会压扁纸质罐口。",
"旋盖扭矩数字可调,杜绝滑盖、拧过紧纸罐变形,适配易拉内封+外旋盖双层封口工艺。"
]
},
{
"title": "速度同步联动",
"lines": [
"变频调速跟随灌装线主线速度,无空罐漏旋,缺盖自动停机报警提示补料。"
]
}
],
"params": [
{
"k": "供电规格",
"v": "220V 50Hz"
},
{
"k": "整机装机功率",
"v": "1.0kW"
},
{
"k": "适配罐口直径",
"v": "35~130mm"
},
{
"k": "稳定旋盖速度",
"v": "25~32罐/分钟"
},
{
"k": "设备外形尺寸",
"v": "2020×660×1510mm"
},
{
"k": "整机重量",
"v": "295kg"
},
{
"k": "扭矩调节方式",
"v": "数字扭矩电控调节"
}
]
},
{
"name": "在线金属检测机(带剔除)",
"images": [
"https://dscache.tencent-cloud.cn/upload/nodir/metal-detector-reject.png"
],
"features": [
{
"title": "高精度金属异物检测",
"lines": [
"可检出铁、不锈钢、铜、铝等混入大米内金属碎屑、螺钉刀片,不合格罐气动自动剔除分流,不混入合格品。",
"检测灵敏度数字可调,产品记忆存储,多规格一键切换。"
]
}
],
"params": [
{
"k": "检测通道尺寸",
"v": "宽140mm×高280mm"
},
{
"k": "检测灵敏度",
"v": "Feφ1.0mm、SUSφ2.2mm"
},
{
"k": "剔除方式",
"v": "气动推杆自动剔除"
},
{
"k": "适配线速",
"v": "0~30m/min"
},
{
"k": "功率",
"v": "0.37kW"
}
]
},
{
"name": "成品重量复检机",
"images": [
"https://dscache.tencent-cloud.cn/upload/nodir/checkweigher.png"
],
"features": [
{
"title": "缺料超重自动分选",
"lines": [
"在线动态称重,低于下限、高于上限罐体自动剔除,杜绝少装、多装次品流入装箱工序。"
]
}
],
"params": [
{
"k": "称重量程",
"v": "0~2000g"
},
{
"k": "称重精度",
"v": "±0.3g"
},
{
"k": "剔除方式",
"v": "气动拨杆剔除"
}
]
},
{
"name": "后端自动开箱+折盖封箱一体机",
"images": [
"https://dscache.tencent-cloud.cn/upload/nodir/case-sealer.png"
],
"features": [
{
"title": "纸箱成型封底+上盖折平封箱一次完成",
"lines": [
"整垛纸箱自动吸取撑开、底部胶带封牢,成品罐装箱后自动折左右上盖,上下工字封箱,适配整线连续自动化装箱。"
]
}
],
"params": [
{
"k": "适用纸箱尺寸范围",
"v": "长250~450×宽180~320×高150~400mm"
},
{
"k": "封箱速度",
"v": "6~12箱/分钟"
},
{
"k": "总功率",
"v": "1.8kW"
}
]
},
{
"name": "机械臂码垛机",
"images": [
"https://dscache.tencent-cloud.cn/upload/nodir/robot-palletizer.png"
],
"features": [
{
"title": "纸箱自动堆叠码垛",
"lines": [
"伺服抓手抓取成品整箱,按预设垛型整齐码放在托盘上,码垛高度、层数程序可调,替代人工堆垛。"
]
}
],
"params": [
{
"k": "最大负载",
"v": "25kg/箱"
},
{
"k": "码垛高度上限",
"v": "1800mm"
},
{
"k": "工作节拍",
"v": "8~12箱/分钟"
}
]
}
],
"quote_items": [
{
"name": "Z型大米上料提升机",
"qty": "1套",
"image": "https://dscache.tencent-cloud.cn/upload/nodir/z-conveyor-01.png",
"desc": "304不锈钢封闭式粮食提升变频调速配套灌装主机联动上料",
"price": "面议"
},
{
"name": "多头称重大米灌装机",
"qty": "1套",
"image": "https://dscache.tencent-cloud.cn/upload/nodir/rice-filling-machine.png",
"desc": "三规格重量一键切换,高精度称重下料,食品级快拆清洗结构",
"price": "面议"
},
{
"name": "自动纸罐理罐机",
"qty": "1套",
"image": "https://dscache.tencent-cloud.cn/upload/nodir/can-sorter.png",
"desc": "圆形纸罐自动排序扶正适配Φ100mm三高度纸罐快速换型",
"price": "面议"
},
{
"name": "自动上盖+四轮旋盖一体机",
"qty": "1套",
"image": "https://dscache.tencent-cloud.cn/upload/nodir/capping-twist-machine.png",
"desc": "自动分盖上盖,数字扭矩旋紧,适配纸罐易拉盖+外旋盖双层封口",
"price": "面议"
},
{
"name": "金属检测机(带剔除)",
"qty": "1套",
"image": "https://dscache.tencent-cloud.cn/upload/nodir/metal-detector-reject.png",
"desc": "在线金属异物检测,不合格罐体自动剔除分流,食品生产合规必备",
"price": "面议"
},
{
"name": "重量复检剔除机",
"qty": "1套",
"image": "https://dscache.tencent-cloud.cn/upload/nodir/checkweigher.png",
"desc": "动态在线称重,超重欠重次品自动剔除,保证净含量达标",
"price": "面议"
},
{
"name": "自动开箱折盖封箱一体机",
"qty": "1套",
"image": "https://dscache.tencent-cloud.cn/upload/nodir/case-sealer.png",
"desc": "纸箱自动成型封底、装箱后折盖封箱,后端自动化装箱配套",
"price": "面议"
},
{
"name": "机器人码垛单元",
"qty": "1套",
"image": "https://dscache.tencent-cloud.cn/upload/nodir/robot-palletizer.png",
"desc": "抓取成品纸箱自动码垛堆托,解放后端人工搬运堆叠",
"price": "面议"
},
{
"name": "全线不锈钢输送过渡机架+电控总控制柜",
"qty": "1套",
"image": "https://dscache.tencent-cloud.cn/upload/nodir/line-conveyor-total.png",
"desc": "各设备接驳输送线、整机联动PLC总控制系统、急停、报警、联动互锁整套电气配套",
"price": "面议"
}
]
}

View File

@@ -0,0 +1,11 @@
{
"name": "标准罐装线模板",
"description": "默认模板,适用于大多数罐装线报价方案",
"image_fields": {
"layout_image": {"width_mm": 160, "scope": "root"},
"equipments[].images[]": {"width_mm": 120, "scope": "list"},
"quote_items[].image": {"width_mm": 30, "scope": "list"}
},
"required": ["project_title", "contact_person", "contact_phone", "requirements"],
"optional_sections": ["show_layout"]
}

Some files were not shown because too many files have changed in this diff Show More