Compare commits

2 Commits

Author SHA1 Message Date
635313a7ab ```
feat(lzwcai-agile-db): 更新版本至0.4.4并优化数据库管理技能文档

- 更新版本号从0.4.2到0.4.4
- 优化API密钥权限管理说明,明确grant_api_key_permissions仅支持追加不支持撤销
- 新增add_sql_tool_to_datasource工具,提供一键创建SQL工具功能
- 调整create_sql_tool说明,强调需技能已存在
- 强化数据写操作安全机制,插入/更新/删除前必须预览并等待用户确认
- 完善导入数据功能说明,详细解释confirm_import_data参数传递方式
- 补充技能与工具管理流程,提供更清晰的操作指引
- 新增数字员工平台数据库技能配置指南文档
```
2026-06-26 16:21:41 +08:00
ba5cd4bbe1 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中的工具数量统计和功能描述
2026-06-17 14:40:43 +08:00
119 changed files with 10521 additions and 731 deletions

Binary file not shown.

View File

@@ -1,7 +1,8 @@
---
name: lzwcai-agile-db
description: AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据库操作工作流指导,适合零基础用户。
version: 0.2.0
metadata:
version: 0.4.4
---
# lzwcai-agile-db
@@ -9,33 +10,56 @@ version: 0.2.0
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` | 获取数据源列表 | 安全 |
| `get_datasource_detail` | 获取数据源详情(含数据库、表结构) | 安全 |
| `create_datasource` | 创建外部数据源连接 | 安全 |
| `create_datasource` | 创建外部数据源连接(可选先测连接) | 安全 |
| `update_datasource` | 更新数据源连接信息 | 中等 |
| `toggle_datasource_status` | 启用/停用数据源 | 中等 |
| `delete_datasource` | 删除数据源 | **危险** |
| `test_connection` | 测试外部数据库连接(不创建) | 安全 |
| `get_realtime_structure` | 实时探测远端库表结构 | 安全 |
| `create_builtin_datasource` | 创建内置 PostgreSQL 连接(建库表链路第一步) | 安全 |
| `update_builtin_datasource` | 修改内置 PostgreSQL 连接 | 中等 |
### 二、数据库与表管理(6 个工具)
### 二、数据库与表管理(9 个工具)
| 工具 | 功能 | 危险等级 |
|------|------|----------|
| `list_databases` | 获取数据源下的数据库列表 | 安全 |
| `list_tables` | 获取数据源下的表列表 | 安全 |
| `list_tables` | 获取数据源下的表列表(基础) | 安全 |
| `list_tables_with_ai` | 获取表元数据列表(含 AI 训练描述,内置库主列表) | 安全 |
| `get_table_detail` | 获取表结构详情(字段、类型、主键) | 安全 |
| `create_table` | 创建新表 | **危险** |
| `alter_table` | 修改表结构 | **危险** |
| `generate_table_by_description` | AI 根据自然语言生成表结构 | 安全(仅生成,不创建) |
| `create_database` | 在内置源下创建数据库(建表前置) | **危险** |
| `create_table` | 在指定库创建新表 | **危险** |
| `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` | 删除数据行(按主键) | **危险** |
| `export_table_excel` | 导出表数据为 Excelbase64 | 安全 |
### 、SQL 执行1 个工具)
### 、SQL 执行1 个工具)
| 工具 | 功能 | 危险等级 |
|------|------|----------|
| `execute_sql` | 执行原生 SQL 查询 | 中等/危险 |
| `execute_sql` | 执行原生 SQL 查询(入参 `sql`target 默认 prod | 中等/危险 |
### 、数据导入2 个工具)
### 、数据导入2 个工具)
| 工具 | 功能 | 危险等级 |
|------|------|----------|
| `preview_import_data` | 上传 Excel 文件AI 智能识别并预览 | 安全 |
| `confirm_import_data` | 确认导入 AI 识别后的数据 | **危险** |
| `preview_import_data` | 从 URL 下载 Excel 文件AI 智能识别并预览 | 安全 |
| `confirm_import_data` | 确认导入(需传 databaseName自动组装结构 | **危险** |
### 、表订阅1 个工具)
### 、表订阅1 个工具)
| 工具 | 功能 | 危险等级 |
|------|------|----------|
| `toggle_table_subscription` | 切换表的订阅状态 | 中等 |
| `toggle_table_subscription` | 切换表的订阅状态configId+tableName+subscribe | 中等 |
### 、API 密钥管理(6 个工具)
### 、API 密钥管理(7 个工具)
| 工具 | 功能 | 危险等级 |
|------|------|----------|
@@ -73,18 +97,40 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
| `toggle_api_key_status` | 启用/禁用 API 密钥 | 中等 |
| `delete_api_key` | 删除 API 密钥 | **危险** |
| `get_api_key_permissions` | 查看指定密钥的权限配置 | 安全 |
| `grant_api_key_permissions` | 批量为 API 密钥授予权限 | **危险** |
| `grant_api_key_permissions` | 批量为 API 密钥授予权限(仅追加,不可撤销) | **危险** |
### 、技能与工具管理(6 个工具)
### 、技能与工具管理(7 个工具)
| 工具 | 功能 | 危险等级 |
|------|------|----------|
| `add_sql_tool_to_datasource` | 把 SQL 沉淀为工具(一步到位,自动建技能+配模板+建工具,去重幂等)**唯一推荐入口** | 中等 |
| `get_skill_by_datasource` | 根据数据源获取技能信息 | 安全 |
| `get_skill_tools` | 获取技能下的工具列表 | 安全 |
| `create_skill` | 为数据源创建技能 | 中等 |
| `create_sql_tool` | 将 SQL 查询创建为可复用工具 | 中等 |
| `create_sql_tool` | 将 SQL 创建为可复用工具(底层积木,需技能已存在) | 中等 |
| `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 +149,11 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
### 环境参数 `target`
- `prod` = 生产环境(正式数据,默认值
- `test` = 测试环境(试数据)
- `prod` = 生产环境(正式数据)
- `test` = 测试环境(试数据)
> **重要**:表数据 CRUDquery/insert/update/delete/export的 `target` **默认 `test`**(安全优先)。要操作正式数据必须**显式传 `target="prod"`**。
> 例外:`execute_sql` 的 target 默认 `prod`(沿用原行为)。
### 主键 `primaryKey`
@@ -120,20 +169,37 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
## ⚠️ 安全确认原则(必须遵守)
### 〇、目标资源不得擅自选择(最高优先级铁律)
定位操作目标的参数(`datasourceId` / `connectionId` / `databaseName` / `tableId` / `tableName` / `apiKeyId` / `skillId` 等),**永远不得猜测、编造或"随手挑一个"**
- **多个候选** → 列出全部,让用户选;严禁默认第一个或自认为"最合理"的那个。
- **唯一候选** → 先声明"我将使用 XXX",给用户否决机会;创建/写入/删除类仍需用户确认后执行。
- **没有候选 / 信息不足** → 直接问用户,不得编造。
> 当你发现自己正在"替用户决定用哪个数据源/数据库/表"时,立刻停下来改成提问。
**创建类操作**`create_table` / `create_datasource` 等)执行前必须逐项确认落点,绝不"随便找个数据源就建"。例如建表须确认:数据源(`connectionId`+ 数据库(`databaseName`+ 表名(`tableName`),三者都要用户点头。
---
### 一、执行工具前的风险评估
当执行以下类型的操作时,**必须先询问用户确认**,不得擅作主张:
1. **删除操作**:删除数据源、删除表数据、删除 API 密钥、删除技能工具
1. **数据写操作(增 / 删 / 改)**:插入数据(`insert_table_row`)、更新数据(`update_table_row`)、删除数据(`delete_table_rows`)、导入数据(`confirm_import_data`
- 说明:写操作会改变库中数据,**执行前必须把将要写入/修改/删除的具体内容预览给用户,等待用户明确确认后才执行**;删除不可恢复
2. **删除操作**:删除数据源、删除表数据、删除 API 密钥、删除技能工具等
- 说明:此操作不可恢复,数据将永久丢失
2. **泄密风险操作**:导出包含敏感数据的表、创建 API 密钥、查看密钥详情等
3. **泄密风险操作**:导出包含敏感数据的表、创建 API 密钥、查看密钥详情等
- 说明:可能导致敏感信息泄露,需确认用户授权
3. **政治敏感操作**:涉及政治相关数据的查询、修改、删除等
4. **政治敏感操作**:涉及政治相关数据的查询、修改、删除等
- 说明:可能涉及合规风险,需确认用户意图
4. **疑似违规内容**:涉及色情、暴力、违法等内容的操作
5. **疑似违规内容**:涉及色情、暴力、违法等内容的操作
- 说明:可能违反法律法规,必须拒绝执行并告知用户
#### 确认格式
@@ -294,26 +360,36 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
**用户**: "查一下 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": [{"id": 1, "username": "admin", "email": "admin@test.com"}, ...],
"data": {
"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
}
}
回复: users 表共 156 条记录,当前显示第 1-10 条:
| id | username | email | created_at |
|----|----------|-----------------|---------------------|
| 1 | admin | admin@test.com | 2024-01-15 10:30:00 |
| 2 | user1 | user1@test.com | 2024-01-16 14:20:00 |
| id | username | email |
|----|----------|-----------------|
| 1 | admin | admin@test.com |
| 2 | user1 | user1@test.com |
...
```
> **返回结构注意**:列定义在 `data.tables.columns[]`(字段名 `columnName`、是否主键 `isPrimaryKey`),行数据在 `data.content[]` 且是**位置数组**(按列顺序,不是键值对象)。展示/取主键时要把 content 的每个位置对到 columns 的同序字段。
### 注意事项
- `target` 参数:`prod`=生产环境(默认),`test`=测试环境
- `target` 参数:`test`=测试环境(**默认**`prod`=生产环境(查正式数据须显式传)
- 如果用户未指定数量,默认 `pageSize=10`
- 如果用户说"翻页",增加 `pageNum` 参数
- 如果用户想看更多数据,可以增大 `pageSize`(最大根据 API 限制)
- 数据返回格式为对象数组
- 数据是位置数组(`data.content[]`),需对照 `data.tables.columns[]` 的列顺序解析
---
@@ -329,7 +405,7 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
1. 如果用户直接提供 SQL直接使用
如果用户提供自然语言需求,先转换为 SQL
2. 调用 execute_sql(datasourceId="xx", executableSql="SELECT ...")
2. 调用 execute_sql(datasourceId="xx", sql="SELECT ...")
3. 展示查询结果
@@ -345,7 +421,7 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
调用: execute_sql(
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"}],
@@ -362,7 +438,8 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
### 注意事项
- `datasourceId` 必须提供,如果用户未指定,先询问
- `sqlTemplate` 可选,用于模板化查询
- `parameters` 可选,用于参数化查询(防止 SQL 注入)
- `params` 可选(对象),用于参数化查询(防止 SQL 注入);工具内部映射为后端的 `parameters`
- `target` 可选:`test`=测试环境(默认),`prod`=生产环境
- **执行危险操作DELETE/DROP/TRUNCATE必须向用户确认**
- 如果查询结果超过 100 行,建议用户使用 `query_table_data` 代替
- 对于只读查询SELECT可以直接执行对于写操作INSERT/UPDATE/DELETE必须二次确认
@@ -373,6 +450,8 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
当用户需要修改表中的数据时,使用此流程。
> 🔒 **写操作统一规则(插入 / 更新 / 删除都适用)**:在调用 `insert_table_row` / `update_table_row` / `delete_table_rows` 之前,**必须先把将要写入/修改/删除的具体数据预览给用户,并等待用户明确确认后才执行**。不得在用户未确认的情况下直接落库。
### 4.1 插入数据
```
@@ -382,9 +461,11 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
2. 确认必填字段(非空字段、无默认值字段)
3. 调用 insert_table_row(tableId="xx", data={...})
3. 向用户展示将要插入的数据,询问"确认插入以上数据?",等待用户确认
4. 确认插入成功
4. 用户确认后,调用 insert_table_row(tableId="xx", data={...})
5. 确认插入成功
```
**示例**:
@@ -395,6 +476,12 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
调用: get_table_detail(tableId="5")
返回: {columns: [{name: "id", isPrimaryKey: true, isAutoIncrement: true}, {name: "username", isNullable: false}, ...]}
回复: 即将向 users 表插入以下数据:
username = test_user, email = test@test.com
id 自动生成)。确认插入吗?
用户: "确认"
调用: insert_table_row(
tableId="5",
data={"username": "test_user", "email": "test@test.com"}
@@ -409,9 +496,11 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
1. 确认主键字段和要更新的值
2. 调用 update_table_row(tableId="xx", primaryKey={主键}, data={要更新的字段})
2. 向用户展示「目标行 + 改动前→改动后」的预览,询问"确认更新?",等待用户确认
3. 确认更新成功
3. 用户确认后,调用 update_table_row(tableId="xx", primaryKey={主键}, data={要更新的字段})
4. 确认更新成功
```
**示例**:
@@ -419,6 +508,12 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
**用户**: "把 ID 为 5 的用户邮箱改成 new@test.com"
```
回复: 即将更新 users 表 ID=5 的记录:
email: old@test.com → new@test.com
确认更新吗?
用户: "确认"
调用: update_table_row(
tableId="5",
primaryKey={"id": 5},
@@ -491,13 +586,13 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
```
### 注意事项
- **删除操作必须二次确认**
- **写操作(插入/更新/删除)执行前必须先预览数据并等待用户确认**;删除操作不可恢复,需格外谨慎
- `primaryKey` 必须是对象格式,如 `{"id": 1}`
- `primaryKeys` 是数组格式,如 `[{"id": 1}, {"id": 2}]`
- `data` 只包含要更新的字段,不需要提供全部字段
- 插入数据时,自增主键不需要提供
- 如果操作涉及多行,使用批量操作或循环调用
- 增删改操作默认作用于 `prod` 环境
- 增删改操作 `target` **默认 `test`**(安全优先);要写正式数据必须显式传 `target="prod"`
---
@@ -511,12 +606,20 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
用户请求: "我需要一个用户表" / "帮我设计一个订单系统的表结构"
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 +630,34 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
调用: generate_table_by_description(
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",
"tableComment": "商品表",
"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": "price", "columnType": "DECIMAL", "columnLength": 10, "columnComment": "价格"},
{"columnName": "stock", "columnType": "INTEGER", "columnComment": "库存数量"},
{"columnName": "category_id", "columnType": "INTEGER", "columnComment": "分类ID"},
{"columnName": "created_at", "columnType": "TIMESTAMP", "defaultValue": "NOW()", "columnComment": "创建时间"}
{"columnName": "stock", "columnType": "INTEGER", "columnComment": "库存数量"}
]
}]}}
}
}
回复: AI 已生成表结构设计:
表名: products (商品表)
字段:
- id (SERIAL, 主键, 自增) - 主键ID
- id (SERIAL, 主键) - 主键ID
- name (VARCHAR(200), 非空) - 商品名称
- price (DECIMAL(10)) - 价格
- stock (INTEGER) - 库存数量
- category_id (INTEGER) - 分类ID
- created_at (TIMESTAMP, 默认NOW()) - 创建时间
是否需要在此基础上修改或直接创建?
@@ -559,7 +668,7 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
databaseName="order_db",
tableName="products",
tableComment="商品表",
columns=[...] // 使用 AI 生成的 columns
columns=[...] // 使用轮询拿到的 createTableData.data.tables[0].columns
)
回复: 已成功创建表 products (商品表)
```
@@ -580,15 +689,13 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
4. 确认修改成功
```
**可用的 operations 类型**
**可用的 operations 类型(真机验证,后端仅支持这 3 种)**
- `ADD_COLUMN`:添加字段
- `MODIFY_COLUMN`:修改字段(改类型/长度/可空/默认值/注释,整列重定义)
- `DROP_COLUMN`:删除字段
- `RENAME_COLUMN`:重命名字段
- `ALTER_COLUMN_TYPE`:修改字段类型
- `SET_NOT_NULL`:设置为非空
- `DROP_NOT_NULL`:取消非空约束
- `SET_DEFAULT`:设置默认值
- `DROP_DEFAULT`:删除默认值
> ⚠️ 后端**不支持** `RENAME_COLUMN`/`ALTER_COLUMN_TYPE`/`SET_NOT_NULL`/`DROP_NOT_NULL`/`SET_DEFAULT`/`DROP_DEFAULT`(会报「不支持的操作类型」)。改字段类型/约束统一用 `MODIFY_COLUMN` 传完整列定义。
> `operations[].column` 是列定义对象字段同建表columnName/columnType/columnLength/isNullable/columnComment/defaultValue 等)。
### 注意事项
- `requirement` 参数应尽可能详细,包含业务场景和字段需求
@@ -608,9 +715,9 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
```
用户请求: "帮我导入这个 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 识别的表结构和数据预览
@@ -618,17 +725,63 @@ 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 插入行数)
```
### confirm_import_data 的 data 传什么
`preview_import_data` 返回的 data 原文整块传给 `data` 参数即可工具会自动解包组装。data 的标准形态(= preview 返回):
```json
{
"tableStructure": {
"success": true,
"message": "Excel表结构生成成功",
"data": {
"tables": [
{
"tableName": "animals",
"tableComment": "宠物信息表",
"columns": [
{ "columnName": "id", "columnType": "SERIAL", "isPrimaryKey": true, "isAdditionField": true },
{ "columnName": "animal_name", "columnType": "VARCHAR", "columnLength": 5000 }
]
}
]
},
"allData": [
["id", "animal_name", "..."],
["1", "豆豆", "..."],
["2", "咪咪", "..."]
]
},
"databaseName": "pp_test",
"target": "prod"
}
```
- `tableStructure`preview 的表结构包装(`{success, message, data:{tables:[...]}}`),工具会取 `tables[0]` 当单表对象,并把 `databaseName` 塞进去。
- `databaseName` / `target` 既可放顶层参数,也可放在 `data` 里,工具都认。
- ⚠️ **allData 是二维数组,首行必须是「列名表头行」**
- `allData[0]` = 各列的 `columnName`(列名表头),**真实数据从 `allData[1]` 起**
- 每行(含表头行)都是按 `columns` 顺序排列的位置数组,**行宽 = 列总数**(含 `SERIAL` 主键等所有列,**不裁剪**,主键列给占位值即可,后端自增时会忽略);
- 若传入的 allData 没带表头行(首行就是数据),工具会**据列名自动补一行表头**——否则后端会把首行数据当成字段名,报「查询字段不存在/字段名称不正确」。
- ⚠️ **columns / 表头列名必须对应目标表真实存在的字段**:后端按列名拼 INSERT`tableStructure.columns`(以及 allData 首行表头)里的列名必须是目标表里**确实存在的字段名**,否则报「查询字段不存在 / 字段名称不正确」。
- **导入到已有表时**:不要直接用 Excel 识别出的列,应**先 `get_table_detail(tableId="xx")` 拿到目标表真实字段,再把 data 里的 columns、allData 首行表头、各行取值都对齐到这些真实字段**(前端就是用目标表真实列覆盖 AI 识别列的)。
### 注意事项
- 文件大小限制:< 500KB
- 支持格式:.xlsx / .xls
- 导入前默认使用 `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 字段」,此时需按预览返回的列名核对
- 后端报「插入数据失败第N行查询字段不存在/字段名称不正确」时,多半是 **allData 缺了列名表头行**(后端把首行数据当成了字段名),或某行列数与 `columns` 对不上——确认首行是列名、每行值个数 = 列总数
---
@@ -707,11 +860,55 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
- `database`:数据库级别权限
- `table`:表级别权限
> ⚠️ 权限为「仅追加」模型:`grant_api_key_permissions` 只能新增权限,不会覆盖已有权限,且后端**不支持撤销/删除已授予的权限**。授权前务必确认范围,授错只能删掉整个密钥(`delete_api_key`)后重建。
### 7.7 调整权限(只能重建密钥)
```
# 后端不支持撤销单条权限。如需收回某密钥的权限,只能删除密钥后重新创建并重新授权:
调用: delete_api_key(id="7")
调用: create_api_key(apiKeyName="xxx")
调用: grant_api_key_permissions(apiKeyId="<新密钥ID>", batchDatas=[...])
```
> 说明:后端 permission 删除接口返回「不支持当前的调用方式」,无法撤销单条已授予权限。要缩小权限范围,走「删密钥 → 重建 → 重新授权」。
---
## 场景 8技能与工具管理
当用户需要创建和管理自定义技能时,使用此流程。
当用户需要把 SQL 沉淀为数据源的可复用工具时,使用此流程。
> 🔒 **核心约束:技能(skill)必须挂着工具才有效**。后端/前端都没有「只建空技能」这个动作——单独建技能会留下一个无效的空技能。前端唯一入口是「添加工具」,它会按需把技能建好、配好,最后必定以创建工具收尾。
### 8.0 一步到位:把 SQL 沉淀为工具(推荐)
**优先用 `add_sql_tool_to_datasource`**,它一步完成整条链路,保证技能必有工具:
```
调用: add_sql_tool_to_datasource(
datasourceId="58",
name="查询活跃用户",
businessDescription="查询所有状态为活跃的用户",
sqlTemplate="SELECT * FROM users WHERE status = #{status}",
sqlParams={"type":"object","required":["status"],"properties":{"status":{"type":"string","description":"用户状态","examples":["active"]}}}, // 可选
resultType="list", // 可选,默认 list
businessScenario="用于查看当前活跃用户列表", // 可选
tableIds=["5"] // 可选
)
```
> **sqlParams 格式**:标准 JSON Schema形如 `{"type":"object","required":[...],"properties":{"参数名":{"type":"...","description":"...","examples":[...]}}}`。`properties` 的键要与 SQL 模板里的 `#{参数名}` 占位符一一对应。传 dict 会自动序列化为 JSON 字符串;无参数可传 `{}`。
内部流程(= 前端 handleAddToolSubmit
```
读 skillBoolGET /datasource/config/{id}
├─ 技能已存在 → getByDatasource 拿 skillId → 按 sqlTemplate 去重 → confirmTools 建工具
└─ 技能不存在 → createOrGet 建技能 → getByDatasource 拿 skillId → 去重
→ updateOrGet 写 lzwcai-mcp-sqlexecutor 配置模板 → confirmTools 建工具
```
- **幂等**:同 datasourceId 下若已有 `sqlTemplate` 相同的工具(空白归一化后比较),返回 `skipped`,不重复创建。
- **skillId 来自 getByDatasource**,不是 createOrGet 的返回(后端 createOrGet 不回可靠 id
### 8.1 查看数据源关联的技能
@@ -719,21 +916,23 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
调用: get_skill_by_datasource(datasourceId="58")
```
### 8.2 创建技能
```
调用: create_skill(datasourceId="58", name="订单查询技能", description="用于订单数据的常用查询")
```
### 8.3 查看技能下的工具
```
调用: get_skill_tools(skillId="xx")
```
### 8.4 将 SQL 创建为可复用工具
---
### 分步操作(高级,一般不需要)
> ⚠️ **不要单独建空技能**:技能必须挂着工具才有效,平时不会单独创建技能。常规「把 SQL 沉淀为工具」一律用 `add_sql_tool_to_datasource`(它会按需建技能+配模板+建工具)。下面的散工具仅用于「技能已存在」时单独加/改工具。
### 8.4 向已有技能添加 SQL 工具(底层积木,需技能已存在)
```
# 前提:技能已存在(用 get_skill_by_datasource 拿到 skillId
# 若技能不存在,请直接用 add_sql_tool_to_datasource别手动建空技能
调用: create_sql_tool(
skillId="xx",
tableIds=["5"],
@@ -741,7 +940,7 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
"name": "查询活跃用户",
"businessDescription": "查询所有状态为活跃的用户",
"sqlTemplate": "SELECT * FROM users WHERE status = #{status}",
"sqlParams": {"status": {"type": "string", "default": "active"}},
"sqlParams": {"type":"object","required":["status"],"properties":{"status":{"type":"string","description":"用户状态","examples":["active"]}}},
"resultType": "list",
"businessScenario": "用于查看当前活跃用户列表"
}]
@@ -762,12 +961,38 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
### 8.6 更新技能配置
```
# datasourceId + skillId 均必填(真实 ID来自其他工具返回不可臆造
# 只给这两个 ID 即自动生成 lzwcai-mcp-sqlexecutor 标准配置模板
调用: update_skill_config(
datasourceId="58", // 来自 list_databases / list_tables_with_ai / get_connection_config_list
skillId="xx" // 来自 get_skill_by_datasource 返回
)
# 或:手动指定完整 configTemplate覆盖自动生成
调用: update_skill_config(
datasourceId="58",
configTemplate='{"mcpServer": "..."}' // JSON 字符串
skillId="xx",
configTemplate='{"mcpServers": {...}}' // JSON 字符串
)
```
> 说明configTemplate 大部分是固定值,只有 mcpServerKey 后缀、`env.databaseId`、`env.skillId` 随 datasourceId / skillId 变化。两个 ID 都必填且必须是其他工具返回的真实值。不显式传 configTemplate 时,工具会按标准模板自动生成(与前端 SqlControllerMsg.vue 一致),无需手写整段 JSON。
### 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表订阅管理
@@ -778,13 +1003,117 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
调用: toggle_table_subscription(
configId="数据库配置ID",
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. 安全第一
@@ -839,8 +1168,8 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
- `1` - 已停止
### 环境
- `prod` - 生产环境(默认
- `test` - 测试环境
- `test` - 测试环境(**表数据 CRUD 的默认值**,安全优先
- `prod` - 生产环境(操作正式数据须显式传 `target="prod"`;仅 `execute_sql` 默认 prod
### 常用字段类型
| 类型 | 用途 | 示例 |
@@ -854,16 +1183,13 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
| `BOOLEAN` | 布尔值 | `is_active BOOLEAN` |
### 表结构变更操作类型alter_table
| 类型 | 用途 |
|------|------|
| `ADD_COLUMN` | 添加字段 |
| `DROP_COLUMN` | 删除字段 |
| `RENAME_COLUMN` | 重命名字段 |
| `ALTER_COLUMN_TYPE` | 修改字段类型 |
| `SET_NOT_NULL` | 设置为非空 |
| `DROP_NOT_NULL` | 取消非空约束 |
| `SET_DEFAULT` | 设置默认值 |
| `DROP_DEFAULT` | 删除默认值 |
| 类型 | 用途 | column 对象关键字段 |
|------|------|------|
| `ADD_COLUMN` | 添加字段 | columnName, columnType, columnLength, isNullable, columnComment |
| `MODIFY_COLUMN` | 修改字段(类型/长度/约束等,含改类型) | columnName, columnType, columnLength, ... |
| `DROP_COLUMN` | 删除字段 | columnName |
> 后端**只支持上述 3 种**。`RENAME_COLUMN`/`ALTER_COLUMN_TYPE`/`SET_NOT_NULL`/`DROP_NOT_NULL`/`SET_DEFAULT`/`DROP_DEFAULT` 均会报「不支持的操作类型」(已真机验证)。改字段类型用 `MODIFY_COLUMN`。
### 权限级别API 密钥授权)
| 级别 | 范围 |
@@ -879,7 +1205,7 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
如果用户说"帮我查一下数据库",按以下步骤操作:
1. 调用 `list_datasources()` 获取数据源列表
2. 展示列表,让用户选择或默认第一个运行中的数据源
2. 展示列表,让用户选择目标数据源(**多个候选时必须让用户选,不得默认第一个**;只有一个候选也要先说明"我将使用 XXX"再继续)
3. 调用 `get_datasource_detail(datasourceId="xx")` 获取数据库和表信息
4. 引导用户选择要操作的表
5. 根据用户意图调用相应的工具:

Binary file not shown.

View File

@@ -0,0 +1,343 @@
# 数字员工平台数据库技能配置指南
## 概述
本文档介绍两种使用数字员工平台数据库技能的方法:
- **方法一**:通过数字员工对话直接使用
- **方法二**:通过 AI 编辑器的 Skills + MCP 搭配使用
---
## 方法一:通过数字员工对话使用
### 配置说明
此方法通过配置 MCP Server 来连接数字员工平台。
### MCP 配置示例
```json
{
"mcpServers": {
"lzwcai-mcp-agile-db": {
"command": "uvx",
"type": "stdio",
"args": [
"lzwcai-mcp-agile-db"
],
"timeout": 600,
"env": {
"API_KEY": "eyJhbGciOiJIUzUxMiJ9.eyJ0b2tlbl90eXBlIjoiTE9HSU4iLCJsb2dpbl91c2VyX2tleSI6IjljMDllMjZhLWFkNzgtNGNmMi05YzQ5LWQzY2Y5NjI5MGRjYyJ9.v5ZffkvM2CnMkoWqc-Xy-0gN2oLBjyfJlm_YnCE6TWBwfKVmpuakoRXFMTiHQG8LOphv7JlEmZHcYpzwM52D0Q"
}
}
}
}
```
### 注意事项
- 配置中的 `API_KEY` 是数字员工平台的密钥
- 存在单点登录问题,请使用不会过期的账号密钥
- Skills 配置参考:`lzwcai-agile-db`
---
## 方法二:通过 AI 编辑器使用
### 配置说明
此方法通过下载 Skills 和 MCP 配置,在 AI 编辑器中搭配使用,支持直接对话操作。
### MCP 配置示例
```json
{
"mcpServers": {
"lzwcai_mcp_agile_db": {
"command": "uvx",
"args": [
"lzwcai-mcp-agile-db"
],
"env": {
"UV_INDEX_URL": "http://192.168.2.236:3141/lzwc/dev/+simple/",
"API_KEY": "eyJhbGciOiJIUzUxMiJ9.eyJ0b2tlbl90eXBlIjoiTE9HSU4iLCJsb2dpbl91c2VyX2tleSI6IjljMDllMjZhLWFkNzgtNGNmMi05YzQ5LWQzY2Y5NjI5MGRjYyJ9.v5ZffkvM2CnMkoWqc-Xy-0gN2oLBjyfJlm_YnCE6TWBwfKVmpuakoRXFMTiHQG8LOphv7JlEmZHcYpzwM52D0Q",
"backendBaseUrl": "http://192.168.2.236:8088"
}
}
}
}
```
### 环境变量说明
| 变量名 | 说明 | 示例值 |
|--------|------|--------|
| `UV_INDEX_URL` | Python 包索引地址 | `http://192.168.2.236:3141/lzwc/dev/+simple/` |
| `API_KEY` | 数字员工平台 API 密钥 | (见配置示例) |
| `backendBaseUrl` | 后端服务基础地址 | `http://192.168.2.236:8088` |
---
## 使用建议
1. **选择方法**:根据实际使用场景选择合适的方法
- 数字员工对话:适合快速使用
- AI 编辑器:适合更灵活的配置和使用
2. **密钥管理**:妥善保管 `API_KEY`,避免泄露
3. **网络配置**:确保网络可以访问配置的后端地址
---
## 相关资源
- Skills 目录:`lzwcai-agile-db/`
- 详细技能文档:`lzwcai-agile-db/SKILL.md`
---
## 对话使用场景示例
以下是配置好技能后,您可以直接使用的对话示例:
### 📊 场景 1浏览数据源和表
**用户:** "帮我看看有哪些数据源"
**AI 回复:**
```
共找到 3 个数据源:
1. HMD产品PostgreSQL, 运行中)
2. 测试数据库MySQL, 运行中)
3. 历史数据Oracle, 已停止)
请告诉我您想看哪个数据源?
```
---
**用户:** "看看HMD产品有哪些表"
**AI 回复:**
```
HMD产品 数据源包含以下数据库和表:
order_db:
- orders (订单表, 15 个字段, 2568 条记录)
- users (用户表, 8 个字段, 156 条记录)
- products (商品表, 12 个字段, 892 条记录)
analytics_db:
- daily_stats (日报表, 10 个字段, 365 条记录)
```
---
### 🔍 场景 2查询表数据
**用户:** "查一下users表前10条数据"
**AI 回复:**
```
users 表共 156 条记录,当前显示第 1-10 条:
| id | username | email | status | created_at |
|----|----------|-----------------|--------|---------------------|
| 1 | admin | admin@test.com | active | 2024-01-01 00:00:00 |
| 2 | user1 | user1@test.com | active | 2024-01-02 10:30:00 |
| 3 | user2 | user2@test.com | inactive| 2024-01-03 15:45:00 |
...
注:默认查询测试环境,如需查询生产环境请说明。
```
---
### 📝 场景 3执行 SQL 查询
**用户:** "统计每个地区的订单数量"
**AI 回复:**
```
各地区订单统计:
| 地区 | 订单数 |
|------|--------|
| 华东 | 1,250 |
| 华南 | 980 |
| 华北 | 756 |
| 西南 | 432 |
| 东北 | 289 |
```
---
### 场景 4插入数据
**用户:** "新增一个用户用户名是test_user邮箱test@test.com"
**AI 回复:**
```
即将向 users 表插入以下数据:
username = test_user
email = test@test.com
id 自动生成)
确认插入吗?
```
**用户:** "确认"
**AI 回复:**
```
✅ 已成功插入用户 test_user (ID: 157)
```
---
### ✏️ 场景 5更新数据
**用户:** "把ID为5的用户邮箱改成new@test.com"
**AI 回复:**
```
即将更新 users 表 ID=5 的记录:
email: old@test.com → new@test.com
确认更新吗?
```
**用户:** "确认"
**AI 回复:**
```
✅ 已更新用户 ID=5 的邮箱为 new@test.com
```
---
### 🗑️ 场景 6删除数据
**用户:** "删除ID为10的订单"
**AI 回复:**
```
⚠️ 安全提醒:此操作存在删除风险。
具体说明:数据将永久删除,不可恢复。
确认要删除以下记录吗?
订单 ID=10, 订单号=ORD-2024-0010, 金额=¥1,250.00
请回复"确认删除"继续。
```
**用户:** "确认删除"
**AI 回复:**
```
✅ 已成功删除订单 ID=10
```
---
### 🤖 场景 7AI 生成表结构
**用户:** "帮我设计一个商品表,包含名称、价格、库存、分类"
**AI 回复:**
```
AI 已生成表结构设计:
表名: products (商品表)
字段:
- id (SERIAL, 主键) - 主键ID
- name (VARCHAR(200), 非空) - 商品名称
- price (DECIMAL(10,2)) - 价格
- stock (INTEGER) - 库存数量
- category (VARCHAR(50)) - 分类
- created_at (TIMESTAMP) - 创建时间
是否需要在此基础上修改或直接创建?
```
**用户:** "直接创建到order_db数据库"
**AI 回复:**
```
✅ 已成功创建表 products (商品表)
```
---
### 📥 场景 8导入 Excel 数据
**用户:** "帮我导入这个Excel文件https://example.com/data.xlsx"
**AI 回复:**
```
AI 已识别 Excel 文件内容:
表名: customers (客户表)
字段:
- id (SERIAL, 主键)
- name (VARCHAR(100)) - 客户名称
- phone (VARCHAR(20)) - 电话
- address (TEXT) - 地址
预览数据前3条
| id | name | phone | address |
|----|--------|------------|----------------|
| 1 | 张三 | 13800138000| 北京市朝阳区 |
| 2 | 李四 | 13900139000| 上海市浦东新区 |
| 3 | 王五 | 13700137000| 广州市天河区 |
共 50 条数据待导入。
⚠️ 安全提醒:此操作可能涉及数据安全风险。
说明:导入的数据将写入数据库,请确认数据来源合法合规。
请确认是否继续?
```
**用户:** "继续"
**AI 回复:**
```
✅ 成功导入 50 条数据到 customers 表
```
---
### 🔑 场景 9API 密钥管理
**用户:** "帮我创建一个新的API密钥"
**AI 回复:**
```
请输入密钥名称:
```
**用户:** "测试密钥"
**AI 回复:**
```
✅ 已创建 API 密钥:
名称: 测试密钥
密钥: lzwc_sk_xxxxxxxxxxxx
状态: 启用
过期时间: 2025-06-18
请妥善保管密钥,丢失后无法找回。
```
---
## ⚠️ 重要使用提示
1. **环境选择:** 默认使用测试环境(`test`),操作生产环境需要明确说明"查询生产环境"或"操作生产环境"
2. **安全确认:** 所有写操作(增删改)都会先预览并等待您的确认
3. **多选原则:** 有多个数据源/数据库/表可选时AI会列出让您选择不会擅自做主
4. **分步执行:** 复杂任务会分步完成,每一步都会确认后再继续

View File

@@ -1,6 +1,6 @@
# lzwcai-mcp-agile-db
数据库管理平台 MCP Server提供 33 个工具用于数据库管理、表操作、数据 CRUD、API 密钥管理、技能与工具管理等。
数据库管理平台 MCP Server提供 34 个工具用于数据库管理、表操作、数据 CRUD、API 密钥管理、技能与工具管理等。
## 环境变量
@@ -57,15 +57,16 @@ lzwcai-mcp-agile-db
- `toggle_api_key_status` - 启用/禁用密钥
- `delete_api_key` - 删除密钥
- `get_api_key_permissions` - 查看密钥权限
- `grant_api_key_permissions` - 授予权限
- `grant_api_key_permissions` - 授予权限(仅追加,不可撤销)
### 技能与工具管理
- `add_sql_tool_to_datasource` - 把 SQL 沉淀为工具(一步到位,自动建技能+配模板+建工具,推荐入口)
- `get_skill_by_datasource` - 获取技能信息
- `get_skill_tools` - 获取技能工具列表
- `create_skill` - 创建技能
- `create_sql_tool` - 创建 SQL 工具
- `create_sql_tool` - 创建 SQL 工具(需技能已存在)
- `delete_skill_tool` - 删除技能工具
- `update_skill_config` - 更新技能配置
- `update_skill_tool` - 修改技能工具
### 数据导入
- `preview_import_data` - 预览导入数据

View File

@@ -1,202 +1,377 @@
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-11 09:30:49 - 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-11 09:30:49 - 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-11 09:30:49 - lzwcai_mcp_agile_db.server - INFO - [server.py:125] - lzwcai-mcp-agile-db MCP Server 启动
2026-06-11 09:30:49 - lzwcai_mcp_agile_db.server - INFO - [server.py:130] - 已注册工具数量: 33
2026-06-11 09:30:49 - lzwcai_mcp_agile_db.server - INFO - [server.py:131] - ==================================================
2026-06-11 09:30:49 - lzwcai_mcp_agile_db.server - INFO - [server.py:133] - 开始运行 MCP Server (stdio 模式)
2026-06-11 09:30:49 - asyncio - DEBUG - [proactor_events.py:634] - Using proactor: IocpProactor
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,
2026-06-22 23:02:48 - 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-22 23:02:48 - mcp.server.lowlevel.server - DEBUG - [server.py:162] - Initializing server 'lzwcai_mcp_agile_db'
2026-06-22 23:02:48 - mcp.server.lowlevel.server - DEBUG - [server.py:439] - Registering handler for ListToolsRequest
2026-06-22 23:02:48 - mcp.server.lowlevel.server - DEBUG - [server.py:519] - Registering handler for CallToolRequest
2026-06-22 23:02:48 - asyncio - DEBUG - [proactor_events.py:634] - Using proactor: IocpProactor
2026-06-22 23:02:48 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:73] - [客户端初始化] base_url=https://dempdemo.lzwcai.com/api, 认证方式=account:demp04
2026-06-22 23:02:48 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:118] - [登录] POST https://dempdemo.lzwcai.com/api/login, username=demp04, loginType=user
2026-06-22 23:02:48 - httpcore.connection - DEBUG - [_trace.py:87] - connect_tcp.started host='dempdemo.lzwcai.com' port=443 local_address=None timeout=30.0 socket_options=None
2026-06-22 23:02:49 - httpcore.connection - DEBUG - [_trace.py:87] - connect_tcp.complete return_value=<httpcore._backends.anyio.AnyIOStream object at 0x0000020F12F4F5C0>
2026-06-22 23:02:49 - httpcore.connection - DEBUG - [_trace.py:87] - start_tls.started ssl_context=<ssl.SSLContext object at 0x0000020F12F165D0> server_hostname='dempdemo.lzwcai.com' timeout=30.0
2026-06-22 23:02:49 - httpcore.connection - DEBUG - [_trace.py:87] - start_tls.complete return_value=<httpcore._backends.anyio.AnyIOStream object at 0x0000020F12DFD970>
2026-06-22 23:02:49 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_headers.started request=<Request [b'POST']>
2026-06-22 23:02:49 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_headers.complete
2026-06-22 23:02:49 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_body.started request=<Request [b'POST']>
2026-06-22 23:02:49 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_body.complete
2026-06-22 23:02:49 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_headers.started request=<Request [b'POST']>
2026-06-22 23:02:49 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'', [(b'Server', b'nginx/1.25.2'), (b'Date', b'Mon, 22 Jun 2026 15:02:58 GMT'), (b'Content-Type', b'application/json;charset=UTF-8'), (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'SAMEORIGIN'), (b'X-Content-Type-Options', b'nosniff'), (b'X-XSS-Protection', b'1; mode=block')])
2026-06-22 23:02:49 - httpx - INFO - [_client.py:1740] - HTTP Request: POST https://dempdemo.lzwcai.com/api/login "HTTP/1.1 200 "
2026-06-22 23:02:49 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_body.started request=<Request [b'POST']>
2026-06-22 23:02:49 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_body.complete
2026-06-22 23:02:49 - httpcore.http11 - DEBUG - [_trace.py:87] - response_closed.started
2026-06-22 23:02:49 - httpcore.http11 - DEBUG - [_trace.py:87] - response_closed.complete
2026-06-22 23:02:49 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:161] - [API响应] HTTP 200
2026-06-22 23:02:49 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:137] - [登录] 成功获取 token
2026-06-22 23:02:49 - httpcore.connection - DEBUG - [_trace.py:87] - close.started
2026-06-22 23:02:49 - httpcore.connection - DEBUG - [_trace.py:87] - close.complete
2026-06-23 09:47:20 - 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-23 09:47:20 - mcp.server.lowlevel.server - DEBUG - [server.py:162] - Initializing server 'lzwcai_mcp_agile_db'
2026-06-23 09:47:20 - mcp.server.lowlevel.server - DEBUG - [server.py:439] - Registering handler for ListToolsRequest
2026-06-23 09:47:20 - mcp.server.lowlevel.server - DEBUG - [server.py:519] - Registering handler for CallToolRequest
2026-06-23 09:47:20 - lzwcai_mcp_agile_db.server - INFO - [server.py:129] - ==================================================
2026-06-23 09:47:20 - lzwcai_mcp_agile_db.server - INFO - [server.py:130] - lzwcai-mcp-agile-db MCP Server 启动
2026-06-23 09:47:20 - lzwcai_mcp_agile_db.server - INFO - [server.py:135] - 已注册工具数量: 56
2026-06-23 09:47:20 - lzwcai_mcp_agile_db.server - INFO - [server.py:136] - ==================================================
2026-06-23 09:47:20 - lzwcai_mcp_agile_db.server - INFO - [server.py:138] - 开始运行 MCP Server (stdio 模式)
2026-06-23 09:47:20 - asyncio - DEBUG - [proactor_events.py:634] - Using proactor: IocpProactor
2026-06-23 09:47:20 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: root=InitializedNotification(method='notifications/initialized', params=None, jsonrpc='2.0')
2026-06-23 09:47:21 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: <mcp.shared.session.RequestResponder object at 0x000001FB167DF860>
2026-06-23 09:47:21 - mcp.server.lowlevel.server - INFO - [server.py:720] - Processing request of type ListToolsRequest
2026-06-23 09:47:21 - mcp.server.lowlevel.server - DEBUG - [server.py:723] - Dispatching request of type ListToolsRequest
2026-06-23 09:47:21 - lzwcai_mcp_agile_db.server - INFO - [server.py:42] - 收到 ListTools 请求
2026-06-23 09:47:21 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:72] - [客户端初始化] base_url=http://192.168.2.236:8088, 认证方式=account:yy8z9
2026-06-23 09:47:21 - lzwcai_mcp_agile_db.server - INFO - [server.py:56] - ListTools 响应: 返回 56 个工具
2026-06-23 09:47:21 - mcp.server.lowlevel.server - DEBUG - [server.py:790] - Response sent
2026-06-23 09:47:28 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: <mcp.shared.session.RequestResponder object at 0x000001FB156EB260>
2026-06-23 09:47:28 - mcp.server.lowlevel.server - INFO - [server.py:720] - Processing request of type CallToolRequest
2026-06-23 09:47:28 - mcp.server.lowlevel.server - DEBUG - [server.py:723] - Dispatching request of type CallToolRequest
2026-06-23 09:47:28 - lzwcai_mcp_agile_db.server - INFO - [server.py:66] - 收到 CallTool 请求: name=list_api_keys
2026-06-23 09:47:28 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:117] - [登录] POST http://192.168.2.236:8088/login, username=yy8z9, loginType=user
2026-06-23 09:47:28 - httpcore.connection - DEBUG - [_trace.py:87] - connect_tcp.started host='192.168.2.236' port=8088 local_address=None timeout=30.0 socket_options=None
2026-06-23 09:47:28 - httpcore.connection - DEBUG - [_trace.py:87] - connect_tcp.complete return_value=<httpcore._backends.anyio.AnyIOStream object at 0x000001FB16D14260>
2026-06-23 09:47:28 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_headers.started request=<Request [b'POST']>
2026-06-23 09:47:28 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_headers.complete
2026-06-23 09:47:28 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_body.started request=<Request [b'POST']>
2026-06-23 09:47:28 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_body.complete
2026-06-23 09:47:28 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_headers.started request=<Request [b'POST']>
2026-06-23 09:47:28 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'', [(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'Content-Type', b'application/json;charset=UTF-8'), (b'Transfer-Encoding', b'chunked'), (b'Date', b'Tue, 23 Jun 2026 01:47:27 GMT'), (b'Keep-Alive', b'timeout=60'), (b'Connection', b'keep-alive')])
2026-06-23 09:47:28 - httpx - INFO - [_client.py:1740] - HTTP Request: POST http://192.168.2.236:8088/login "HTTP/1.1 200 "
2026-06-23 09:47:28 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_body.started request=<Request [b'POST']>
2026-06-23 09:47:28 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_body.complete
2026-06-23 09:47:28 - httpcore.http11 - DEBUG - [_trace.py:87] - response_closed.started
2026-06-23 09:47:28 - httpcore.http11 - DEBUG - [_trace.py:87] - response_closed.complete
2026-06-23 09:47:28 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:160] - [API响应] HTTP 200
2026-06-23 09:47:28 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:136] - [登录] 成功获取 token
2026-06-23 09:47:28 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:216] - [API请求] GET http://192.168.2.236:8088/datasource/api_key/list
2026-06-23 09:47:28 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_headers.started request=<Request [b'GET']>
2026-06-23 09:47:28 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_headers.complete
2026-06-23 09:47:28 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_body.started request=<Request [b'GET']>
2026-06-23 09:47:28 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_body.complete
2026-06-23 09:47:28 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_headers.started request=<Request [b'GET']>
2026-06-23 09:47:28 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'', [(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'Content-Type', b'application/json'), (b'Transfer-Encoding', b'chunked'), (b'Date', b'Tue, 23 Jun 2026 01:47:27 GMT'), (b'Keep-Alive', b'timeout=60'), (b'Connection', b'keep-alive')])
2026-06-23 09:47:28 - httpx - INFO - [_client.py:1740] - HTTP Request: GET http://192.168.2.236:8088/datasource/api_key/list?pageNum=1&pageSize=20 "HTTP/1.1 200 "
2026-06-23 09:47:28 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_body.started request=<Request [b'GET']>
2026-06-23 09:47:28 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_body.complete
2026-06-23 09:47:28 - httpcore.http11 - DEBUG - [_trace.py:87] - response_closed.started
2026-06-23 09:47:28 - httpcore.http11 - DEBUG - [_trace.py:87] - response_closed.complete
2026-06-23 09:47:28 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:160] - [API响应] HTTP 200
2026-06-23 09:47:28 - lzwcai_mcp_agile_db.server - INFO - [server.py:86] - 工具执行成功: list_api_keys
2026-06-23 09:47:28 - lzwcai_mcp_agile_db.server - DEBUG - [server.py:87] - 工具返回结果: {
"total": 21,
"rows": [
{
"createBy": "",
"createTime": "2026-06-10 16:47:43",
"createTime": "2026-06-18 11:29:46",
"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",
"updateTime": "2026-06-18 11:59:25",
"remark": null,
"id": "7",
"apiKey": "Lb8LgEJ7eBUU8QMifKUJvo9w6YLAotbKJ-w1DKU8ZrU",
"apiKeyName": "AWINBEXT",
"enterpriseId": "1937166012193443842",
"id": "37",
"apiKey": "9fqiemn5nhWqTWeZoZnEGwlFgZIEPlFHjI3ucEz6fmY",
"apiKeyName": "盒马超市只读访问密钥",
"enterpriseId": "1932095424144715777",
"status": 0,
"expireTime": "2027-06-06T15:10:32.000+08:00"
"expireTime": "2027-06-18T11:59:25.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
"createTime": "2026-06-18 10:54:40",
...
2026-06-23 09:47:28 - mcp.server.lowlevel.server - DEBUG - [server.py:790] - Response sent
2026-06-23 09:47:33 - httpcore.connection - DEBUG - [_trace.py:87] - close.started
2026-06-23 09:47:33 - httpcore.connection - DEBUG - [_trace.py:87] - close.complete
2026-06-23 09:48:33 - 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-23 09:48:33 - mcp.server.lowlevel.server - DEBUG - [server.py:162] - Initializing server 'lzwcai_mcp_agile_db'
2026-06-23 09:48:33 - mcp.server.lowlevel.server - DEBUG - [server.py:439] - Registering handler for ListToolsRequest
2026-06-23 09:48:33 - mcp.server.lowlevel.server - DEBUG - [server.py:519] - Registering handler for CallToolRequest
2026-06-23 09:48:33 - lzwcai_mcp_agile_db.server - INFO - [server.py:129] - ==================================================
2026-06-23 09:48:33 - lzwcai_mcp_agile_db.server - INFO - [server.py:130] - lzwcai-mcp-agile-db MCP Server 启动
2026-06-23 09:48:33 - lzwcai_mcp_agile_db.server - INFO - [server.py:135] - 已注册工具数量: 56
2026-06-23 09:48:33 - lzwcai_mcp_agile_db.server - INFO - [server.py:136] - ==================================================
2026-06-23 09:48:33 - lzwcai_mcp_agile_db.server - INFO - [server.py:138] - 开始运行 MCP Server (stdio 模式)
2026-06-23 09:48:33 - asyncio - DEBUG - [proactor_events.py:634] - Using proactor: IocpProactor
2026-06-23 09:48:33 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: root=InitializedNotification(method='notifications/initialized', params=None, jsonrpc='2.0')
2026-06-23 09:48:34 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: <mcp.shared.session.RequestResponder object at 0x000001F89A81AAB0>
2026-06-23 09:48:34 - mcp.server.lowlevel.server - INFO - [server.py:720] - Processing request of type ListToolsRequest
2026-06-23 09:48:34 - mcp.server.lowlevel.server - DEBUG - [server.py:723] - Dispatching request of type ListToolsRequest
2026-06-23 09:48:34 - lzwcai_mcp_agile_db.server - INFO - [server.py:42] - 收到 ListTools 请求
2026-06-23 09:48:34 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:72] - [客户端初始化] base_url=http://192.168.2.236:8082/api, 认证方式=account:yy8z9
2026-06-23 09:48:34 - lzwcai_mcp_agile_db.server - INFO - [server.py:56] - ListTools 响应: 返回 56 个工具
2026-06-23 09:48:34 - mcp.server.lowlevel.server - DEBUG - [server.py:790] - Response sent
2026-06-23 09:48:36 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: <mcp.shared.session.RequestResponder object at 0x000001F89A273B00>
2026-06-23 09:48:36 - mcp.server.lowlevel.server - INFO - [server.py:720] - Processing request of type CallToolRequest
2026-06-23 09:48:36 - mcp.server.lowlevel.server - DEBUG - [server.py:723] - Dispatching request of type CallToolRequest
2026-06-23 09:48:36 - lzwcai_mcp_agile_db.server - INFO - [server.py:66] - 收到 CallTool 请求: name=list_api_keys
2026-06-23 09:48:36 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:117] - [登录] POST http://192.168.2.236:8082/api/login, username=yy8z9, loginType=user
2026-06-23 09:48:36 - httpcore.connection - DEBUG - [_trace.py:87] - connect_tcp.started host='192.168.2.236' port=8082 local_address=None timeout=30.0 socket_options=None
2026-06-23 09:48:36 - httpcore.connection - DEBUG - [_trace.py:87] - connect_tcp.complete return_value=<httpcore._backends.anyio.AnyIOStream object at 0x000001F89C2B8F20>
2026-06-23 09:48:36 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_headers.started request=<Request [b'POST']>
2026-06-23 09:48:36 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_headers.complete
2026-06-23 09:48:36 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_body.started request=<Request [b'POST']>
2026-06-23 09:48:36 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_body.complete
2026-06-23 09:48:36 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_headers.started request=<Request [b'POST']>
2026-06-23 09:48:36 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'', [(b'Server', b'nginx/1.25.2'), (b'Date', b'Tue, 23 Jun 2026 01:48:35 GMT'), (b'Content-Type', b'application/json;charset=UTF-8'), (b'Transfer-Encoding', b'chunked'), (b'Connection', b'keep-alive'), (b'Vary', b'Accept-Encoding'), (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'Content-Encoding', b'gzip')])
2026-06-23 09:48:36 - httpx - INFO - [_client.py:1740] - HTTP Request: POST http://192.168.2.236:8082/api/login "HTTP/1.1 200 "
2026-06-23 09:48:36 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_body.started request=<Request [b'POST']>
2026-06-23 09:48:36 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_body.complete
2026-06-23 09:48:36 - httpcore.http11 - DEBUG - [_trace.py:87] - response_closed.started
2026-06-23 09:48:36 - httpcore.http11 - DEBUG - [_trace.py:87] - response_closed.complete
2026-06-23 09:48:36 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:160] - [API响应] HTTP 200
2026-06-23 09:48:36 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:136] - [登录] 成功获取 token
2026-06-23 09:48:36 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:216] - [API请求] GET http://192.168.2.236:8082/api/datasource/api_key/list
2026-06-23 09:48:36 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_headers.started request=<Request [b'GET']>
2026-06-23 09:48:36 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_headers.complete
2026-06-23 09:48:36 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_body.started request=<Request [b'GET']>
2026-06-23 09:48:36 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_body.complete
2026-06-23 09:48:36 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_headers.started request=<Request [b'GET']>
2026-06-23 09:48:36 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'', [(b'Server', b'nginx/1.25.2'), (b'Date', b'Tue, 23 Jun 2026 01:48:35 GMT'), (b'Content-Type', b'application/json'), (b'Transfer-Encoding', b'chunked'), (b'Connection', b'keep-alive'), (b'Vary', b'Accept-Encoding'), (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'Content-Encoding', b'gzip')])
2026-06-23 09:48:36 - httpx - INFO - [_client.py:1740] - HTTP Request: GET http://192.168.2.236:8082/api/datasource/api_key/list?pageNum=1&pageSize=20 "HTTP/1.1 200 "
2026-06-23 09:48:36 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_body.started request=<Request [b'GET']>
2026-06-23 09:48:36 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_body.complete
2026-06-23 09:48:36 - httpcore.http11 - DEBUG - [_trace.py:87] - response_closed.started
2026-06-23 09:48:36 - httpcore.http11 - DEBUG - [_trace.py:87] - response_closed.complete
2026-06-23 09:48:36 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:160] - [API响应] HTTP 200
2026-06-23 09:48:36 - lzwcai_mcp_agile_db.server - INFO - [server.py:86] - 工具执行成功: list_api_keys
2026-06-23 09:48:36 - lzwcai_mcp_agile_db.server - DEBUG - [server.py:87] - 工具返回结果: {
"total": 21,
"rows": [
{
"createBy": "",
"createTime": "2026-06-18 11:29:46",
"updateBy": "",
"updateTime": "2026-06-18 11:59:25",
"remark": null,
"id": "37",
"apiKey": "9fqiemn5nhWqTWeZoZnEGwlFgZIEPlFHjI3ucEz6fmY",
"apiKeyName": "盒马超市只读访问密钥",
"enterpriseId": "1932095424144715777",
"status": 0,
"expireTime": "2027-06-18T11:59:25.000+08:00"
},
{
"createBy": "",
"createTime": "2026-06-18 10:54:40",
...
2026-06-23 09:48:36 - mcp.server.lowlevel.server - DEBUG - [server.py:790] - Response sent
2026-06-23 09:48:39 - httpcore.connection - DEBUG - [_trace.py:87] - close.started
2026-06-23 09:48:39 - httpcore.connection - DEBUG - [_trace.py:87] - close.complete
2026-06-23 11:11:10 - 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-23 11:11:10 - mcp.server.lowlevel.server - DEBUG - [server.py:162] - Initializing server 'lzwcai_mcp_agile_db'
2026-06-23 11:11:10 - mcp.server.lowlevel.server - DEBUG - [server.py:439] - Registering handler for ListToolsRequest
2026-06-23 11:11:10 - mcp.server.lowlevel.server - DEBUG - [server.py:519] - Registering handler for CallToolRequest
2026-06-23 11:11:10 - lzwcai_mcp_agile_db.server - INFO - [server.py:129] - ==================================================
2026-06-23 11:11:10 - lzwcai_mcp_agile_db.server - INFO - [server.py:130] - lzwcai-mcp-agile-db MCP Server 启动
2026-06-23 11:11:10 - lzwcai_mcp_agile_db.server - INFO - [server.py:135] - 已注册工具数量: 56
2026-06-23 11:11:10 - lzwcai_mcp_agile_db.server - INFO - [server.py:136] - ==================================================
2026-06-23 11:11:10 - lzwcai_mcp_agile_db.server - INFO - [server.py:138] - 开始运行 MCP Server (stdio 模式)
2026-06-23 11:11:10 - asyncio - DEBUG - [proactor_events.py:634] - Using proactor: IocpProactor
2026-06-23 11:11:10 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: root=InitializedNotification(method='notifications/initialized', params=None, jsonrpc='2.0')
2026-06-23 11:11:11 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: <mcp.shared.session.RequestResponder object at 0x000002BCAFE606E0>
2026-06-23 11:11:11 - mcp.server.lowlevel.server - INFO - [server.py:720] - Processing request of type ListToolsRequest
2026-06-23 11:11:11 - mcp.server.lowlevel.server - DEBUG - [server.py:723] - Dispatching request of type ListToolsRequest
2026-06-23 11:11:11 - lzwcai_mcp_agile_db.server - INFO - [server.py:42] - 收到 ListTools 请求
2026-06-23 11:11:11 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:72] - [客户端初始化] base_url=http://192.168.2.236:8082/api, 认证方式=account:yy8z9
2026-06-23 11:11:11 - lzwcai_mcp_agile_db.server - INFO - [server.py:56] - ListTools 响应: 返回 56 个工具
2026-06-23 11:11:11 - mcp.server.lowlevel.server - DEBUG - [server.py:790] - Response sent
2026-06-23 11:11:14 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: <mcp.shared.session.RequestResponder object at 0x000002BCB0FBFB60>
2026-06-23 11:11:14 - mcp.server.lowlevel.server - INFO - [server.py:720] - Processing request of type CallToolRequest
2026-06-23 11:11:14 - mcp.server.lowlevel.server - DEBUG - [server.py:723] - Dispatching request of type CallToolRequest
2026-06-23 11:11:14 - lzwcai_mcp_agile_db.server - INFO - [server.py:66] - 收到 CallTool 请求: name=list_api_keys
2026-06-23 11:11:14 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:117] - [登录] POST http://192.168.2.236:8082/api/login, username=yy8z9, loginType=user
2026-06-23 11:11:14 - httpcore.connection - DEBUG - [_trace.py:87] - connect_tcp.started host='192.168.2.236' port=8082 local_address=None timeout=30.0 socket_options=None
2026-06-23 11:11:14 - httpcore.connection - DEBUG - [_trace.py:87] - connect_tcp.complete return_value=<httpcore._backends.anyio.AnyIOStream object at 0x000002BCB15974D0>
2026-06-23 11:11:14 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_headers.started request=<Request [b'POST']>
2026-06-23 11:11:14 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_headers.complete
2026-06-23 11:11:14 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_body.started request=<Request [b'POST']>
2026-06-23 11:11:14 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_body.complete
2026-06-23 11:11:14 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_headers.started request=<Request [b'POST']>
2026-06-23 11:11:15 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'', [(b'Server', b'nginx/1.25.2'), (b'Date', b'Tue, 23 Jun 2026 03:11:13 GMT'), (b'Content-Type', b'application/json;charset=UTF-8'), (b'Transfer-Encoding', b'chunked'), (b'Connection', b'keep-alive'), (b'Vary', b'Accept-Encoding'), (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'Content-Encoding', b'gzip')])
2026-06-23 11:11:15 - httpx - INFO - [_client.py:1740] - HTTP Request: POST http://192.168.2.236:8082/api/login "HTTP/1.1 200 "
2026-06-23 11:11:15 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_body.started request=<Request [b'POST']>
2026-06-23 11:11:15 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_body.complete
2026-06-23 11:11:15 - httpcore.http11 - DEBUG - [_trace.py:87] - response_closed.started
2026-06-23 11:11:15 - httpcore.http11 - DEBUG - [_trace.py:87] - response_closed.complete
2026-06-23 11:11:15 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:176] - [API响应] HTTP 200
2026-06-23 11:11:15 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:136] - [登录] 成功获取 token
2026-06-23 11:11:15 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:232] - [API请求] GET http://192.168.2.236:8082/api/datasource/api_key/list
2026-06-23 11:11:15 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_headers.started request=<Request [b'GET']>
2026-06-23 11:11:15 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_headers.complete
2026-06-23 11:11:15 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_body.started request=<Request [b'GET']>
2026-06-23 11:11:15 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_body.complete
2026-06-23 11:11:15 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_headers.started request=<Request [b'GET']>
2026-06-23 11:11:15 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'', [(b'Server', b'nginx/1.25.2'), (b'Date', b'Tue, 23 Jun 2026 03:11:13 GMT'), (b'Content-Type', b'application/json'), (b'Transfer-Encoding', b'chunked'), (b'Connection', b'keep-alive'), (b'Vary', b'Accept-Encoding'), (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'Content-Encoding', b'gzip')])
2026-06-23 11:11:15 - httpx - INFO - [_client.py:1740] - HTTP Request: GET http://192.168.2.236:8082/api/datasource/api_key/list?pageNum=1&pageSize=20 "HTTP/1.1 200 "
2026-06-23 11:11:15 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_body.started request=<Request [b'GET']>
2026-06-23 11:11:15 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_body.complete
2026-06-23 11:11:15 - httpcore.http11 - DEBUG - [_trace.py:87] - response_closed.started
2026-06-23 11:11:15 - httpcore.http11 - DEBUG - [_trace.py:87] - response_closed.complete
2026-06-23 11:11:15 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:176] - [API响应] HTTP 200
2026-06-23 11:11:15 - lzwcai_mcp_agile_db.server - INFO - [server.py:86] - 工具执行成功: list_api_keys
2026-06-23 11:11:15 - lzwcai_mcp_agile_db.server - DEBUG - [server.py:87] - 工具返回结果: {
"total": 21,
"rows": [
{
"createBy": "",
"createTime": "2026-06-18 11:29:46",
"updateBy": "",
"updateTime": "2026-06-18 11:59:25",
"remark": null,
"id": "37",
"apiKey": "9fqiemn5nhWqTWeZoZnEGwlFgZIEPlFHjI3ucEz6fmY",
"apiKeyName": "盒马超市只读访问密钥",
"enterpriseId": "1932095424144715777",
"status": 0,
"expireTime": "2027-06-18T11:59:25.000+08:00"
},
{
"createBy": "",
"createTime": "2026-06-18 10:54:40",
...
2026-06-23 11:11:15 - mcp.server.lowlevel.server - DEBUG - [server.py:790] - Response sent
2026-06-23 11:11:27 - mcp.server.lowlevel.server - DEBUG - [server.py:675] - Received message: <mcp.shared.session.RequestResponder object at 0x000002BCB0EA5D60>
2026-06-23 11:11:27 - mcp.server.lowlevel.server - INFO - [server.py:720] - Processing request of type CallToolRequest
2026-06-23 11:11:27 - mcp.server.lowlevel.server - DEBUG - [server.py:723] - Dispatching request of type CallToolRequest
2026-06-23 11:11:27 - lzwcai_mcp_agile_db.server - INFO - [server.py:66] - 收到 CallTool 请求: name=list_api_keys
2026-06-23 11:11:27 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:232] - [API请求] GET http://192.168.2.236:8082/api/datasource/api_key/list
2026-06-23 11:11:27 - httpcore.connection - DEBUG - [_trace.py:87] - close.started
2026-06-23 11:11:27 - httpcore.connection - DEBUG - [_trace.py:87] - close.complete
2026-06-23 11:11:27 - httpcore.connection - DEBUG - [_trace.py:87] - connect_tcp.started host='192.168.2.236' port=8082 local_address=None timeout=30.0 socket_options=None
2026-06-23 11:11:27 - httpcore.connection - DEBUG - [_trace.py:87] - connect_tcp.complete return_value=<httpcore._backends.anyio.AnyIOStream object at 0x000002BCB100F1D0>
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_headers.started request=<Request [b'GET']>
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_headers.complete
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_body.started request=<Request [b'GET']>
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_body.complete
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_headers.started request=<Request [b'GET']>
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'', [(b'Server', b'nginx/1.25.2'), (b'Date', b'Tue, 23 Jun 2026 03:11:26 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')])
2026-06-23 11:11:27 - httpx - INFO - [_client.py:1740] - HTTP Request: GET http://192.168.2.236:8082/api/datasource/api_key/list?pageNum=1&pageSize=20 "HTTP/1.1 200 "
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_body.started request=<Request [b'GET']>
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_body.complete
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - response_closed.started
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - response_closed.complete
2026-06-23 11:11:27 - lzwcai_mcp_agile_db.utils.api_client - WARNING - [api_client.py:239] - [认证] 收到 401登录过期尝试重新登录后重试一次
2026-06-23 11:11:27 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:117] - [登录] POST http://192.168.2.236:8082/api/login, username=yy8z9, loginType=user
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_headers.started request=<Request [b'POST']>
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_headers.complete
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_body.started request=<Request [b'POST']>
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_body.complete
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_headers.started request=<Request [b'POST']>
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'', [(b'Server', b'nginx/1.25.2'), (b'Date', b'Tue, 23 Jun 2026 03:11:26 GMT'), (b'Content-Type', b'application/json;charset=UTF-8'), (b'Transfer-Encoding', b'chunked'), (b'Connection', b'keep-alive'), (b'Vary', b'Accept-Encoding'), (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'Content-Encoding', b'gzip')])
2026-06-23 11:11:27 - httpx - INFO - [_client.py:1740] - HTTP Request: POST http://192.168.2.236:8082/api/login "HTTP/1.1 200 "
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_body.started request=<Request [b'POST']>
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_body.complete
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - response_closed.started
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - response_closed.complete
2026-06-23 11:11:27 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:176] - [API响应] HTTP 200
2026-06-23 11:11:27 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:136] - [登录] 成功获取 token
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_headers.started request=<Request [b'GET']>
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_headers.complete
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_body.started request=<Request [b'GET']>
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - send_request_body.complete
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_headers.started request=<Request [b'GET']>
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'', [(b'Server', b'nginx/1.25.2'), (b'Date', b'Tue, 23 Jun 2026 03:11:26 GMT'), (b'Content-Type', b'application/json'), (b'Transfer-Encoding', b'chunked'), (b'Connection', b'keep-alive'), (b'Vary', b'Accept-Encoding'), (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'Content-Encoding', b'gzip')])
2026-06-23 11:11:27 - httpx - INFO - [_client.py:1740] - HTTP Request: GET http://192.168.2.236:8082/api/datasource/api_key/list?pageNum=1&pageSize=20 "HTTP/1.1 200 "
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_body.started request=<Request [b'GET']>
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - receive_response_body.complete
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - response_closed.started
2026-06-23 11:11:27 - httpcore.http11 - DEBUG - [_trace.py:87] - response_closed.complete
2026-06-23 11:11:27 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:176] - [API响应] HTTP 200
2026-06-23 11:11:27 - lzwcai_mcp_agile_db.server - INFO - [server.py:86] - 工具执行成功: list_api_keys
2026-06-23 11:11:27 - lzwcai_mcp_agile_db.server - DEBUG - [server.py:87] - 工具返回结果: {
"total": 21,
"rows": [
{
"createBy": "",
"createTime": "2026-06-18 11:29:46",
"updateBy": "",
"updateTime": "2026-06-18 11:59:25",
"remark": null,
"id": "37",
"apiKey": "9fqiemn5nhWqTWeZoZnEGwlFgZIEPlFHjI3ucEz6fmY",
"apiKeyName": "盒马超市只读访问密钥",
"enterpriseId": "1932095424144715777",
"status": 0,
"expireTime": "2027-06-18T11:59:25.000+08:00"
},
{
"createBy": "",
"createTime": "2026-06-18 10:54:40",
...
2026-06-23 11:11:27 - mcp.server.lowlevel.server - DEBUG - [server.py:790] - Response sent
2026-06-23 11:13:47 - httpcore.connection - DEBUG - [_trace.py:87] - close.started
2026-06-23 11:13:47 - httpcore.connection - DEBUG - [_trace.py:87] - close.complete
2026-06-23 11:37:08 - 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-23 11:37:08 - mcp.server.lowlevel.server - DEBUG - [server.py:162] - Initializing server 'lzwcai_mcp_agile_db'
2026-06-23 11:37:08 - mcp.server.lowlevel.server - DEBUG - [server.py:439] - Registering handler for ListToolsRequest
2026-06-23 11:37:08 - mcp.server.lowlevel.server - DEBUG - [server.py:519] - Registering handler for CallToolRequest
2026-06-23 11:37:08 - asyncio - DEBUG - [proactor_events.py:634] - Using proactor: IocpProactor
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:72] - [客户端初始化] base_url=http://x, 认证方式=account:u
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:134] - [登录] POST http://x/login, username=u, loginType=user
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:236] - [API响应] HTTP 200
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:154] - [登录] 成功获取 token
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:294] - [API请求] POST http://x/foo
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - WARNING - [api_client.py:304] - [认证] 收到 401登录过期尝试重新登录后重试一次
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:134] - [登录] POST http://x/login, username=u, loginType=user
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:236] - [API响应] HTTP 200
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:154] - [登录] 成功获取 token
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:236] - [API响应] HTTP 200
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:72] - [客户端初始化] base_url=http://x, 认证方式=account:u
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:134] - [登录] POST http://x/login, username=u, loginType=user
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:236] - [API响应] HTTP 200
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:154] - [登录] 成功获取 token
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:294] - [API请求] POST http://x/foo
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - WARNING - [api_client.py:304] - [认证] 收到 401登录过期尝试重新登录后重试一次
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:134] - [登录] POST http://x/login, username=u, loginType=user
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:236] - [API响应] HTTP 200
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:154] - [登录] 成功获取 token
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:236] - [API响应] HTTP 200
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:72] - [客户端初始化] base_url=http://x, 认证方式=account:u
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:134] - [登录] POST http://x/login, username=u, loginType=user
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:236] - [API响应] HTTP 200
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:154] - [登录] 成功获取 token
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:294] - [API请求] POST http://x/foo
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - WARNING - [api_client.py:304] - [认证] 收到 401登录过期尝试重新登录后重试一次
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:134] - [登录] POST http://x/login, username=u, loginType=user
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:236] - [API响应] HTTP 200
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:154] - [登录] 成功获取 token
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - ERROR - [api_client.py:314] - [认证] 重新登录后仍返回 401
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:72] - [客户端初始化] base_url=http://x, 认证方式=account:u
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:134] - [登录] POST http://x/login, username=u, loginType=user
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:236] - [API响应] HTTP 200
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:154] - [登录] 成功获取 token
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:294] - [API请求] POST http://x/foo
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:236] - [API响应] HTTP 200
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:72] - [客户端初始化] base_url=http://x, 认证方式=account:u
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:134] - [登录] POST http://x/login, username=u, loginType=user
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:236] - [API响应] HTTP 200
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:154] - [登录] 成功获取 token
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:294] - [API请求] POST http://x/foo
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - WARNING - [api_client.py:304] - [认证] 收到 401登录过期尝试重新登录后重试一次
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:134] - [登录] POST http://x/login, username=u, loginType=user
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:236] - [API响应] HTTP 200
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:154] - [登录] 成功获取 token
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:236] - [API响应] HTTP 200
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:72] - [客户端初始化] base_url=http://x, 认证方式=account:u
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:134] - [登录] POST http://x/login, username=u, loginType=user
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:236] - [API响应] HTTP 200
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:154] - [登录] 成功获取 token
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:294] - [API请求] POST http://x/foo
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:236] - [API响应] HTTP 200

View File

@@ -1,15 +1 @@
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-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - ERROR - [api_client.py:314] - [认证] 重新登录后仍返回 401

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""
lzwcai-mcp-agile-db MCP Server
数据库管理平台 MCP 工具服务,提供 33 个工具用于数据库管理、表操作、API 密钥管理等
数据库管理平台 MCP 工具服务,提供数据库管理、表/数据操作、技能、API 密钥、MQTT 同步、AI 训练等工具
"""
import json
@@ -104,19 +104,24 @@ async def handle_call_tool(
async def run_server():
"""运行 MCP Server (stdio 模式)"""
try:
async with stdio_server() as streams:
await server.run(
streams[0],
streams[1],
InitializationOptions(
server_name="lzwcai_mcp_agile_db",
server_version="0.1.0",
server_version="0.1.8",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
)
finally:
# 释放全局 HTTP 客户端
if _api_client is not None:
await _api_client.close()
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,8 @@
"""
API 密钥管理工具 (工具 18-23)
API 密钥管理工具(含创建、状态切换、删除、权限查询/授予)
注意权限模型为「仅追加」——grant_api_key_permissions 只能新增权限,后端不支持撤销/删除
已授予的权限(真机验证 permission 删除接口返回「不支持当前的调用方式」),故不提供 revoke 工具。
"""
from ._base import register_tool, ToolDef
@@ -93,7 +96,7 @@ class GetApiKeyPermissionsTool(ToolDef):
@register_tool("grant_api_key_permissions")
class GrantApiKeyPermissionsTool(ToolDef):
name = "grant_api_key_permissions"
description = "批量为 API 密钥授予权限"
description = "批量为 API 密钥授予权限(仅追加,不会覆盖或删除已有权限;后端不支持撤销已授予的权限)"
input_schema = {
"type": "object",
"properties": {

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)
"""
import base64
import io
import mimetypes
import os
from urllib.parse import urlparse, unquote
from ._base import register_tool, ToolDef
@@ -11,31 +13,75 @@ from ._base import register_tool, ToolDef
@register_tool("preview_import_data")
class PreviewImportDataTool(ToolDef):
name = "preview_import_data"
description = "上传 Excel 文件AI 智能识别并预览表结构/数据"
description = "从 URL 下载 Excel 文件AI 智能识别并预览表结构/数据"
input_schema = {
"type": "object",
"properties": {
"connectionId": {"type": "string", "description": "数据源 ID"},
"target": {"type": "string", "enum": ["prod", "test"], "default": "test", "description": "环境,默认 test"},
"file_base64": {"type": "string", "description": "Excel 文件 base64 编码.xlsx/.xls, <500KB"},
"file_name": {"type": "string", "description": "文件名(如 data.xlsx"},
"file_url": {"type": "string", "description": "Excel 文件下载地址.xlsx/.xls, <500KB"},
},
"required": ["connectionId", "file_base64"],
"required": ["connectionId", "file_url"],
}
async def execute(self, args: dict) -> dict:
args = dict(args)
connection_id = args.pop("connectionId")
target = args.pop("target", "test")
file_base64 = args.pop("file_base64")
file_name = args.pop("file_name", "import.xlsx")
file_url = args.pop("file_url")
# 解码 base64 文件
file_content = base64.b64decode(file_base64)
# 0. 基础 URL 校验
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 = {
"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(
@@ -44,29 +90,235 @@ class PreviewImportDataTool(ToolDef):
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")
class ConfirmImportDataTool(ToolDef):
name = "confirm_import_data"
description = "确认导入 AI 识别后的数据"
description = (
"确认导入 AI 识别后的数据(建表+插数据),第二步。第一步先调 preview_import_data。\n"
"【data 传什么】把 preview_import_data 返回的 data 原文整块传给 data 参数即可,"
"工具会自动解包并组装成后端要求的 {tableStructure(单表对象,含databaseName), allData} 结构。\n"
"data 的标准形态(= preview 的返回):\n"
" {\n"
" \"tableStructure\": { \"success\":true, \"message\":\"...\",\n"
" \"data\": { \"tables\": [ { \"tableName\":\"animals\", \"columns\":[...] } ] },\n"
" \"allData\": [ [列名表头行...], [行1各列值...], [行2各列值...] ] },\n"
" \"databaseName\": \"目标库名\", \"target\": \"prod|test\"\n"
" }\n"
"【allData 的结构关键】allData 是二维数组:\n"
" · 首行 allData[0] 是【表头行】= 各列的 columnName列名真实数据从 allData[1] 起;\n"
" · 每行(含表头行)都是「按 columns 顺序排列的位置数组」,行宽 = 列总数(含 SERIAL 主键等所有列,不裁剪);\n"
" · 若调用方传的 allData 没带表头行(首行就是数据),工具会据列名自动补一行表头——"
"否则后端会把首行数据当成字段名,报「查询字段不存在/字段名称不正确」。\n"
"【列名必须对应目标表真实字段】tableStructure.columns及 allData 首行表头)里的列名,"
"必须是目标表中确实存在的字段名(后端按列名拼 INSERT。导入到已有表时"
"不要直接用 Excel 识别出的列,应先调 get_table_detail 拿到目标表真实字段,"
"再把 columns、表头、各行取值对齐到这些真实字段否则报「查询字段不存在/字段名称不正确」。\n"
"databaseName/target 既可放顶层参数,也可放在 data 里,工具都能识别。"
)
input_schema = {
"type": "object",
"properties": {
"connectionId": {"type": "string", "description": "数据源 ID"},
"target": {"type": "string", "enum": ["prod", "test"], "description": "环境"},
"data": {"type": "object", "description": "导入数据(含 tableStructure + allData"},
"connectionId": {"type": "string", "description": "数据源连接 ID(同 preview 用的那个)"},
"databaseName": {"type": "string", "description": "落库的数据库名(导入目标库)。必填——顶层不给会尝试从 data.databaseName 回捞"},
"target": {"type": "string", "enum": ["prod", "test"], "default": "test", "description": "环境,默认 test。可放顶层或 data"},
"data": {
"type": "object",
"description": (
"preview_import_data 返回的 data 原文整块(含 tableStructure{success,message,data:{tables:[...]}} 与 allData"
"allData 为二维数组:首行是列名表头(allData[0])、数据行从 allData[1] 起,"
"每行按 columns 顺序给出全部列的值,行宽 = 列总数(不裁剪自增列)。"
"缺表头时工具会据列名自动补。也接受调用方已组装好的最终结构。"
),
},
"required": ["connectionId", "data"],
},
"required": ["connectionId", "databaseName", "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
ts_inner = {}
if isinstance(ts, dict):
if "columns" in ts:
# 已是单表对象(调用方自行组装过)
single_table = dict(ts)
else:
# preview 包装tableStructure.data.tables[0]
ts_inner = ts.get("data") if isinstance(ts.get("data"), dict) else {}
tables = ts_inner.get("tables") if isinstance(ts_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
# allData 可能落在多个层级(取决于调用方/preview 的嵌套方式),按优先级查找:
# 1. data.allData —— 与 tableStructure 平级(约定的标准位置)
# 2. tableStructure.allData —— 嵌在 tableStructure 包装内(真机/AI 常见误放)
# 3. tableStructure.data.allData —— 嵌在内层 data 里
# 注意:只接受 list任何非 list如内层 data 的 tables 包装对象)都视为未命中,
# 避免把 dict 误当成行数据传给后端。
all_data = None
for candidate in (
data.get("allData"),
ts.get("allData") if isinstance(ts, dict) else None,
ts_inner.get("allData") if isinstance(ts_inner, dict) else None,
):
if isinstance(candidate, list):
all_data = candidate
break
if all_data is None:
all_data = []
# 表头行:后端约定 allData[0] 是「表头行」(列名数组),真实数据从 allData[1] 起
# (见前端 TableRecognition.vue handleComplete 与 CustomizeDbTable.vue validateDataColumns
# 若调用方传的 allData 没带表头(首行就是数据),后端会把首行数据当成字段名,
# 报「查询字段不存在/字段名称不正确」。这里据列名补出表头行。
all_data = ConfirmImportDataTool._ensure_header(single_table.get("columns"), all_data)
return {"tableStructure": single_table, "allData": all_data}
@staticmethod
def _column_names(columns):
"""从列定义中按顺序提取列名数组。"""
if not isinstance(columns, list):
return []
return [c.get("columnName") for c in columns if isinstance(c, dict) and c.get("columnName")]
@staticmethod
def _ensure_header(columns, all_data):
"""确保 allData[0] 是「表头行」(列名数组)。
后端约定allData[0] 为表头(列名),真实数据行从 allData[1] 起;数据行按 columns
顺序给出【全部列】的值(不裁剪自增列)。前端 TableRecognition.vue 在提交前总会把
列名作为首行 push 进 allData。若调用方含 AI传来的 allData 首行已经是数据(缺表头)
后端会把首行当列名解析,报「查询字段不存在/字段名称不正确」。这里据列名补表头:
- 首行恰好等于列名数组 → 视为已带表头,原样返回
- 否则 → 在最前面补一行列名
"""
names = ConfirmImportDataTool._column_names(columns)
if not names or not isinstance(all_data, list) or not all_data:
return all_data
first = all_data[0]
if isinstance(first, list) and list(first) == names:
return all_data # 已带表头
return [names, *all_data]
async def execute(self, args: dict) -> dict:
args = dict(args)
connection_id = args.pop("connectionId")
target = args.pop("target", "test")
target = args.pop("target", None)
database_name = args.pop("databaseName", None)
data = args.pop("data")
# 容错databaseName / target 可能被放进 data 里AI 常把 preview 返回的整块连同
# databaseName/target 一起塞进 data。顶层没给时从 data 里回捞,并清出 data
# 避免污染最终 body。
if isinstance(data, dict):
if database_name is None and data.get("databaseName"):
database_name = data.get("databaseName")
if target is None and data.get("target"):
target = data.get("target")
data = {k: v for k, v in data.items() if k not in ("databaseName", "target")}
if target is None:
target = "test"
body = self._build_body(data, database_name)
# 预检:把后端那两个含糊的报错(「导入数据不能为空」/「数据库名称不能为空」)
# 提前在工具层拦下,给出可操作的提示(指明 allData/databaseName 该放哪),
# 避免调用方对着后端原文反复试错。仅在 body 已被识别为标准结构时校验。
if isinstance(body, dict) and "tableStructure" in body:
if not body.get("allData"):
raise ValueError(
"导入数据为空:未能从 data 中解析到 allData数据行"
"请确认 allData 是一个非空数组,可放在 data.allData、"
"data.tableStructure.allData 或 data.tableStructure.data.allData 任一层级。"
)
ts = body["tableStructure"]
if isinstance(ts, dict) and not ts.get("databaseName"):
raise ValueError(
"缺少 databaseName落库目标库名请通过顶层参数 databaseName 传入,"
"或放在 data.databaseName 中(工具会自动塞进表对象)。"
)
# 行宽与表头校验_ensure_header 已保证 allData[0] 是表头行(列名)。
# 后端要求每行(含表头)宽度 = 列数(全部列,含自增列占位),且表头之外至少有 1 行数据。
# 行宽对不上后端只会回含糊的「字段名称不正确/查询字段不存在」,这里提前报清楚。
cols = ts.get("columns") if isinstance(ts, dict) else None
all_data = body["allData"]
if isinstance(cols, list) and cols:
total = len(cols)
# 表头之外至少要有一行真实数据
data_rows = [r for r in all_data[1:] if isinstance(r, list)]
if not data_rows:
raise ValueError(
"导入数据为空allData 除表头行外没有任何数据行。"
"allData 约定首行为表头(列名),真实数据从第 2 行起。"
)
for idx, row in enumerate(all_data):
if isinstance(row, list) and len(row) != total:
raise ValueError(
f"{idx + 1} 行列数为 {len(row)},与表结构的 {total} 列不匹配。"
"allData 每行(含表头行)都应按 columns 顺序给出全部列的值;"
"首行须为列名表头,数据行从第 2 行起。请核对是否多/少了列。"
)
return await self.client.post(
f"/datasource/connection/{connection_id}/import_document/confirm",
json_data=data,
json_data=body,
params={"target": target},
)

View File

@@ -45,6 +45,28 @@ class ListTablesTool(ToolDef):
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")
class GetTableDetailTool(ToolDef):
name = "get_table_detail"
@@ -117,8 +139,8 @@ class AlterTableTool(ToolDef):
"items": {
"type": "object",
"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": "变更类型"},
"column": {"type": "object", "description": "列定义(根据 operation 不同包含不同字段)"},
"operation": {"type": "string", "enum": ["ADD_COLUMN", "MODIFY_COLUMN", "DROP_COLUMN"], "description": "变更类型:新增/修改/删除字段(后端实测仅支持这三种)"},
"column": {"type": "object", "description": "列定义。ADD/MODIFY 需 columnName+columnType(+columnLength/columnComment 等)DROP 仅需 columnName"},
},
"required": ["operation", "column"],
},
@@ -137,7 +159,7 @@ class AlterTableTool(ToolDef):
@register_tool("generate_table_by_description")
class GenerateTableByDescriptionTool(ToolDef):
name = "generate_table_by_description"
description = "通过自然语言描述让 AI 生成表结构(异步任务"
description = "通过自然语言描述让 AI 生成表结构(异步任务,返回 taskId。需配合 get_ai_training_detail 轮询 taskId 获取生成结果"
input_schema = {
"type": "object",
"properties": {
@@ -149,3 +171,116 @@ class GenerateTableByDescriptionTool(ToolDef):
async def execute(self, args: dict) -> dict:
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",
"properties": {
"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": "数据库地址"},
"port": {"type": "integer", "description": "端口号"},
"databaseName": {"type": "string", "description": "要连接的数据库名"},
@@ -88,7 +88,7 @@ class CreateDatasourceTool(ToolDef):
args = dict(args)
test_first = args.pop("test_first", True)
# 如果需要先测试连接
# 如果需要先测试连接(测试失败时 client 会直接抛异常,由上层统一捕获)
if test_first:
test_data = {
"datasourceName": args.get("datasourceName"),
@@ -100,9 +100,7 @@ class CreateDatasourceTool(ToolDef):
"password": args.get("password"),
"connectionType": args.get("connectionType", "user_password"),
}
test_result = 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', '未知错误')}"}
await self.client.post("/datasource/connection/test", json_data=test_data)
# 创建数据源
return await self.client.post("/datasource/connection", json_data=args)
@@ -117,11 +115,13 @@ class UpdateDatasourceTool(ToolDef):
"properties": {
"id": {"type": "string", "description": "数据源 ID"},
"datasourceName": {"type": "string", "description": "更新名称"},
"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": "连接类型"},
"remark": {"type": "string", "description": "更新描述"},
},
"required": ["id"],
@@ -171,3 +171,79 @@ class DeleteDatasourceTool(ToolDef):
# 删除数据源
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

@@ -1,5 +1,9 @@
"""
技能与工具管理工具 (工具 24-29)
技能与工具管理工具
把 SQL 沉淀为数据源可复用工具的入口统一走 add_sql_tool_to_datasource保证技能必有工具
内部按需建技能+写配置+建工具)。其余为底层积木:查询类、向已有技能加/改工具、改技能配置。
不单独暴露「只建技能」工具,避免产生无效空技能。
"""
import json
@@ -39,24 +43,6 @@ class GetSkillToolsTool(ToolDef):
return await self.client.get(f"/datasource/skill/getBySkillId/{args['skillId']}")
@register_tool("create_skill")
class CreateSkillTool(ToolDef):
name = "create_skill"
description = "为数据源创建技能"
input_schema = {
"type": "object",
"properties": {
"datasourceId": {"type": "string", "description": "数据源 ID"},
"name": {"type": "string", "description": "技能名称(不传则自动生成)"},
"description": {"type": "string", "description": "技能描述"},
},
"required": ["datasourceId"],
}
async def execute(self, args: dict) -> dict:
return await self.client.post("/datasource/skill/createOrGet", json_data=args)
@register_tool("create_sql_tool")
class CreateSqlToolTool(ToolDef):
name = "create_sql_tool"
@@ -79,7 +65,7 @@ class CreateSqlToolTool(ToolDef):
"name": {"type": "string", "description": "工具名称"},
"businessDescription": {"type": "string", "description": "业务描述"},
"sqlTemplate": {"type": "string", "description": "SQL 模板(支持 #{param} 参数占位)"},
"sqlParams": {"type": "string", "description": "参数 JSON SchemaJSON 字符串或对象)"},
"sqlParams": {"type": "string", "description": "参数定义,最终以 JSON 字符串提交(传 dict 会自动 json.dumps。内容形态后端不挑剔可为 JSON Schema 对象串如 {\"type\":\"object\",...},也可为字段定义数组串;无参数传 \"{}\""},
"resultType": {"type": "string", "enum": ["single", "list"], "default": "list", "description": "结果类型,默认 list"},
"businessScenario": {"type": "string", "description": "业务场景描述"},
},
@@ -92,11 +78,17 @@ class CreateSqlToolTool(ToolDef):
async def execute(self, args: dict) -> dict:
args = dict(args)
# 处理 suggestions 中的 sqlParams
# tableIds 后端始终期望该键存在:前端真机始终传 ""(空串)。
# 调用方未给时补 "",与前端 postSqlSkillConfirmTools 的请求保持一致。
if "tableIds" not in args or args["tableIds"] is None:
args["tableIds"] = ""
# 处理 suggestions 中的 sqlParamsdict 自动序列化为 JSON 字符串;
# 同时补齐 resultType 默认值 list与前端默认一致
if "suggestions" in args and isinstance(args["suggestions"], list):
for suggestion in args["suggestions"]:
if "sqlParams" in suggestion and isinstance(suggestion["sqlParams"], dict):
suggestion["sqlParams"] = json.dumps(suggestion["sqlParams"])
suggestion.setdefault("resultType", "list")
return await self.client.post("/datasource/skill/confirmTools", json_data=args)
@@ -119,19 +111,276 @@ class DeleteSkillToolTool(ToolDef):
@register_tool("update_skill_config")
class UpdateSkillConfigTool(ToolDef):
name = "update_skill_config"
description = "更新技能配置(如 MCP Server 配置模板)"
description = (
"更新技能(名称/描述/MCP 配置模板等)。对应后端 skill/updateOrGet。"
"datasourceId 与 skillId 均必填且为真实 ID来自其他工具返回不可臆造"
"若不显式传 configTemplate会按这两个 ID 自动生成 lzwcai-mcp-sqlexecutor 的标准 MCP 配置模板(与前端一致)。"
)
input_schema = {
"type": "object",
"properties": {
"datasourceId": {"type": "string", "description": "数据源 ID"},
"configTemplate": {"type": "string", "description": "配置模板 JSON 字符串"},
"datasourceId": {
"type": "string",
"description": "数据源/配置 ID真实 ID来自 list_databases / list_tables_with_ai / get_connection_config_list不可臆造",
},
"required": ["datasourceId", "configTemplate"],
"skillId": {
"type": "string",
"description": "技能 ID真实 ID来自 get_skill_by_datasource 的返回,不可臆造;与 datasourceId 一起用于生成 configTemplate",
},
"name": {"type": "string", "description": "技能名称(可选)"},
"description": {"type": "string", "description": "技能描述(可选)"},
"configTemplate": {
"type": "string",
"description": "配置模板 JSON 字符串(可选)。不传时按 datasourceId + skillId 自动生成标准模板",
},
},
"required": ["datasourceId", "skillId"],
}
@staticmethod
def _build_config_template(datasource_id: str, skill_id: str) -> str:
"""生成 lzwcai-mcp-sqlexecutor 的标准 MCP 配置模板。
模板大部分是固定值,仅 mcpServerKey 后缀、env.databaseId、env.skillId 随
datasourceId / skillId 动态变化(与前端 SqlControllerMsg.vue 的 configTemplateObj 完全一致)。
"""
mcp_server_key = f"lzwcai_mcp_sqlexecutor_{datasource_id}"
config_obj = {
"mcpServers": {
mcp_server_key: {
"command": "uvx",
"type": "stdio",
"args": ["lzwcai-mcp-sqlexecutor"],
"tiemout": 200,
"env": {
"databaseId": datasource_id,
"skillId": skill_id,
},
}
}
}
return json.dumps(config_obj)
async def execute(self, args: dict) -> dict:
args = dict(args)
# 如果 configTemplate 是 dict转为 JSON 字符串
if "configTemplate" in args and isinstance(args["configTemplate"], dict):
args["configTemplate"] = json.dumps(args["configTemplate"])
# 未显式提供 configTemplate 时,按 datasourceId + skillId 自动生成标准模板
elif not args.get("configTemplate"):
args["configTemplate"] = self._build_config_template(
str(args["datasourceId"]), str(args["skillId"])
)
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 语义)。"
"改展示名传 name 即可(工具会同时写 name 和 uniqueName 两个字段,与工具实体存储一致)。"
"工具名建议遵循前端约束≤20 字、只含中英文/数字/空格、不含特殊符号。"
)
# 工具实体同时存在 name 与 uniqueName 两个字段(真机数据里二者取值相同,见 skill.json
# 前端 ChatDebugging.vue handleEditToolSubmit 改名时提交 {id, name, description}(用 name
# 早期真机探测又得到 uniqueName。为消除歧义、与实体存储保持一致改名时两个字段都写。
input_schema = {
"type": "object",
"properties": {
"id": {"type": "string", "description": "技能工具 IDget_skill_tools 返回的 id"},
"name": {"type": "string", "description": "工具展示名(可选)。会同时写入 name 与 uniqueName"},
"description": {"type": "string", "description": "工具描述(可选)"},
"sqlTemplate": {"type": "string", "description": "SQL 模板(可选)"},
"resultType": {"type": "string", "enum": ["single", "list"], "description": "结果类型(可选)"},
},
"required": ["id"],
}
# 旧参数名 -> 真实字段名(向后兼容此前错误实现的调用方)
_LEGACY_MAP = {
"skillToolId": "id",
"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)
# 展示名name / uniqueName 任一传入都同步到两个字段(与工具实体存储一致,
# 兼容前端用 name、早期探测用 uniqueName 两种契约,避免改名不生效)。
display_name = args.get("name") if args.get("name") is not None else args.get("uniqueName")
if display_name is not None:
args["name"] = display_name
args["uniqueName"] = display_name
# businessScenario 后端实体无此字段,丢弃避免干扰
args.pop("businessScenario", None)
return await self.client.post("/datasource/skill/tskilltool/updateOrGet", json_data=args)
@register_tool("add_sql_tool_to_datasource")
class AddSqlToolToDatasourceTool(ToolDef):
name = "add_sql_tool_to_datasource"
description = (
"把一条 SQL 沉淀为数据源的可复用工具(一步到位,推荐用这个而不是手动拼 "
"update_skill_config/create_sql_tool\n"
"【为什么用它】技能(skill)必须挂着工具才有效,单独建技能会留下无效的空技能。本工具内部"
"1:1 复刻前端 handleAddToolSubmit 的完整链路,保证技能必有工具:\n"
" 读 skillBool → 技能不存在则 createOrGet 建技能 → getByDatasource 拿真实 skillId →"
" 按 sqlTemplate 去重 → 技能新建时写 lzwcai-mcp-sqlexecutor 配置模板 → confirmTools 建工具。\n"
"【幂等/去重】同一 datasourceId 下若已存在 sqlTemplate 相同的工具,直接返回 skipped不重复创建。\n"
"datasourceId 必填且为真实 ID来自 list_databases / list_tables_with_ai / get_connection_config_list"
)
input_schema = {
"type": "object",
"properties": {
"datasourceId": {"type": "string", "description": "数据源/配置 ID真实 ID不可臆造"},
"name": {"type": "string", "description": "工具名称(展示名)"},
"businessDescription": {"type": "string", "description": "工具的业务描述"},
"sqlTemplate": {"type": "string", "description": "SQL 模板(支持 #{param} 参数占位)"},
"sqlParams": {
"type": "string",
"description": "参数定义,最终以 JSON 字符串提交(传 dict 会自动 json.dumps。内容形态后端不挑剔JSON Schema 对象串或字段定义数组串均可);不传默认空 schema",
},
"resultType": {
"type": "string",
"enum": ["single", "list"],
"default": "list",
"description": "结果类型,默认 list",
},
"businessScenario": {"type": "string", "description": "业务场景描述(可选)"},
"tableIds": {
"type": "array",
"items": {"type": "string"},
"description": "关联的表 ID 数组(可选)",
},
"skillName": {"type": "string", "description": "技能不存在时新建技能用的名称(可选,不传自动生成)"},
"skillDescription": {"type": "string", "description": "技能不存在时新建技能用的描述(可选)"},
},
"required": ["datasourceId", "name", "businessDescription", "sqlTemplate"],
}
@staticmethod
def _unwrap(resp):
"""{code,msg,data} 信封里取 data非信封则原样返回。"""
if isinstance(resp, dict) and "data" in resp and ("code" in resp or "msg" in resp):
return resp["data"]
return resp
@staticmethod
def _normalize_sql(sql) -> str:
"""归一化 SQL 用于去重比较:折叠空白 + strip与前端 replace(/\\s+/g,' ').trim() 一致)。"""
if not isinstance(sql, str):
return ""
return " ".join(sql.split()).strip()
async def _get_skill_id(self, datasource_id: str):
"""getByDatasource 拿技能 id拿不到返回 None。"""
resp = await self.client.get(f"/datasource/skill/getByDatasource/{datasource_id}")
data = self._unwrap(resp)
if isinstance(data, dict):
return data.get("id")
return None
async def execute(self, args: dict) -> dict:
args = dict(args)
datasource_id = str(args["datasourceId"])
sql_template = args["sqlTemplate"]
# 1. 读数据源配置,判断 skillBool技能是否已存在
config_resp = await self.client.get(f"/datasource/config/{datasource_id}")
config_data = self._unwrap(config_resp)
skill_bool = config_data.get("skillBool") if isinstance(config_data, dict) else None
created_skill = False
# 2. 技能不存在 → 先建技能createOrGet
if skill_bool is not True:
create_skill_body = {"datasourceId": datasource_id}
if args.get("skillName"):
create_skill_body["name"] = args["skillName"]
if args.get("skillDescription"):
create_skill_body["description"] = args["skillDescription"]
await self.client.post("/datasource/skill/createOrGet", json_data=create_skill_body)
created_skill = True
# 3. 拿真实 skillId注意id 来自 getByDatasource不是 createOrGet 的返回)
skill_id = await self._get_skill_id(datasource_id)
if not skill_id:
raise ValueError(
f"未能获取数据源 {datasource_id} 的技能 IDgetByDatasource 未返回 id"
"请确认 datasourceId 正确、技能创建是否成功。"
)
skill_id = str(skill_id)
# 4. 去重:同 sqlTemplate 的工具已存在则跳过
tools_resp = await self.client.get(f"/datasource/skill/getBySkillId/{skill_id}")
tools_data = self._unwrap(tools_resp)
target_norm = self._normalize_sql(sql_template)
if isinstance(tools_data, list):
for tool in tools_data:
if isinstance(tool, dict) and self._normalize_sql(tool.get("sqlTemplate")) == target_norm:
return {
"skipped": True,
"reason": "已存在 sqlTemplate 相同的工具,未重复创建",
"skillId": skill_id,
"existingTool": {
"id": tool.get("id"),
"uniqueName": tool.get("uniqueName") or tool.get("name"),
},
}
# 5. 技能是本次新建的 → 写入 lzwcai-mcp-sqlexecutor 标准配置模板
if created_skill:
config_template = UpdateSkillConfigTool._build_config_template(datasource_id, skill_id)
await self.client.post(
"/datasource/skill/updateOrGet",
json_data={"datasourceId": datasource_id, "configTemplate": config_template},
)
# 6. confirmTools 建工具
sql_params = args.get("sqlParams")
if isinstance(sql_params, dict):
sql_params = json.dumps(sql_params)
elif not sql_params:
sql_params = '{"type":"object","required":[],"properties":{}}'
suggestion = {
"name": args["name"],
"businessDescription": args["businessDescription"],
"sqlTemplate": sql_template,
"sqlParams": sql_params,
"resultType": args.get("resultType", "list"),
"businessScenario": args.get("businessScenario", "数据查询场景"),
}
# tableIds前端真机始终传 ""空串。None / 空列表都归一为 "",与前端一致;
# 仅当调用方显式给了非空列表时才透传该列表。
table_ids = args.get("tableIds")
confirm_body = {
"skillId": skill_id,
"tableIds": table_ids if table_ids else "",
"suggestions": [suggestion],
}
try:
confirm_result = await self.client.post(
"/datasource/skill/confirmTools", json_data=confirm_body
)
except Exception as e:
# 工具创建失败:若本次刚建了技能,此刻技能是「空技能」(有配置无工具)——
# 正是要避免的无效状态。明确告知调用方:技能已建好,重跑本工具即可补上工具
# (重跑会走 skillBool=true 分支,仅补工具,幂等安全)。
if created_skill:
raise Exception(
f"技能已创建skillId={skill_id})但工具创建失败:{e}"
"当前技能为「空技能」,请用相同参数重新调用本工具补上工具"
"(重跑只会补工具、不会重复建技能)。"
) from e
raise
return {
"success": True,
"skillId": skill_id,
"skillCreated": created_skill,
"result": confirm_result,
}

View File

@@ -35,4 +35,11 @@ class ExecuteSqlTool(ToolDef):
body["businessName"] = args["businessName"]
if "params" in args:
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")
class ToggleTableSubscriptionTool(ToolDef):
name = "toggle_table_subscription"
description = "切换表的订阅状态"
description = "切换表的订阅状态(订阅依赖该表已配置 MQTT 字段关联,否则后端会报操作失败)"
input_schema = {
"type": "object",
"properties": {
"tableId": {"type": "string", "description": "表 ID"},
"datasourceId": {"type": "string", "description": "数据源 ID"},
"configId": {"type": "string", "description": "库/配置 ID即 list_databases 返回的 config id"},
"tableName": {"type": "string", "description": "表名(注意:后端按表名而非 tableId 识别)"},
"subscribe": {"type": "boolean", "description": "true=订阅, false=取消订阅"},
},
"required": ["tableId", "datasourceId", "subscribe"],
"required": ["configId", "tableName", "subscribe"],
}
async def execute(self, args: dict) -> dict:
# 映射参数名为后端 API 期望的格式
# 后端真实字段已真机探测确认configId + tableName + isSubscribe(bool)
# 兼容旧参数名 datasourceId->configId
config_id = args.get("configId") or args.get("datasourceId")
body = {
"tableId": args["tableId"],
"datasourceId": args["datasourceId"],
"subscribe": args["subscribe"],
"configId": config_id,
"tableName": args.get("tableName"),
"isSubscribe": args.get("subscribe"),
}
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")
class QueryTableDataTool(ToolDef):
name = "query_table_data"
description = "查询内置表数据(分页)"
description = "查询内置表数据(分页)。target 默认 test调试环境查询线上数据须显式传 target=prod"
input_schema = {
"type": "object",
"properties": {
"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": "页码"},
"pageSize": {"type": "integer", "default": 10, "description": "每页数量"},
},
@@ -25,6 +25,7 @@ class QueryTableDataTool(ToolDef):
async def execute(self, args: dict) -> dict:
args = dict(args)
table_id = args.pop("tableId")
args.setdefault("target", "test")
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)
@@ -32,12 +33,12 @@ class QueryTableDataTool(ToolDef):
@register_tool("insert_table_row")
class InsertTableRowTool(ToolDef):
name = "insert_table_row"
description = "向内置表插入一行数据"
description = "向内置表插入一行数据。⚠️ 写操作 target 默认 test调试环境写入线上库须显式传 target=prod"
input_schema = {
"type": "object",
"properties": {
"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": "行数据(键值对,键为字段名)"},
},
"required": ["tableId", "data"],
@@ -46,21 +47,22 @@ class InsertTableRowTool(ToolDef):
async def execute(self, args: dict) -> dict:
args = dict(args)
table_id = args.pop("tableId")
target = args.pop("target", "prod")
target = args.pop("target", "test")
data = args.pop("data", {})
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")
class UpdateTableRowTool(ToolDef):
name = "update_table_row"
description = "更新内置表的指定行"
description = "更新内置表的指定行。⚠️ 写操作 target 默认 test调试环境修改线上库须显式传 target=prod"
input_schema = {
"type": "object",
"properties": {
"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}"},
"data": {"type": "object", "description": "要更新的字段值"},
},
@@ -70,7 +72,7 @@ class UpdateTableRowTool(ToolDef):
async def execute(self, args: dict) -> dict:
args = dict(args)
table_id = args.pop("tableId")
target = args.pop("target", "prod")
target = args.pop("target", "test")
primary_key = args.pop("primaryKey")
data = args.pop("data", {})
params = {"target": target} if target else {}
@@ -81,12 +83,12 @@ class UpdateTableRowTool(ToolDef):
@register_tool("delete_table_rows")
class DeleteTableRowsTool(ToolDef):
name = "delete_table_rows"
description = "删除内置表的指定行(根据主键批量删除)"
description = "删除内置表的指定行(根据主键批量删除)。⚠️ 写操作 target 默认 test调试环境删除线上库数据须显式传 target=prod"
input_schema = {
"type": "object",
"properties": {
"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": {
"type": "array",
"description": "主键数组(如 [{\"id\": 1}, {\"id\": 2}]",
@@ -99,7 +101,7 @@ class DeleteTableRowsTool(ToolDef):
async def execute(self, args: dict) -> dict:
args = dict(args)
table_id = args.pop("tableId")
target = args.pop("target", "prod")
target = args.pop("target", "test")
primary_keys = args.pop("primaryKeys")
params = {"target": target} if target else {}
body = {"primaryKeys": primary_keys}
@@ -109,12 +111,12 @@ class DeleteTableRowsTool(ToolDef):
@register_tool("export_table_excel")
class ExportTableExcelTool(ToolDef):
name = "export_table_excel"
description = "导出表数据为 Excel 文件(返回 base64 编码)"
description = "导出表数据为 Excel 文件(返回 base64 编码)。target 默认 test调试环境导出线上数据须显式传 target=prod"
input_schema = {
"type": "object",
"properties": {
"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"],
}
@@ -122,7 +124,7 @@ class ExportTableExcelTool(ToolDef):
async def execute(self, args: dict) -> dict:
args = dict(args)
table_id = args.pop("tableId")
target = args.pop("target", "prod")
target = args.pop("target", "test")
params = {"target": target} if target else {}
result = await self.client.get(f"/datasource/connection/builtin/table/{table_id}/export/excel", params=params)

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,10 @@
from .env_config import get_api_key, get_base_url, get_env_config
from .env_config import (
get_api_key,
get_base_url,
get_env_config,
get_account,
get_password,
)
from .logger_config import setup_system_logging, get_logger
from .api_client import AgileDBAPIClient, get_default_client
@@ -6,6 +12,8 @@ __all__ = [
'get_api_key',
'get_base_url',
'get_env_config',
'get_account',
'get_password',
'setup_system_logging',
'get_logger',
'AgileDBAPIClient',

View File

@@ -3,12 +3,17 @@
用于调用数据库管理平台的所有 API 接口
"""
import asyncio
import httpx
import json
import os
from typing import Dict, Any, Optional
from .env_config import get_api_key, get_base_url
from .env_config import (
get_api_key,
get_base_url,
get_account,
get_password,
)
from .logger_config import get_logger
logger = get_logger(__name__)
@@ -16,14 +21,29 @@ logger = get_logger(__name__)
# 默认超时配置(秒)
DEFAULT_TIMEOUT = 30.0
# 登录接口路径base_url 已含 /api 前缀,此处不重复带)
LOGIN_PATH = "/login"
# 登录类型(平台固定为 user
LOGIN_TYPE = "user"
class AgileDBAPIClient:
"""数据库管理平台 API 客户端"""
"""数据库管理平台 API 客户端
认证支持两种方式(优先级从高到低):
1. 显式 api_key / 环境变量 API_KEY —— 直接作为 Bearer token 使用;
2. 账号密码(环境变量 AGILE_DB_ACCOUNT / AGILE_DB_PASSWORD—— 懒登录,
首次请求时自动调用 /login 换取 token 并缓存token 失效401
自动重新登录并重试一次。
"""
def __init__(
self,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
account: Optional[str] = None,
password: Optional[str] = None,
default_timeout: float = DEFAULT_TIMEOUT,
):
"""
@@ -31,21 +51,36 @@ class AgileDBAPIClient:
Args:
base_url: API 基础 URL默认从环境变量 backendBaseUrl 读取)
api_key: API 密钥(默认从环境变量 API_KEY 读取)
api_key: API 密钥(默认从环境变量 API_KEY 读取,可为空
account: 登录账号(默认从环境变量 AGILE_DB_ACCOUNT 读取)
password: 登录密码(默认从环境变量 AGILE_DB_PASSWORD 读取)
default_timeout: 请求超时时间(秒),默认 30 秒
"""
if base_url is None:
base_url = get_base_url()
self.base_url = (base_url if base_url is not None else get_base_url()).rstrip('/')
# 显式配置的 api_key 直接作为 token 使用(去掉可能存在的 Bearer 前缀,统一在 _get_headers 拼)
explicit_key = api_key if api_key is not None else get_api_key()
self._token: Optional[str] = self._strip_bearer(explicit_key) or None
if api_key is None:
api_key = get_api_key()
self.account = account if account is not None else get_account()
self.password = password if password is not None else get_password()
self.base_url = base_url.rstrip('/')
self.api_key = api_key
self.default_timeout = default_timeout
self._client: Optional[httpx.AsyncClient] = None
# 串行化登录,避免并发请求同时触发多次登录
self._login_lock = asyncio.Lock()
logger.info(f"[客户端初始化] base_url={self.base_url}")
logger.info(
f"[客户端初始化] base_url={self.base_url}, "
f"认证方式={'api_key' if self._token else ('account:' + self.account if self.account else '未配置')}"
)
@staticmethod
def _strip_bearer(value: Optional[str]) -> str:
"""去掉 token 字符串可能携带的 'Bearer ' 前缀"""
if not value:
return ""
value = value.strip()
return value[7:].strip() if value.lower().startswith("bearer ") else value
@property
def client(self) -> httpx.AsyncClient:
@@ -54,56 +89,232 @@ class AgileDBAPIClient:
self._client = httpx.AsyncClient(timeout=self.default_timeout)
return self._client
async def _ensure_token(self) -> str:
"""确保已有可用 token没有则登录获取"""
if self._token:
return self._token
return await self._login()
async def _login(self) -> str:
"""换取 token 并缓存(并发安全,用于首次登录)"""
async with self._login_lock:
# 双重检查:可能在等锁期间已有其它协程完成登录
if self._token:
return self._token
return await self._do_login()
async def _relogin(self, stale_token: Optional[str]) -> str:
"""登录态失效后重新登录compare-and-swap并发安全
仅当当前 token 仍是那次失败请求所用的旧 token 时才真正重登;
若在等锁期间已有其它协程刷新过 token则直接复用新 token
避免把别人刚拿到的新 token 抹掉又触发一次多余的重登。
"""
async with self._login_lock:
if self._token != stale_token:
# 别的协程已经刷新过 token直接用新的
return self._token or ""
self._token = None
return await self._do_login()
async def _do_login(self) -> str:
"""实际执行 /login 的逻辑(调用方需自行持有 _login_lock"""
if not self.account or not self.password:
raise Exception(
"未配置认证信息:请设置环境变量 API_KEY"
"或同时设置 AGILE_DB_ACCOUNT 和 AGILE_DB_PASSWORD"
)
url = self._build_url(LOGIN_PATH)
payload = {
"username": self.account,
"password": self.password,
"loginType": LOGIN_TYPE,
}
logger.info(f"[登录] POST {url}, username={self.account}, loginType={LOGIN_TYPE}")
try:
response = await self.client.post(
url,
headers={"Content-Type": "application/json"},
json=payload,
)
except httpx.TimeoutException:
raise Exception(f"登录请求超时: {url}")
except httpx.RequestError as e:
raise Exception(f"登录请求异常: {url}, 错误: {str(e)}")
is_json, body = self._try_parse_json(response)
data = self._handle_response(response, url, is_json, body)
# 平台登录响应token 在顶层 token 字段
token = data.get("token") if isinstance(data, dict) else None
if not token:
raise Exception(f"登录成功但未返回 token响应: {json.dumps(data, ensure_ascii=False)[:300]}")
self._token = self._strip_bearer(token)
logger.info("[登录] 成功获取 token")
return self._token
@staticmethod
def _try_parse_json(response: httpx.Response):
"""尝试把响应体解析为 JSON只解析一次供后续复用。
Returns:
(is_json, data):是 JSON 则 (True, 解析结果)
二进制/非 JSON 响应(如 Excel 下载)则 (False, None)。
"""
try:
return True, response.json()
except (json.JSONDecodeError, UnicodeDecodeError):
return False, None
@staticmethod
def _is_unauthorized(response: httpx.Response, is_json: bool, data: Any) -> bool:
"""判断响应是否为登录态失效(基于已解析好的 body不重复 parse
平台有两种表达 401 的方式,都需识别:
1. HTTP 状态码 401
2. HTTP 200 但 body 信封里 code=401{"code":401,"msg":"登录过期,请重新登录"})。
"""
if response.status_code == 401:
return True
return is_json and isinstance(data, dict) and data.get("code") == 401
@staticmethod
def _rewind_files(files: Optional[Dict[str, Any]]) -> None:
"""把上传用的文件流游标重置到开头。
401 重试会复用同一个 files 二次发送,而文件流在第一次发送后游标已到末尾,
不 rewind 会导致重试上传空内容。支持两种形态:
- 直接的文件对象;
- (filename, fileobj, content_type) 元组httpx multipart 常用写法)。
"""
if not files:
return
for value in files.values():
fileobj = value
if isinstance(value, (tuple, list)) and len(value) >= 2:
fileobj = value[1]
seek = getattr(fileobj, "seek", None)
if callable(seek):
try:
seek(0)
except (OSError, ValueError):
# 不可重置的流(如已关闭/不支持 seek静默跳过交由上传结果反映
pass
def _get_headers(self, extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
"""获取请求头"""
headers = {
'Authorization': self.api_key if self.api_key.startswith('Bearer ') else f'Bearer {self.api_key}',
}
headers = {}
if self._token:
headers['Authorization'] = f'Bearer {self._token}'
if extra_headers:
headers.update(extra_headers)
return headers
def _build_url(self, path: str) -> str:
"""构建完整 URL,自动去掉路径中多余的 /api 前缀"""
"""构建完整 URL
约定base_url 已包含完整地址(如含 /api 前缀则配在环境变量里),
各工具传入的 path 不带 /api 前缀,此处直接拼接。
"""
if path.startswith('http://') or path.startswith('https://'):
return path
# 去掉 /api 前缀,因为 base_url 已经包含完整地址
clean_path = path
if clean_path.startswith('/api'):
clean_path = clean_path[4:]
return f"{self.base_url}{clean_path}"
return f"{self.base_url}{path}"
def _handle_response(self, response: httpx.Response, url: str) -> Dict[str, Any]:
"""统一处理 API 响应"""
def _handle_response(
self,
response: httpx.Response,
url: str,
is_json: Optional[bool] = None,
data: Any = None,
) -> Dict[str, Any]:
"""统一处理 API 响应
is_json / data 为调用方已解析好的 body避免对大响应重复 parse
未传入时此处自行解析一次。
"""
logger.info(f"[API响应] HTTP {response.status_code}")
if response.status_code == 204:
return {"success": True, "data": None}
response.raise_for_status()
# 复用调用方解析结果;未提供则在此解析一次
if is_json is None:
is_json, data = self._try_parse_json(response)
try:
data = response.json()
except json.JSONDecodeError:
# 非 JSON 响应(如文件下载)
# 先看 body 再判断状态码。
# 平台会用非 2xx 状态码 + body 里的 {code, msg} 返回业务错误,
# 若先 raise_for_status() 会在拿到 body 前抛异常,导致真正的 msg 全部丢失。
if not is_json:
# 非 JSON 响应(如 Excel/文件下载,二进制内容 .json() 会抛 UnicodeDecodeError
response.raise_for_status()
return {"success": True, "data": response.content, "raw": True}
# 检查平台 API 的 {code, msg} 格式
# 平台约定code 为 200 或 0 均表示成功(见数据库模块文档 §1.1
if isinstance(data, dict) and 'code' in data:
if data['code'] != 200:
if data['code'] not in (0, 200):
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)
# 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
async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""发送 GET 请求"""
async def _request(
self,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
json_data: Optional[Dict[str, Any]] = None,
files: Optional[Dict[str, Any]] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""统一请求入口自动注入认证、登录态失效401时重登重试一次"""
url = self._build_url(path)
# 账号密码模式下首次请求前先确保有 token纯 api_key 模式 _ensure_token 直接返回
await self._ensure_token()
async def _send() -> httpx.Response:
headers = self._get_headers(extra_headers)
return await self.client.request(
method, url, headers=headers, params=params, json=json_data, files=files
)
try:
logger.info(f"[API请求] GET {url}")
response = await self.client.get(url, headers=self._get_headers(), params=params)
return self._handle_response(response, url)
logger.info(f"[API请求] {method} {url}")
# 记下本次请求所用 token供 401 时做 compare-and-swap 重登
token_used = self._token
response = await _send()
is_json, body = self._try_parse_json(response)
# token 失效:仅在账号密码模式下尝试重新登录并重试一次。
# 平台可能用 HTTP 401也可能用 HTTP 200 + body code=401 表达登录过期,
# 两者都要识别(见 _is_unauthorized
if self._is_unauthorized(response, is_json, body) and self.account and self.password:
logger.warning("[认证] 收到 401登录过期尝试重新登录后重试一次")
# CAS 重登:仅当 token 仍是本次用的旧值才真正重登,否则复用别人刚拿到的新 token
await self._relogin(token_used)
# 重试前把上传文件流游标重置到开头,避免二次发送空内容
self._rewind_files(files)
response = await _send()
is_json, body = self._try_parse_json(response)
# 重登后仍判定为登录态失效:账号密码本身失效/被禁,给明确报错
if self._is_unauthorized(response, is_json, body):
logger.error("[认证] 重新登录后仍返回 401")
raise Exception("重新登录后仍未通过认证,请检查账号密码是否正确或账号是否被禁用")
return self._handle_response(response, url, is_json, body)
except httpx.TimeoutException:
raise Exception(f"API 请求超时: {url}")
except httpx.HTTPStatusError as e:
@@ -111,68 +322,38 @@ class AgileDBAPIClient:
except httpx.RequestError as e:
raise Exception(f"API 请求异常: {url}, 错误: {str(e)}")
async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""发送 GET 请求"""
return await self._request("GET", path, params=params)
async def post(self, path: str, json_data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""发送 POST 请求"""
url = self._build_url(path)
try:
logger.info(f"[API请求] POST {url}")
headers = self._get_headers({'Content-Type': 'application/json'})
response = await self.client.post(url, headers=headers, json=json_data, params=params)
return self._handle_response(response, url)
except httpx.TimeoutException:
raise Exception(f"API 请求超时: {url}")
except httpx.HTTPStatusError as e:
raise Exception(f"API 请求失败 (HTTP {e.response.status_code}): {url}")
except httpx.RequestError as e:
raise Exception(f"API 请求异常: {url}, 错误: {str(e)}")
return await self._request(
"POST", path, params=params, json_data=json_data,
extra_headers={'Content-Type': 'application/json'},
)
async def put(self, path: str, json_data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""发送 PUT 请求"""
url = self._build_url(path)
try:
logger.info(f"[API请求] PUT {url}")
headers = self._get_headers({'Content-Type': 'application/json'})
response = await self.client.put(url, headers=headers, json=json_data, params=params)
return self._handle_response(response, url)
except httpx.TimeoutException:
raise Exception(f"API 请求超时: {url}")
except httpx.HTTPStatusError as e:
raise Exception(f"API 请求失败 (HTTP {e.response.status_code}): {url}")
except httpx.RequestError as e:
raise Exception(f"API 请求异常: {url}, 错误: {str(e)}")
return await self._request(
"PUT", path, params=params, json_data=json_data,
extra_headers={'Content-Type': 'application/json'},
)
async def delete(self, path: str, params: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""发送 DELETE 请求"""
url = self._build_url(path)
try:
logger.info(f"[API请求] DELETE {url}")
headers = self._get_headers()
if json_data is not None:
headers['Content-Type'] = 'application/json'
response = await self.client.delete(url, headers=headers, params=params, json=json_data)
return self._handle_response(response, url)
except httpx.TimeoutException:
raise Exception(f"API 请求超时: {url}")
except httpx.HTTPStatusError as e:
raise Exception(f"API 请求失败 (HTTP {e.response.status_code}): {url}")
except httpx.RequestError as e:
raise Exception(f"API 请求异常: {url}, 错误: {str(e)}")
"""发送 DELETE 请求
平台部分 DELETE 接口需带 body统一走通用 request() 处理。
"""
extra = {'Content-Type': 'application/json'} if json_data is not None else None
return await self._request("DELETE", path, params=params, json_data=json_data, extra_headers=extra)
async def upload(self, path: str, files: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""发送文件上传请求multipart/form-data"""
url = self._build_url(path)
try:
logger.info(f"[API请求] UPLOAD {url}")
# 文件上传不需要 Content-Typehttpx 会自动设置 multipart/form-data
headers = self._get_headers()
response = await self.client.post(url, headers=headers, files=files, params=params)
return self._handle_response(response, url)
except httpx.TimeoutException:
raise Exception(f"API 请求超时: {url}")
except httpx.HTTPStatusError as e:
raise Exception(f"API 请求失败 (HTTP {e.response.status_code}): {url}")
except httpx.RequestError as e:
raise Exception(f"API 请求异常: {url}, 错误: {str(e)}")
"""发送文件上传请求multipart/form-data
不显式设置 Content-Typehttpx 会根据 files 自动生成 multipart 边界。
"""
return await self._request("POST", path, params=params, files=files)
async def close(self):
"""关闭 HTTP 客户端"""

View File

@@ -6,21 +6,28 @@ from typing import Optional
def get_api_key(default: Optional[str] = None) -> str:
"""
获取 API 密钥
获取 API 密钥(可选)
优先级:显式配置了 API_KEY 时直接使用;未配置则返回空串,
由客户端回落到账号密码登录流程换取 token。
Args:
default: 默认值(可选)
Returns:
str: API 密钥
Raises:
ValueError: 当 API_KEY 未设置且无默认值时
str: API 密钥,未配置时为空串
"""
value = os.environ.get("API_KEY", default or "")
if not value:
raise ValueError("环境变量 API_KEY 未设置")
return value
return os.environ.get("API_KEY", default or "")
def get_account(default: Optional[str] = None) -> str:
"""获取登录账号(环境变量 AGILE_DB_ACCOUNT"""
return os.environ.get("AGILE_DB_ACCOUNT", default or "")
def get_password(default: Optional[str] = None) -> str:
"""获取登录密码(环境变量 AGILE_DB_PASSWORD"""
return os.environ.get("AGILE_DB_PASSWORD", default or "")
def get_base_url(default: str = "http://lzwcai-demp-corp-manager:8086") -> str:
@@ -44,7 +51,8 @@ def get_env_config() -> dict:
dict: 包含所有配置的字典
"""
return {
"api_key": get_api_key(""),
"api_key": os.environ.get("API_KEY", ""),
"account": os.environ.get("AGILE_DB_ACCOUNT", ""),
"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 | 是 | 目标数据库名 |
| tableName | string | 是 | 表名(小写字母+数字+下划线) |
| tableComment | string | 否 | 表注释 |
@@ -198,33 +198,29 @@
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| tableId | string | 是 | 表 ID |
| columns | array | 是 | 字段变更数组 |
| columns[].operation | string | 是 | ADD_COLUMN / MODIFY_COLUMN / DROP_COLUMN |
| columns[].columnName | string | 是 | 字段名 |
| columns[].columnType | string | | 字段类型ADD/MODIFY |
| columns[].columnLength | int | | 字段长度 |
| columns[].isPrimaryKey | bool | 否 | 是否主键 |
| columns[].isNullable | bool | 否 | 是否可空 |
| columns[].columnComment | string | 否 | 字段注释 |
| columns[].defaultValue | string | 否 | 默认值 |
| newTableName | string | 否 | 新表名(重命名) |
| newTableComment | string | 否 | 新表注释 |
| connectionId | string | 是 | 数据源连接 ID路径参数 |
| databaseName | string | 是 | 数据库名 |
| tableName | string | 是 | 表名 |
| operations | array | 是 | 表结构变更操作数组 |
| operations[].operation | string | | ADD_COLUMN / DROP_COLUMN / RENAME_COLUMN / ALTER_COLUMN_TYPE / SET_NOT_NULL / DROP_NOT_NULL / SET_DEFAULT / DROP_DEFAULT |
| operations[].column | object | | 列定义(根据 operation 不同包含不同字段,如 columnName/columnType/newColumnName 等) |
| tableComment | string | 否 | 新表注释 |
**返回**:修改结果
---
#### 12. `generate_table_by_description`
- **用途**:通过自然语言描述让 AI 生成表结构
- **用途**:通过自然语言描述让 AI 生成表结构(异步任务)
- **对应前端**CreateBuiltinDataSource.vue AI 生成表结构
- **对应 API**`postGenerateTable(data)` ✅ 已实现 — `POST /api/datasource/connection/generate_table`
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| description | string | 是 | 业务场景描述至少6个字符 |
| requirement | string | 是 | 业务需求描述 |
| databaseId | int | 否 | 关联的数据库 ID |
**返回**AI 生成的表结构(表名、表注释、字段列表含类型/主键/注释等)
**返回**异步任务信息taskId、status需轮询任务状态获取最终生成的表结构
> **场景示例**:用户说"我需要一个商城系统,管理商品、分类和用户评价"AI 返回完整的表结构设计。
@@ -389,6 +385,12 @@
---
#### 23.5 ~~`revoke_api_key_permissions`~~(已废弃,不提供)
- **结论**:权限为「仅追加」模型。真机验证后端 permission 删除接口返回「不支持当前的调用方式」,无法撤销单条已授予权限,故不实现该工具。
- **替代方案**:要缩小某密钥的权限范围,只能「删密钥(`delete_api_key`)→ 重建(`create_api_key`)→ 重新授权(`grant_api_key_permissions`)」。
---
### 🤖 技能与工具管理 (内置数据源 AI 能力)
#### 24. `get_skill_by_datasource`
@@ -415,17 +417,9 @@
---
#### 26. `create_skill`
- **用途**为数据源创建技能
- **对应 API**`postSkillCreateOrGet(data)` ✅ 已实现 — `POST /api/datasource/skill/createOrGet`
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| datasourceId | string | 是 | 数据源 ID |
| name | string | 否 | 技能名称(不传则自动生成) |
| description | string | 否 | 技能描述 |
**返回**:技能 ID
#### 26. ~~`create_skill`~~(已移除,不再单独暴露)
- **结论**:技能(skill)必须挂着工具才有效,平时不会单独创建技能;单独建技能会留下无效的空技能。前端也没有「只建技能」入口。
- **替代方案**:把 SQL 沉淀为工具统一用 `add_sql_tool_to_datasource`(见 §29.5),它内部按需调 `skill/createOrGet` 建技能、写配置模板、再 `confirmTools` 建工具,一步到位且保证技能必有工具。底层 `skill/createOrGet` 端点仍被该编排工具内部使用,只是不再作为独立 MCP 工具暴露。
---
@@ -437,12 +431,14 @@
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| skillId | string | 是 | 技能 ID |
| name | string | | 工具名称 |
| businessDescription | string | 是 | 业务描述 |
| sqlTemplate | string | 是 | SQL 模板(支持 #{param} 参数占位) |
| sqlParams | string | | 参数 JSON Schema默认空对象 |
| resultType | string | | single/list默认 list |
| businessScenario | string | 否 | 业务场景描述 |
| tableIds | array | | 关联的表 ID 数组 |
| suggestions | array | 是 | SQL 工具建议数组(支持批量) |
| suggestions[].name | string | 是 | 工具名称 |
| suggestions[].businessDescription | string | | 业务描述 |
| suggestions[].sqlTemplate | string | | SQL 模板(支持 #{param} 参数占位) |
| suggestions[].sqlParams | string/object | 否 | 参数 JSON Schema对象会自动序列化为字符串 |
| suggestions[].resultType | string | 否 | single/list默认 list |
| suggestions[].businessScenario | string | 否 | 业务场景描述 |
**返回**:工具创建结果
@@ -497,9 +493,15 @@
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| connectionId | int | 是 | 数据源 ID |
| databaseName | string | 是 | 落库目标库名 |
| target | string | 否 | prod/test |
| data | object | 是 | 导入数据(含 tableStructure + allData |
**data.tableStructure.columns 的关键约束**
- `columns` 定义了「这批数据要写入哪些字段」,**导入时这些列必须对应目标表中真实存在的字段**——列名(`columnName`)要与目标表的实际字段名一致,否则后端按列名拼 INSERT 时会报「查询字段不存在 / 字段名称不正确」。
- `allData` 的**首行是列名表头**= `columns[].columnName`),数据从第 2 行起;每行按 `columns` 顺序给出**全部列**的值(全列宽,不裁剪)。表头列名同样必须是目标表存在的字段。
- **导入到已有表时**:前端会用目标表的真实列(`get_table_detail` 返回的 columns覆盖 AI 识别的列。MCP 调用方应等价处理——**先 `get_table_detail` 拿到目标表真实字段,再让 data 里的 columns / 表头 / 数据与之对齐**,不要直接用 Excel 识别出的、可能与表字段不符的列。
**返回**:导入结果
---

View File

@@ -7,6 +7,8 @@ from lzwcai_mcp_agile_db.server import main
import os
if __name__ == "__main__":
os.environ["API_KEY"] = "Bearer eyJhbGciOiJIUzUxMiJ9.eyJ0b2tlbl90eXBlIjoiTE9HSU4iLCJsb2dpbl91c2VyX2tleSI6IjJiMDY0YzMzLTBiZWYtNDU0NC04NWY1LTRmNTFiOGMxMmI5NSJ9.Uv599TvlQvlTlwrnZGo3Tl2eLAvM0ldE9vpMI5jHxbTf4_tVSRA60rUNIV4LBiw6pt1r_xIi7aFcTRE2PeN5sg"
os.environ["backendBaseUrl"] = "https://dempdemo.lzwcai.com"
# 账号密码方式:客户端会在首次请求时自动调用 /login 换取 token
os.environ["AGILE_DB_ACCOUNT"] = "yy8z9"
os.environ["AGILE_DB_PASSWORD"] = "lzwc@2025."
os.environ["backendBaseUrl"] = "http://192.168.2.236:8082/api"
main()

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "lzwcai-mcp-agile-db"
version = "0.1.3"
version = "0.1.17"
description = "MCP server for database management platform with 33 tools for datasource, table, data, API key, and skill management"
readme = "README.md"
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.6"
description = "MCP server for Agile DB third-party datasource APIs"
readme = "README.md"
requires-python = ">=3.10"
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:221] - 文件轮转: 最大10MB, 保留5个备份
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,188 @@
# lzwcai-mcpskills-generate-reports
用户提供 **docx 模板 + JSON 数据**,本包负责渲染成 docx并可选做样式迁移。
本包**不内置模板**,模板完全由调用方维护。
## 安装
```powershell
cd lzwcai_mcpskills_generate_reports
pip install -e .
```
## Python API
### 核心入口 `generate` / `scan_template`
```python
from lzwcai_mcpskills_generate_reports import generate, scan_template
# 扫描模板需要哪些占位符 / for / if 块
result = scan_template("./模板.docx") # 本地路径或 http/https URL
# 渲染
out_path = generate(
data="data.json", # dict 或 JSON 文件路径
template="./模板.docx", # 本地路径或 http/https URL自动下载
out_path="_out/报价方案.docx",
style_ref="./用户样式.docx", # 可选:把用户模板的 theme/字体套过来
)
```
### 便捷封装 `main` 模块
`main` 模块在核心入口之上做了两点增强,适合程序化调用:
1. `data` 除 dict / 本地 JSON 路径外,还支持 **JSON 文件 URL**(自动下载、用完即删)。
2. `out` **可省略**;省略时落到当前目录 `_out/`,文件名按 `模板名_时间戳.docx` 自动生成。
```python
from lzwcai_mcpskills_generate_reports.main import generate_report, scan_report
# data 传 dictout 省略 -> 默认 _out/ 下自动命名
generate_report(template="./模板.docx", data={...})
# data 传本地 JSON 路径
generate_report(template="./模板.docx", data="data.json", out="_out/a.docx")
# template / data 都传 URL
generate_report(
template="https://host/模板.docx",
data="https://host/data.json",
)
# 扫描占位符,支持本地路径或 URL
scan_report(template="https://host/模板.docx")
```
`generate_report` 返回 `{"output": 输出文件绝对路径}`
### 扫描结果结构
```python
# scan_template / scan_report 返回:
# {
# "placeholders": ["project_title", "contact_person", "equipments", ...],
# "blocks": [
# {"type": "for", "iterator": "eq", "variable": "equipments"},
# {"type": "if", "condition": "show_layout"},
# ...
# ]
# }
```
## MCP Server
本包同时提供 MCP Serverstdio 模式),把渲染引擎暴露成 2 个 MCP 工具:
| 工具 | 说明 | 参数 |
|------|------|------|
| `generate_report` | 模板 + 数据 → 渲染输出 docx返回输出文件绝对路径 | 必填 `template``data`;可选 `out`(省略落到 `_out/` 自动命名)、`style_ref` |
| `scan_template` | 扫描模板占位符与 for/if 块结构 | 必填 `template` |
其中 `data` 既可以传 JSON 对象,也可以传指向 JSON 文件的路径或 URL 字符串;`template` 支持本地路径或 http/https URL。数据契约校验由 `generate_report` 内部自动完成(不合法会报错)。
### 启动
```powershell
# 安装后用 console script 启动
lzwcai-mcpskills-generate-reports
# 或以模块方式运行
python -m lzwcai_mcpskills_generate_reports.server
```
### MCP 客户端配置示例
```json
{
"mcpServers": {
"generate-reports": {
"command": "lzwcai-mcpskills-generate-reports"
}
}
}
```
> stdio 模式下 stdout 被 MCP 协议占用,所有日志写入 `logs/` 目录与 stderr。
## 环境变量
| 变量 | 默认 | 说明 |
|------|------|------|
| `LOG_LEVEL` | `INFO` | 日志级别DEBUG/INFO/WARNING/ERROR/CRITICAL。 |
| `LZWCAI_INSECURE_SSL` | 关闭 | 设为 `1`/`true`/`yes` 时,下载模板/数据/图片**关闭 SSL 证书校验**。仅用于内网自签名证书等可信场景,生产慎用。 |
## 数据契约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": "面议"}
]
}
```
- 必填顶层字段:`project_title``contact_person``contact_phone``requirements`
- `requirements` 传列表会自动拼成多行字符串。
- `equipments[].index` 省略时自动从"四"起按中文数字补全(前三章固定为公司简介 / 客户要求 / 布局图)。
- `params[].v` 允许为 `0`、空串等合法假值;仅当键缺失或值为 `null` 才算缺失。
- 图片字段约定:路径/URL → 真实图;`""` → 默认占位图;`None` → 不显示。
## 目录结构
```
lzwcai_mcpskills_generate_reports/
├── pyproject.toml
├── README.md
├── main.py # 使用本包的示例脚本(仓库根,非包内)
├── templates/ # 用户模板(示例,不在包内)
│ └── standard/
│ ├── template.docx
│ └── meta.json
├── samples/ # 示例数据(不在包内)
│ └── sample_data.json
└── lzwcai_mcpskills_generate_reports/ # Python 包
├── __init__.py # 公共 API 入口
├── main.py # 程序化便捷封装URL data / out 可省略)
├── server.py # MCP Server (stdio)
├── pipeline.py # 总入口
├── schema.py # 数据契约 + 校验器
├── render_quote.py # 渲染引擎
├── style_transfer.py # 样式迁移
├── template_scanner.py # 模板占位符扫描
└── utils/ # 下载 / 日志等工具
```
## 模板约定
- 使用 [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,35 @@
# -*- coding: utf-8 -*-
"""
lzwcai-mcpskills-generate-reports
纯渲染引擎,不内置模板。
对外暴露的公共 API
- generate: 数据 + 模板路径 -> docx核心入口
- scan_template: 返回 QuoteData 数据契约结构(该传哪些字段、嵌套结构)
- validate: 校验数据契约
- normalize: 归一化数据
- describe: 返回数据契约结构scan_template 的底层实现)
- transplant_style: 将用户模板样式迁移到结果文档
"""
__version__ = "0.1.0"
from .pipeline import generate
from .template_scanner import scan_template
from .schema import validate, normalize, describe, DEFAULTS
from .style_transfer import transplant_style
from .utils.fetch import is_url, download_to_temp, local_file
__all__ = [
"generate",
"scan_template",
"validate",
"normalize",
"describe",
"transplant_style",
"is_url",
"download_to_temp",
"local_file",
"DEFAULTS",
"__version__",
]

View File

@@ -0,0 +1,78 @@
2026-06-22 22:30:08 - lzwcai_mcpskills_generate_reports.server - ERROR - [server.py:166] - 工具执行失败: generate_report: data 看起来是 JSON 内容但解析失败: Expecting value: line 1 column 1019 (char 1018)
Traceback (most recent call last):
File "E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcpskills_generate_reports\lzwcai_mcpskills_generate_reports\main.py", line 55, in _load_data
obj = json.loads(s)
^^^^^^^^^^^^^
File "D:\anaconda3\Lib\json\__init__.py", line 346, in loads
return _default_decoder.decode(s)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "D:\anaconda3\Lib\json\decoder.py", line 337, in decode
obj, end = self.raw_decode(s, idx=_w(s, 0).end())
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "D:\anaconda3\Lib\json\decoder.py", line 355, in raw_decode
raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1019 (char 1018)
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcpskills_generate_reports\lzwcai_mcpskills_generate_reports\server.py", line 163, in handle_call_tool
result = await anyio.to_thread.run_sync(handler, args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "D:\anaconda3\Lib\site-packages\anyio\to_thread.py", line 63, in run_sync
return await get_async_backend().run_sync_in_worker_thread(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "D:\anaconda3\Lib\site-packages\anyio\_backends\_asyncio.py", line 2502, in run_sync_in_worker_thread
return await future
^^^^^^^^^^^^
File "D:\anaconda3\Lib\site-packages\anyio\_backends\_asyncio.py", line 986, in run
result = context.run(func, *args)
^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcpskills_generate_reports\lzwcai_mcpskills_generate_reports\server.py", line 120, in _do_generate_report
return _generate_report(
^^^^^^^^^^^^^^^^^
File "E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcpskills_generate_reports\lzwcai_mcpskills_generate_reports\main.py", line 99, in generate_report
data = _load_data(data)
^^^^^^^^^^^^^^^^
File "E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcpskills_generate_reports\lzwcai_mcpskills_generate_reports\main.py", line 57, in _load_data
raise ValueError(f"data 看起来是 JSON 内容但解析失败: {e}")
ValueError: data 看起来是 JSON 内容但解析失败: Expecting value: line 1 column 1019 (char 1018)
2026-06-22 22:30:32 - lzwcai_mcpskills_generate_reports.server - ERROR - [server.py:166] - 工具执行失败: generate_report: data 看起来是 JSON 内容但解析失败: Expecting value: line 1 column 1012 (char 1011)
Traceback (most recent call last):
File "E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcpskills_generate_reports\lzwcai_mcpskills_generate_reports\main.py", line 55, in _load_data
obj = json.loads(s)
^^^^^^^^^^^^^
File "D:\anaconda3\Lib\json\__init__.py", line 346, in loads
return _default_decoder.decode(s)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "D:\anaconda3\Lib\json\decoder.py", line 337, in decode
obj, end = self.raw_decode(s, idx=_w(s, 0).end())
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "D:\anaconda3\Lib\json\decoder.py", line 355, in raw_decode
raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1012 (char 1011)
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcpskills_generate_reports\lzwcai_mcpskills_generate_reports\server.py", line 163, in handle_call_tool
result = await anyio.to_thread.run_sync(handler, args)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "D:\anaconda3\Lib\site-packages\anyio\to_thread.py", line 63, in run_sync
return await get_async_backend().run_sync_in_worker_thread(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "D:\anaconda3\Lib\site-packages\anyio\_backends\_asyncio.py", line 2502, in run_sync_in_worker_thread
return await future
^^^^^^^^^^^^
File "D:\anaconda3\Lib\site-packages\anyio\_backends\_asyncio.py", line 986, in run
result = context.run(func, *args)
^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcpskills_generate_reports\lzwcai_mcpskills_generate_reports\server.py", line 120, in _do_generate_report
return _generate_report(
^^^^^^^^^^^^^^^^^
File "E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcpskills_generate_reports\lzwcai_mcpskills_generate_reports\main.py", line 99, in generate_report
data = _load_data(data)
^^^^^^^^^^^^^^^^
File "E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcpskills_generate_reports\lzwcai_mcpskills_generate_reports\main.py", line 57, in _load_data
raise ValueError(f"data 看起来是 JSON 内容但解析失败: {e}")
ValueError: data 看起来是 JSON 内容但解析失败: Expecting value: line 1 column 1012 (char 1011)

View File

@@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
"""
main.py程序化调用入口非 CLI、非 MCP server
两点特性:
1. template / data 既可传本地文件路径,也可传 http/https URL自动下载
2. 输出路径 out 可省略;省略时落到当前目录下的 _out/
文件名按 模板名 + 时间戳 自动生成。
用法:
from lzwcai_mcpskills_generate_reports.main import generate_report, scan_report
# data 传 dict
generate_report(template="./模板.docx", data={...})
# data 传本地 JSON 路径out 省略
generate_report(template="./模板.docx", data="data.json")
# template / data 都传 URL
generate_report(
template="https://host/模板.docx",
data="https://host/data.json",
out="_out/a.docx",
)
# 扫描占位符,支持本地路径或 URL
scan_report(template="https://host/模板.docx")
"""
import json
import os
import sys
import time
try:
sys.stdout.reconfigure(encoding="utf-8")
except (AttributeError, OSError):
pass
from . import generate, scan_template
from .utils.fetch import is_url, local_file
def _load_data(data):
"""把 data 归一化为 dict支持 dict、JSON 内容字符串、本地 JSON 文件路径、或 JSON 文件 URL。
字符串的判定顺序:
1. 以 '{' 开头 -> 当作 JSON 内容直接解析;
2. 否则尝试 JSON 解析(捕获失败则当作路径);
3. 本地路径 / URLURL 会下载到临时文件读取,用完即删)。
"""
if isinstance(data, dict):
return data
if isinstance(data, str):
s = data.strip()
# 1) 看起来就是 JSON 对象内容(路径/URL 不会以 '{' 开头)
if s.startswith("{"):
try:
obj = json.loads(s)
except json.JSONDecodeError as e:
raise ValueError(f"data 看起来是 JSON 内容但解析失败: {e}")
if not isinstance(obj, dict):
raise TypeError("data 为 JSON 内容时必须是对象dict")
return obj
# 2) 尝试当作 JSON 字符串解析(捕获失败说明是路径)
try:
obj = json.loads(s)
if isinstance(obj, dict):
return obj
except (json.JSONDecodeError, ValueError):
pass
# 3) 本地路径 / URL
with local_file(data, suffix=".json") as path:
if not is_url(data) and not os.path.isfile(path):
raise FileNotFoundError(f"数据文件不存在: {data}")
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
raise TypeError(
f"data 必须是 dict、JSON 内容/文件路径/URL 字符串,实际类型: {type(data).__name__}"
)
def _default_out_path(template):
"""out 省略时的默认输出路径:<cwd>/_out/<模板名>_<时间戳>.docx。"""
name = os.path.basename(template.split("?")[0]) # 去掉 URL 查询串
base = os.path.splitext(name)[0] or "report"
stamp = time.strftime("%Y%m%d_%H%M%S")
return os.path.join(os.getcwd(), "_out", f"{base}_{stamp}.docx")
def generate_report(template, data, out=None, style_ref=None):
"""生成 docx 报告。
参数:
template: 模板 docx 文件路径,或 http/https URL自动下载
data: dict、JSON 文件路径,或 JSON 文件 URL自动下载
out: 输出 docx 文件路径省略则用默认路径_out/ 下按模板名+时间戳命名)
style_ref: 用户样式参考 docx 路径或 URL可选
返回:
dict: {"output": 输出文件绝对路径}
"""
data = _load_data(data)
if not out:
out = _default_out_path(template)
out_path = generate(data=data, template=template, out_path=out, style_ref=style_ref)
return {"output": out_path}
def scan_report(template=None):
"""返回 QuoteData 数据契约结构(该传哪些字段、是否必填、嵌套结构)。
参数:
template: 已忽略,保留兼容旧签名。
返回:
dict: 数据契约,见 schema.describe()。
"""
return scan_template(template)

View File

@@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
"""
pipeline.py总入口 — 串起 数据校验 -> 渲染 -> (可选)样式迁移。
注意:本包不维护内置模板。调用方需要自己准备 .docx 模板文件,
并通过 template 参数传入路径。
"""
import os
import json
import contextlib
from .schema import validate, normalize
from .render_quote import render
from .style_transfer import transplant_style
from .utils.fetch import is_url, local_file
@contextlib.contextmanager
def _noop():
"""style_ref 为空时占位用的空上下文,产出 None。"""
yield None
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 文件路径,或 http/https URL自动下载
out_path: 输出 docx 路径
style_ref: 用户上传的样式参考 docx 路径,或 URL可选自动下载
返回:
生成的 docx 绝对路径
"""
data = _load_data(data)
if not isinstance(template, str):
raise TypeError(f"template 必须是文件路径或 URL 字符串,实际类型: {type(template).__name__}")
# 归一化 + 校验
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)
# 把 template / style_ref 统一解析成本地路径URL 会下载到临时文件,用完即删)
with local_file(template) as template_path, \
(local_file(style_ref) if style_ref else _noop()) as style_path:
if not is_url(template) and not os.path.isfile(template_path):
raise FileNotFoundError(f"模板文件不存在: {template}")
template_path = os.path.abspath(template_path)
# 渲染(优先读取同目录 meta.json 作为图片配置URL 模板无同目录 meta则为空
meta = _load_meta_for_template(template_path)
render(data, out_path, template_path, meta=meta)
# 可选样式迁移
if style_ref:
if not is_url(style_ref) and not os.path.isfile(style_path):
raise FileNotFoundError(f"样式参考文件不存在: {style_ref}")
transplant_style(out_path, style_path, out_path)
return os.path.abspath(out_path)

View File

@@ -0,0 +1,160 @@
# -*- 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 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
from .utils.fetch import make_ssl_context
def _resolve_image_path(src, tmp_files):
"""把图片字段值解析为本地文件路径。
下载成功的临时文件会记录到 tmp_files由调用方在渲染结束后统一清理。
"""
def _download(url):
try:
ctx = make_ssl_context()
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,194 @@
# -*- 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
# 用"键是否存在/为空"判断避免把合法假值0、False、空串当 v误判为缺失
if not p.get("k"):
errors.append(f"equipments[{i}].params[{j}] 缺少 k")
if "v" not in p or p.get("v") is None:
errors.append(f"equipments[{i}].params[{j}] 缺少 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
def describe():
"""返回 QuoteData 数据契约结构(该传哪些字段、是否必填、嵌套结构)。
与 validate / normalize 保持一致,是「调用方该如何组织数据」的权威说明,
可直接对照 samples/sample_data.json。返回新副本调用方可安全修改。
"""
return {
"type": "QuoteData",
"required": ["project_title", "contact_person", "contact_phone", "requirements"],
"fields": {
"project_title": {"type": "string", "required": True, "desc": "项目/方案标题"},
"contact_person": {"type": "string", "required": True, "desc": "联系人"},
"contact_phone": {"type": "string", "required": True, "desc": "联系电话"},
"contact_company": {"type": "string", "required": False, "desc": "客户公司名(模板用到才生效)"},
"requirements": {
"type": "string | list[string]",
"required": True,
"desc": "客户要求;传列表会自动拼成多行字符串",
},
"layout_image": {"type": "string", "required": False, "desc": "整线布局图,本地路径或 URL空串=占位图None=不显示"},
"layout_title": {"type": "string", "required": False, "default": DEFAULTS["layout_title"]},
"show_layout": {"type": "bool", "required": False, "default": DEFAULTS["show_layout"]},
"equipments": {
"type": "list",
"required": False,
"desc": "设备清单",
"item": {
"name": {"type": "string", "required": True, "desc": "设备名称"},
"index": {"type": "string", "required": False, "desc": "章节序号,缺省自动按中文数字(四、五…)补全"},
"images": {"type": "list[string]", "required": False, "desc": "设备图,路径或 URL 列表;缺省为 ['']"},
"features": {
"type": "list",
"required": False,
"item": {
"title": {"type": "string", "required": True, "desc": "特点标题"},
"lines": {"type": "list[string]", "required": True, "desc": "特点说明,多行"},
},
},
"params": {
"type": "list",
"required": False,
"item": {
"k": {"type": "string", "required": True, "desc": "参数名"},
"v": {"type": "string | number", "required": True, "desc": "参数值,允许 0/空串等合法假值"},
},
},
},
},
"quote_items": {
"type": "list",
"required": False,
"desc": "报价表条目",
"item": {
"name": {"type": "string", "required": True, "desc": "条目名称"},
"qty": {"type": "string", "required": False, "desc": "数量,如 '1套'"},
"image": {"type": "string", "required": False, "desc": "条目图,路径或 URL缺省为空串"},
"desc": {"type": "string", "required": False, "desc": "条目说明"},
"price": {"type": "string", "required": False, "desc": "价格,如 '面议'"},
},
},
},
"auto_generated": {
"section_quote_table": "报价表章节序号,按设备数量自动计算,无需提供",
"section_after_sales": "售后服务章节序号,按设备数量自动计算,无需提供",
},
}

View File

@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""
lzwcai-mcpskills-generate-reports MCP Server
把 docx 模板渲染引擎封装成 MCP 工具,提供两个工具:
- generate_report: 数据 + 模板路径 -> 渲染输出 docx
- scan_template: 返回 QuoteData 数据契约结构(该传哪些字段、嵌套结构)
stdio 模式运行;所有日志走 stderrstdout 留给 MCP 协议。
"""
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 scan_template
from .main import generate_report as _generate_report
except ImportError:
from lzwcai_mcpskills_generate_reports.utils.logger_config import (
setup_system_logging, get_logger,
)
from lzwcai_mcpskills_generate_reports import scan_template
from lzwcai_mcpskills_generate_reports.main import (
generate_report as _generate_report,
)
# 初始化日志系统
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")
# ── 工具定义 ──────────────────────────────────────────────
_DATA_DESC = "报价数据:可以是 JSON 对象,或指向 JSON 文件的路径/URL 字符串"
TOOL_DEFS = [
types.Tool(
name="generate_report",
description=(
"用 docx 模板 + 结构化数据渲染生成报价文档 docx返回输出文件绝对路径。"
"模板由调用方提供,本工具不内置模板。"
),
inputSchema={
"type": "object",
"properties": {
"template": {
"type": "string",
"description": "模板 docx 文件路径,或 http/https URL会自动下载必填",
},
"data": {
"type": "string",
"description": _DATA_DESC + "(必填)",
},
"out": {
"type": "string",
"description": "输出 docx 文件路径(可选);省略则落到当前目录 _out/,按 模板名_时间戳.docx 自动命名",
},
"style_ref": {
"type": "string",
"description": "用户上传的样式参考 docx 路径或 URL可选会把其 theme/字体套到结果文档",
},
},
"required": ["template", "data"],
},
# 输出契约:成功返回 {"output": 路径};失败返回 {"error":..., "tool_name":...}。
# MCP 要求 outputSchema 顶层必须是 type:"object",所以 oneOf 只用来约束
# 两种 required 组合,否则出错时 structuredContent 会过不了校验。
outputSchema={
"type": "object",
"properties": {
"output": {"type": "string", "description": "输出 docx 文件绝对路径"},
"error": {"type": "string", "description": "错误信息"},
"tool_name": {"type": "string"},
},
"oneOf": [
{"required": ["output"]},
{"required": ["error", "tool_name"]},
],
},
),
types.Tool(
name="scan_template",
description=(
"返回 QuoteData 数据契约结构该传哪些字段、是否必填、equipments/features/params/"
"quote_items 等嵌套结构,以及自动生成无需提供的字段。用于在渲染前了解数据该如何组织。"
),
inputSchema={
"type": "object",
"properties": {
"template": {
"type": "string",
"description": "(已忽略,保留兼容)模板路径或 URL所有模板共用同一数据契约",
},
},
"required": [],
},
),
]
@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:
# 复用 main.generate_reportdata 支持 URLout 可省略(落到 _out/ 自动命名)
return _generate_report(
template=arguments["template"],
data=arguments["data"],
out=arguments.get("out"),
style_ref=arguments.get("style_ref"),
)
def _do_scan_template(arguments: dict) -> dict:
return scan_template(arguments.get("template"))
_HANDLERS = {
"generate_report": _do_generate_report,
"scan_template": _do_scan_template,
}
@server.call_tool()
async def handle_call_tool(
name: str,
arguments: dict | None,
) -> tuple[list[types.TextContent], dict]:
"""调用工具。
返回 (content, structured) 二元组:
- structured: 结构化结果 dictSDK 自动填入响应的 structuredContent
调用方可直接取值,无需再对 content[].text 做 json.loads。
- content: 序列化后的 TextContent保留对老客户端的向后兼容。
"""
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}
content = [
types.TextContent(
type="text",
text=json.dumps(result, ensure_ascii=False, indent=2),
)
]
return content, result
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,23 @@
# -*- coding: utf-8 -*-
"""
template_scanner.py返回 QuoteData 数据契约结构。
历史上本模块扫描 docx 模板里的 Jinja2 占位符;现已改为直接返回
schema.describe() 的数据契约(该传哪些字段、是否必填、嵌套结构),
更直观、可直接对照 samples/sample_data.json。
保留 scan_template 这个名字与 template 参数,向后兼容既有调用方。
"""
from .schema import describe
def scan_template(template_path=None):
"""返回 QuoteData 数据契约结构。
参数:
template_path: 兼容旧签名,已忽略(所有模板共用同一数据契约)。
返回:
dict: 数据契约,见 schema.describe()。
"""
return describe()

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,77 @@
# -*- coding: utf-8 -*-
"""
fetch.py把远程文件地址http/https下载成本地临时文件。
用于让 generate / scan_template 既能接收本地路径,也能直接接收一个 URL
下载后按本地文件处理,用完即删。
"""
import contextlib
import os
import ssl
import tempfile
import urllib.request
def is_url(s):
"""判断字符串是否是 http/https URL。"""
return isinstance(s, str) and s.lower().startswith(("http://", "https://"))
def make_ssl_context():
"""构造下载用的 SSL 上下文。
默认开启证书校验(安全)。仅当环境变量 LZWCAI_INSECURE_SSL 设为
1/true/yes 时才关闭校验,用于内网自签名证书等可信场景。
"""
ctx = ssl.create_default_context()
if os.environ.get("LZWCAI_INSECURE_SSL", "").lower() in ("1", "true", "yes"):
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def download_to_temp(url, suffix=".docx"):
"""下载 URL 内容到临时文件,返回本地路径。
参数:
url: 远程文件地址http/https
suffix: 当 URL 末尾无扩展名时使用的默认后缀
返回:
本地临时文件绝对路径(调用方负责删除)
"""
ctx = make_ssl_context()
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
data = urllib.request.urlopen(req, context=ctx, timeout=60).read()
ext = os.path.splitext(url.split("?")[0])[1] or suffix
fd, path = tempfile.mkstemp(suffix=ext)
try:
os.write(fd, data)
finally:
os.close(fd)
return path
@contextlib.contextmanager
def local_file(path_or_url, suffix=".docx"):
"""统一把"本地路径或 URL"解析成本地路径的上下文管理器。
- 传入本地路径:原样产出,退出时不删除。
- 传入 URL下载到临时文件并产出其路径退出时自动删除。
用法:
with local_file(template) as path:
... 用 path 读模板 ...
"""
if is_url(path_or_url):
tmp = download_to_temp(path_or_url, suffix=suffix)
try:
yield tmp
finally:
try:
os.remove(tmp)
except OSError:
pass
else:
yield path_or_url

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)

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