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参数传递方式
- 补充技能与工具管理流程,提供更清晰的操作指引
- 新增数字员工平台数据库技能配置指南文档
```
This commit is contained in:
2026-06-26 16:21:41 +08:00
parent ba5cd4bbe1
commit 635313a7ab
43 changed files with 3464 additions and 686 deletions

Binary file not shown.

View File

@@ -2,7 +2,7 @@
name: lzwcai-agile-db name: lzwcai-agile-db
description: AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据库操作工作流指导,适合零基础用户。 description: AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据库操作工作流指导,适合零基础用户。
metadata: metadata:
version: 0.4.2 version: 0.4.4
--- ---
# lzwcai-agile-db # lzwcai-agile-db
@@ -97,17 +97,16 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
| `toggle_api_key_status` | 启用/禁用 API 密钥 | 中等 | | `toggle_api_key_status` | 启用/禁用 API 密钥 | 中等 |
| `delete_api_key` | 删除 API 密钥 | **危险** | | `delete_api_key` | 删除 API 密钥 | **危险** |
| `get_api_key_permissions` | 查看指定密钥的权限配置 | 安全 | | `get_api_key_permissions` | 查看指定密钥的权限配置 | 安全 |
| `grant_api_key_permissions` | 批量为 API 密钥授予权限 | **危险** | | `grant_api_key_permissions` | 批量为 API 密钥授予权限(仅追加,不可撤销) | **危险** |
| `revoke_api_key_permissions` | 撤销/删除已授予的权限(按权限记录 ID | **危险** |
### 九、技能与工具管理7 个工具) ### 九、技能与工具管理7 个工具)
| 工具 | 功能 | 危险等级 | | 工具 | 功能 | 危险等级 |
|------|------|----------| |------|------|----------|
| `add_sql_tool_to_datasource` | 把 SQL 沉淀为工具(一步到位,自动建技能+配模板+建工具,去重幂等)**唯一推荐入口** | 中等 |
| `get_skill_by_datasource` | 根据数据源获取技能信息 | 安全 | | `get_skill_by_datasource` | 根据数据源获取技能信息 | 安全 |
| `get_skill_tools` | 获取技能下的工具列表 | 安全 | | `get_skill_tools` | 获取技能下的工具列表 | 安全 |
| `create_skill` | 为数据源创建技能 | 中等 | | `create_sql_tool` | 将 SQL 创建为可复用工具(底层积木,需技能已存在) | 中等 |
| `create_sql_tool` | 将 SQL 查询创建为可复用工具 | 中等 |
| `delete_skill_tool` | 删除技能下的工具 | **危险** | | `delete_skill_tool` | 删除技能下的工具 | **危险** |
| `update_skill_config` | 更新技能配置(名称/描述/模板) | 中等 | | `update_skill_config` | 更新技能配置(名称/描述/模板) | 中等 |
| `update_skill_tool` | 修改技能工具id+description+uniqueName | 中等 | | `update_skill_tool` | 修改技能工具id+description+uniqueName | 中等 |
@@ -188,16 +187,19 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
当执行以下类型的操作时,**必须先询问用户确认**,不得擅作主张: 当执行以下类型的操作时,**必须先询问用户确认**,不得擅作主张:
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. **疑似违规内容**:涉及色情、暴力、违法等内容的操作
- 说明:可能违反法律法规,必须拒绝执行并告知用户 - 说明:可能违反法律法规,必须拒绝执行并告知用户
#### 确认格式 #### 确认格式
@@ -448,6 +450,8 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
当用户需要修改表中的数据时,使用此流程。 当用户需要修改表中的数据时,使用此流程。
> 🔒 **写操作统一规则(插入 / 更新 / 删除都适用)**:在调用 `insert_table_row` / `update_table_row` / `delete_table_rows` 之前,**必须先把将要写入/修改/删除的具体数据预览给用户,并等待用户明确确认后才执行**。不得在用户未确认的情况下直接落库。
### 4.1 插入数据 ### 4.1 插入数据
``` ```
@@ -457,9 +461,11 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
2. 确认必填字段(非空字段、无默认值字段) 2. 确认必填字段(非空字段、无默认值字段)
3. 调用 insert_table_row(tableId="xx", data={...}) 3. 向用户展示将要插入的数据,询问"确认插入以上数据?",等待用户确认
4. 确认插入成功 4. 用户确认后,调用 insert_table_row(tableId="xx", data={...})
5. 确认插入成功
``` ```
**示例**: **示例**:
@@ -470,6 +476,12 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
调用: get_table_detail(tableId="5") 调用: get_table_detail(tableId="5")
返回: {columns: [{name: "id", isPrimaryKey: true, isAutoIncrement: true}, {name: "username", isNullable: false}, ...]} 返回: {columns: [{name: "id", isPrimaryKey: true, isAutoIncrement: true}, {name: "username", isNullable: false}, ...]}
回复: 即将向 users 表插入以下数据:
username = test_user, email = test@test.com
id 自动生成)。确认插入吗?
用户: "确认"
调用: insert_table_row( 调用: insert_table_row(
tableId="5", tableId="5",
data={"username": "test_user", "email": "test@test.com"} data={"username": "test_user", "email": "test@test.com"}
@@ -484,9 +496,11 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
1. 确认主键字段和要更新的值 1. 确认主键字段和要更新的值
2. 调用 update_table_row(tableId="xx", primaryKey={主键}, data={要更新的字段}) 2. 向用户展示「目标行 + 改动前→改动后」的预览,询问"确认更新?",等待用户确认
3. 确认更新成功 3. 用户确认后,调用 update_table_row(tableId="xx", primaryKey={主键}, data={要更新的字段})
4. 确认更新成功
``` ```
**示例**: **示例**:
@@ -494,6 +508,12 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
**用户**: "把 ID 为 5 的用户邮箱改成 new@test.com" **用户**: "把 ID 为 5 的用户邮箱改成 new@test.com"
``` ```
回复: 即将更新 users 表 ID=5 的记录:
email: old@test.com → new@test.com
确认更新吗?
用户: "确认"
调用: update_table_row( 调用: update_table_row(
tableId="5", tableId="5",
primaryKey={"id": 5}, primaryKey={"id": 5},
@@ -566,7 +586,7 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
``` ```
### 注意事项 ### 注意事项
- **删除操作必须二次确认** - **写操作(插入/更新/删除)执行前必须先预览数据并等待用户确认**;删除操作不可恢复,需格外谨慎
- `primaryKey` 必须是对象格式,如 `{"id": 1}` - `primaryKey` 必须是对象格式,如 `{"id": 1}`
- `primaryKeys` 是数组格式,如 `[{"id": 1}, {"id": 2}]` - `primaryKeys` 是数组格式,如 `[{"id": 1}, {"id": 2}]`
- `data` 只包含要更新的字段,不需要提供全部字段 - `data` 只包含要更新的字段,不需要提供全部字段
@@ -711,6 +731,47 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
6. 确认导入成功(返回含 insertedRows 插入行数) 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 - 文件大小限制:< 500KB
- 支持格式:.xlsx / .xls - 支持格式:.xlsx / .xls
@@ -720,6 +781,7 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
- `file_url` 对应的文件扩展名需为 `.xlsx` / `.xls` - `file_url` 对应的文件扩展名需为 `.xlsx` / `.xls`
- **`confirm_import_data` 必须传 `databaseName`**(落库目标库);`data` 直接传 `preview_import_data` 的返回原文即可,工具内部会自动解包并组装成 `{tableStructure(含databaseName), allData}` - **`confirm_import_data` 必须传 `databaseName`**(落库目标库);`data` 直接传 `preview_import_data` 的返回原文即可,工具内部会自动解包并组装成 `{tableStructure(含databaseName), allData}`
- AI 识别会把中文表头转成英文列名(如「姓名」→`name`);若导入数据键名与生成的列名对不上会报「未找到 XX 字段」,此时需按预览返回的列名核对 - AI 识别会把中文表头转成英文列名(如「姓名」→`name`);若导入数据键名与生成的列名对不上会报「未找到 XX 字段」,此时需按预览返回的列名核对
- 后端报「插入数据失败第N行查询字段不存在/字段名称不正确」时,多半是 **allData 缺了列名表头行**(后端把首行数据当成了字段名),或某行列数与 `columns` 对不上——确认首行是列名、每行值个数 = 列总数
--- ---
@@ -798,28 +860,55 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
- `database`:数据库级别权限 - `database`:数据库级别权限
- `table`:表级别权限 - `table`:表级别权限
### 7.7 撤销权限 > ⚠️ 权限为「仅追加」模型:`grant_api_key_permissions` 只能新增权限,不会覆盖已有权限,且后端**不支持撤销/删除已授予的权限**。授权前务必确认范围,授错只能删掉整个密钥(`delete_api_key`)后重建。
### 7.7 调整权限(只能重建密钥)
``` ```
调用: get_api_key_permissions(apiKeyId="7") # 后端不支持撤销单条权限。如需收回某密钥的权限,只能删除密钥后重新创建并重新授权:
返回: { 调用: delete_api_key(id="7")
"data": { 调用: create_api_key(apiKeyName="xxx")
"connectionPermissions": [{"id": "101", "connectionId": "58", "permissionType": "read"}], 调用: grant_api_key_permissions(apiKeyId="<新密钥ID>", batchDatas=[...])
"databasePermissions": [...],
"tablePermissions": [...]
}
}
调用: revoke_api_key_permissions(permissionIds=["101"])
``` ```
> 说明:`revoke_api_key_permissions` 按权限记录的 `id` 删除,需先从 `get_api_key_permissions` 获取 > 说明:后端 permission 删除接口返回「不支持当前的调用方式」,无法撤销单条已授予权限。要缩小权限范围,走「删密钥 → 重建 → 重新授权」
--- ---
## 场景 8技能与工具管理 ## 场景 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 查看数据源关联的技能 ### 8.1 查看数据源关联的技能
@@ -827,21 +916,23 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
调用: get_skill_by_datasource(datasourceId="58") 调用: get_skill_by_datasource(datasourceId="58")
``` ```
### 8.2 创建技能
```
调用: create_skill(datasourceId="58", name="订单查询技能", description="用于订单数据的常用查询")
```
### 8.3 查看技能下的工具 ### 8.3 查看技能下的工具
``` ```
调用: get_skill_tools(skillId="xx") 调用: 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( 调用: create_sql_tool(
skillId="xx", skillId="xx",
tableIds=["5"], tableIds=["5"],
@@ -849,7 +940,7 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
"name": "查询活跃用户", "name": "查询活跃用户",
"businessDescription": "查询所有状态为活跃的用户", "businessDescription": "查询所有状态为活跃的用户",
"sqlTemplate": "SELECT * FROM users WHERE status = #{status}", "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", "resultType": "list",
"businessScenario": "用于查看当前活跃用户列表" "businessScenario": "用于查看当前活跃用户列表"
}] }]
@@ -870,12 +961,24 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
### 8.6 更新技能配置 ### 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( 调用: update_skill_config(
datasourceId="58", 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 修改技能下某个工具 ### 8.7 修改技能下某个工具
``` ```

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

@@ -57,16 +57,16 @@ lzwcai-mcp-agile-db
- `toggle_api_key_status` - 启用/禁用密钥 - `toggle_api_key_status` - 启用/禁用密钥
- `delete_api_key` - 删除密钥 - `delete_api_key` - 删除密钥
- `get_api_key_permissions` - 查看密钥权限 - `get_api_key_permissions` - 查看密钥权限
- `grant_api_key_permissions` - 授予权限 - `grant_api_key_permissions` - 授予权限(仅追加,不可撤销)
- `revoke_api_key_permissions` - 撤销/删除已授予权限
### 技能与工具管理 ### 技能与工具管理
- `add_sql_tool_to_datasource` - 把 SQL 沉淀为工具(一步到位,自动建技能+配模板+建工具,推荐入口)
- `get_skill_by_datasource` - 获取技能信息 - `get_skill_by_datasource` - 获取技能信息
- `get_skill_tools` - 获取技能工具列表 - `get_skill_tools` - 获取技能工具列表
- `create_skill` - 创建技能 - `create_sql_tool` - 创建 SQL 工具(需技能已存在)
- `create_sql_tool` - 创建 SQL 工具
- `delete_skill_tool` - 删除技能工具 - `delete_skill_tool` - 删除技能工具
- `update_skill_config` - 更新技能配置 - `update_skill_config` - 更新技能配置
- `update_skill_tool` - 修改技能工具
### 数据导入 ### 数据导入
- `preview_import_data` - 预览导入数据 - `preview_import_data` - 预览导入数据

View File

@@ -1,10 +1,377 @@
2026-06-17 11:19:35 - root - INFO - [logger_config.py:102] - 日志系统初始化完成 - 日志目录: E:\yh-ai\project\lzwcai-szyg\lzwcai-mcp-server-package\lzwcai_mcp_agile_db\lzwcai_mcp_agile_db\logs 2026-06-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-17 11:19:35 - 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:162] - Initializing server 'lzwcai_mcp_agile_db'
2026-06-17 11:19:35 - 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:439] - Registering handler for ListToolsRequest
2026-06-17 11:19:35 - mcp.server.lowlevel.server - DEBUG - [server.py:519] - Registering handler for CallToolRequest 2026-06-22 23:02:48 - mcp.server.lowlevel.server - DEBUG - [server.py:519] - Registering handler for CallToolRequest
2026-06-17 11:19:35 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:48] - [客户端初始化] base_url=http://x 2026-06-22 23:02:48 - asyncio - DEBUG - [proactor_events.py:634] - Using proactor: IocpProactor
2026-06-17 11:19:35 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:78] - [API响应] HTTP 400 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-17 11:19:35 - lzwcai_mcp_agile_db.utils.api_client - ERROR - [api_client.py:98] - [API错误] HTTP 400, 参数不合法 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-17 11:19:35 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:78] - [API响应] HTTP 200 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-17 11:19:35 - lzwcai_mcp_agile_db.utils.api_client - INFO - [api_client.py:78] - [API响应] HTTP 404 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-17 11:19:35 - lzwcai_mcp_agile_db.utils.api_client - ERROR - [api_client.py:107] - [API错误] HTTP 404, {"path": "/x"} 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-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: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,2 +1 @@
2026-06-17 11:19:35 - lzwcai_mcp_agile_db.utils.api_client - ERROR - [api_client.py:98] - [API错误] HTTP 400, 参数不合法 2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - ERROR - [api_client.py:314] - [认证] 重新登录后仍返回 401
2026-06-17 11:19:35 - lzwcai_mcp_agile_db.utils.api_client - ERROR - [api_client.py:107] - [API错误] HTTP 404, {"path": "/x"}

View File

@@ -111,7 +111,7 @@ async def run_server():
streams[1], streams[1],
InitializationOptions( InitializationOptions(
server_name="lzwcai_mcp_agile_db", server_name="lzwcai_mcp_agile_db",
server_version="0.1.12", server_version="0.1.8",
capabilities=server.get_capabilities( capabilities=server.get_capabilities(
notification_options=NotificationOptions(), notification_options=NotificationOptions(),
experimental_capabilities={}, experimental_capabilities={},

View File

@@ -1,5 +1,8 @@
""" """
API 密钥管理工具(含创建、状态切换、删除、权限查询/授予/撤销 API 密钥管理工具(含创建、状态切换、删除、权限查询/授予)
注意权限模型为「仅追加」——grant_api_key_permissions 只能新增权限,后端不支持撤销/删除
已授予的权限(真机验证 permission 删除接口返回「不支持当前的调用方式」),故不提供 revoke 工具。
""" """
from ._base import register_tool, ToolDef from ._base import register_tool, ToolDef
@@ -93,7 +96,7 @@ class GetApiKeyPermissionsTool(ToolDef):
@register_tool("grant_api_key_permissions") @register_tool("grant_api_key_permissions")
class GrantApiKeyPermissionsTool(ToolDef): class GrantApiKeyPermissionsTool(ToolDef):
name = "grant_api_key_permissions" name = "grant_api_key_permissions"
description = "批量为 API 密钥授予权限" description = "批量为 API 密钥授予权限(仅追加,不会覆盖或删除已有权限;后端不支持撤销已授予的权限)"
input_schema = { input_schema = {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -119,34 +122,3 @@ class GrantApiKeyPermissionsTool(ToolDef):
async def execute(self, args: dict) -> dict: async def execute(self, args: dict) -> dict:
return await self.client.post("/datasource/api_key/permission/grant_batch", json_data=args) return await self.client.post("/datasource/api_key/permission/grant_batch", json_data=args)
@register_tool("revoke_api_key_permissions")
class RevokeApiKeyPermissionsTool(ToolDef):
name = "revoke_api_key_permissions"
description = "撤销/删除 API 密钥已授予的权限(按权限记录 ID"
input_schema = {
"type": "object",
"properties": {
"permissionIds": {
"type": "array",
"items": {"type": "string"},
"description": (
"权限记录 ID 列表。"
"先从 get_api_key_permissions 获取,"
"取 connectionPermissions / databasePermissions / tablePermissions 中每项的 id 字段"
),
},
},
"required": ["permissionIds"],
}
async def execute(self, args: dict) -> dict:
args = dict(args)
permission_ids = args.pop("permissionIds", None) or []
# 过滤掉空字符串/None防止拼接出类似 "1,,2" 的非法 ID
permission_ids = [pid for pid in permission_ids if pid is not None and str(pid).strip()]
if not permission_ids:
raise ValueError("permissionIds 不能为空")
ids = ",".join(str(pid).strip() for pid in permission_ids)
return await self.client.delete(f"/datasource/api_key/permission/{ids}")

View File

@@ -125,19 +125,44 @@ class PreviewImportDataTool(ToolDef):
class ConfirmImportDataTool(ToolDef): class ConfirmImportDataTool(ToolDef):
name = "confirm_import_data" name = "confirm_import_data"
description = ( description = (
"确认导入 AI 识别后的数据(建表+插数据)" "确认导入 AI 识别后的数据(建表+插数据),第二步。第一步先调 preview_import_data。\n"
"传入 preview_import_data 返回的 data 原文 + databaseName 即可," "【data 传什么】把 preview_import_data 返回的 data 原文整块传给 data 参数即可,"
"工具内部会自动组装成后端要求的 {tableStructure(含databaseName), allData} 结构" "工具会自动解包并组装成后端要求的 {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 = { input_schema = {
"type": "object", "type": "object",
"properties": { "properties": {
"connectionId": {"type": "string", "description": "数据源连接 ID"}, "connectionId": {"type": "string", "description": "数据源连接 ID(同 preview 用的那个)"},
"databaseName": {"type": "string", "description": "落库的数据库名(导入目标库)"}, "databaseName": {"type": "string", "description": "落库的数据库名(导入目标库)。必填——顶层不给会尝试从 data.databaseName 回捞"},
"target": {"type": "string", "enum": ["prod", "test"], "default": "test", "description": "环境,默认 test"}, "target": {"type": "string", "enum": ["prod", "test"], "default": "test", "description": "环境,默认 test。可放顶层或 data 里"},
"data": {"type": "object", "description": "preview_import_data 返回的 data含 tableStructure/allData或已组装好的最终结构"}, "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 @staticmethod
@@ -160,14 +185,15 @@ class ConfirmImportDataTool(ToolDef):
ts = data.get("tableStructure") ts = data.get("tableStructure")
single_table = None single_table = None
ts_inner = {}
if isinstance(ts, dict): if isinstance(ts, dict):
if "columns" in ts: if "columns" in ts:
# 已是单表对象(调用方自行组装过) # 已是单表对象(调用方自行组装过)
single_table = dict(ts) single_table = dict(ts)
else: else:
# preview 包装tableStructure.data.tables[0] # preview 包装tableStructure.data.tables[0]
inner = ts.get("data") if isinstance(ts.get("data"), dict) else {} ts_inner = ts.get("data") if isinstance(ts.get("data"), dict) else {}
tables = inner.get("tables") if isinstance(inner, dict) else None tables = ts_inner.get("tables") if isinstance(ts_inner, dict) else None
if isinstance(tables, list) and tables: if isinstance(tables, list) and tables:
single_table = dict(tables[0]) single_table = dict(tables[0])
@@ -178,19 +204,119 @@ class ConfirmImportDataTool(ToolDef):
if database_name and not single_table.get("databaseName"): if database_name and not single_table.get("databaseName"):
single_table["databaseName"] = database_name single_table["databaseName"] = database_name
all_data = data.get("allData") # 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: if all_data is None:
all_data = data.get("data") or [] 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} 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: async def execute(self, args: dict) -> dict:
args = dict(args) args = dict(args)
connection_id = args.pop("connectionId") connection_id = args.pop("connectionId")
target = args.pop("target", "test") target = args.pop("target", None)
database_name = args.pop("databaseName", None) database_name = args.pop("databaseName", None)
data = args.pop("data") 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) 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( return await self.client.post(
f"/datasource/connection/{connection_id}/import_document/confirm", f"/datasource/connection/{connection_id}/import_document/confirm",
json_data=body, json_data=body,

View File

@@ -1,5 +1,9 @@
""" """
技能与工具管理工具 (工具 24-29) 技能与工具管理工具
把 SQL 沉淀为数据源可复用工具的入口统一走 add_sql_tool_to_datasource保证技能必有工具
内部按需建技能+写配置+建工具)。其余为底层积木:查询类、向已有技能加/改工具、改技能配置。
不单独暴露「只建技能」工具,避免产生无效空技能。
""" """
import json import json
@@ -39,24 +43,6 @@ class GetSkillToolsTool(ToolDef):
return await self.client.get(f"/datasource/skill/getBySkillId/{args['skillId']}") 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") @register_tool("create_sql_tool")
class CreateSqlToolTool(ToolDef): class CreateSqlToolTool(ToolDef):
name = "create_sql_tool" name = "create_sql_tool"
@@ -79,7 +65,7 @@ class CreateSqlToolTool(ToolDef):
"name": {"type": "string", "description": "工具名称"}, "name": {"type": "string", "description": "工具名称"},
"businessDescription": {"type": "string", "description": "业务描述"}, "businessDescription": {"type": "string", "description": "业务描述"},
"sqlTemplate": {"type": "string", "description": "SQL 模板(支持 #{param} 参数占位)"}, "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"}, "resultType": {"type": "string", "enum": ["single", "list"], "default": "list", "description": "结果类型,默认 list"},
"businessScenario": {"type": "string", "description": "业务场景描述"}, "businessScenario": {"type": "string", "description": "业务场景描述"},
}, },
@@ -92,11 +78,17 @@ class CreateSqlToolTool(ToolDef):
async def execute(self, args: dict) -> dict: async def execute(self, args: dict) -> dict:
args = dict(args) 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): if "suggestions" in args and isinstance(args["suggestions"], list):
for suggestion in args["suggestions"]: for suggestion in args["suggestions"]:
if "sqlParams" in suggestion and isinstance(suggestion["sqlParams"], dict): if "sqlParams" in suggestion and isinstance(suggestion["sqlParams"], dict):
suggestion["sqlParams"] = json.dumps(suggestion["sqlParams"]) suggestion["sqlParams"] = json.dumps(suggestion["sqlParams"])
suggestion.setdefault("resultType", "list")
return await self.client.post("/datasource/skill/confirmTools", json_data=args) return await self.client.post("/datasource/skill/confirmTools", json_data=args)
@@ -119,38 +111,85 @@ class DeleteSkillToolTool(ToolDef):
@register_tool("update_skill_config") @register_tool("update_skill_config")
class UpdateSkillConfigTool(ToolDef): class UpdateSkillConfigTool(ToolDef):
name = "update_skill_config" name = "update_skill_config"
description = "更新技能(名称/描述/MCP 配置模板等)。对应后端 skill/updateOrGet" description = (
"更新技能(名称/描述/MCP 配置模板等)。对应后端 skill/updateOrGet。"
"datasourceId 与 skillId 均必填且为真实 ID来自其他工具返回不可臆造"
"若不显式传 configTemplate会按这两个 ID 自动生成 lzwcai-mcp-sqlexecutor 的标准 MCP 配置模板(与前端一致)。"
)
input_schema = { input_schema = {
"type": "object", "type": "object",
"properties": { "properties": {
"datasourceId": {"type": "string", "description": "数据源 ID"}, "datasourceId": {
"skillId": {"type": "string", "description": "技能 ID可选"}, "type": "string",
"description": "数据源/配置 ID真实 ID来自 list_databases / list_tables_with_ai / get_connection_config_list不可臆造",
},
"skillId": {
"type": "string",
"description": "技能 ID真实 ID来自 get_skill_by_datasource 的返回,不可臆造;与 datasourceId 一起用于生成 configTemplate",
},
"name": {"type": "string", "description": "技能名称(可选)"}, "name": {"type": "string", "description": "技能名称(可选)"},
"description": {"type": "string", "description": "技能描述(可选)"}, "description": {"type": "string", "description": "技能描述(可选)"},
"configTemplate": {"type": "string", "description": "配置模板 JSON 字符串(可选)"}, "configTemplate": {
"type": "string",
"description": "配置模板 JSON 字符串(可选)。不传时按 datasourceId + skillId 自动生成标准模板",
},
}, },
"required": ["datasourceId"], "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: async def execute(self, args: dict) -> dict:
args = dict(args) args = dict(args)
# 如果 configTemplate 是 dict转为 JSON 字符串 # 如果 configTemplate 是 dict转为 JSON 字符串
if "configTemplate" in args and isinstance(args["configTemplate"], dict): if "configTemplate" in args and isinstance(args["configTemplate"], dict):
args["configTemplate"] = json.dumps(args["configTemplate"]) args["configTemplate"] = json.dumps(args["configTemplate"])
# 未显式提供 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) return await self.client.post("/datasource/skill/updateOrGet", json_data=args)
@register_tool("update_skill_tool") @register_tool("update_skill_tool")
class UpdateSkillToolTool(ToolDef): class UpdateSkillToolTool(ToolDef):
name = "update_skill_tool" name = "update_skill_tool"
description = "修改技能下某个工具的名称/描述/SQL等对应后端 tskilltool/updateOrGetupsert 语义)" description = (
# 真实工具实体字段(已真机探测确认):主键 id、展示名 uniqueName、描述 description、 "修改技能下某个工具的名称/描述/SQL等对应后端 tskilltool/updateOrGetupsert 语义)。"
# SQL 模板 sqlTemplate、结果类型 resultType。后端不认 skillToolId/businessDescription/businessScenario。 "改展示名传 name 即可(工具会同时写 name 和 uniqueName 两个字段,与工具实体存储一致)。"
"工具名建议遵循前端约束≤20 字、只含中英文/数字/空格、不含特殊符号。"
)
# 工具实体同时存在 name 与 uniqueName 两个字段(真机数据里二者取值相同,见 skill.json
# 前端 ChatDebugging.vue handleEditToolSubmit 改名时提交 {id, name, description}(用 name
# 早期真机探测又得到 uniqueName。为消除歧义、与实体存储保持一致改名时两个字段都写。
input_schema = { input_schema = {
"type": "object", "type": "object",
"properties": { "properties": {
"id": {"type": "string", "description": "技能工具 IDget_skill_tools 返回的 id"}, "id": {"type": "string", "description": "技能工具 IDget_skill_tools 返回的 id"},
"uniqueName": {"type": "string", "description": "工具展示名(可选)"}, "name": {"type": "string", "description": "工具展示名(可选)。会同时写入 name 与 uniqueName"},
"description": {"type": "string", "description": "工具描述(可选)"}, "description": {"type": "string", "description": "工具描述(可选)"},
"sqlTemplate": {"type": "string", "description": "SQL 模板(可选)"}, "sqlTemplate": {"type": "string", "description": "SQL 模板(可选)"},
"resultType": {"type": "string", "enum": ["single", "list"], "description": "结果类型(可选)"}, "resultType": {"type": "string", "enum": ["single", "list"], "description": "结果类型(可选)"},
@@ -161,7 +200,6 @@ class UpdateSkillToolTool(ToolDef):
# 旧参数名 -> 真实字段名(向后兼容此前错误实现的调用方) # 旧参数名 -> 真实字段名(向后兼容此前错误实现的调用方)
_LEGACY_MAP = { _LEGACY_MAP = {
"skillToolId": "id", "skillToolId": "id",
"name": "uniqueName",
"businessDescription": "description", "businessDescription": "description",
} }
@@ -172,6 +210,177 @@ class UpdateSkillToolTool(ToolDef):
args[new] = args.pop(old) args[new] = args.pop(old)
else: else:
args.pop(old, None) 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 后端实体无此字段,丢弃避免干扰 # businessScenario 后端实体无此字段,丢弃避免干扰
args.pop("businessScenario", None) args.pop("businessScenario", None)
return await self.client.post("/datasource/skill/tskilltool/updateOrGet", json_data=args) 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,
}

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 .logger_config import setup_system_logging, get_logger
from .api_client import AgileDBAPIClient, get_default_client from .api_client import AgileDBAPIClient, get_default_client
@@ -6,6 +12,8 @@ __all__ = [
'get_api_key', 'get_api_key',
'get_base_url', 'get_base_url',
'get_env_config', 'get_env_config',
'get_account',
'get_password',
'setup_system_logging', 'setup_system_logging',
'get_logger', 'get_logger',
'AgileDBAPIClient', 'AgileDBAPIClient',

View File

@@ -3,12 +3,17 @@
用于调用数据库管理平台的所有 API 接口 用于调用数据库管理平台的所有 API 接口
""" """
import asyncio
import httpx import httpx
import json import json
import os
from typing import Dict, Any, Optional 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 from .logger_config import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -16,53 +21,196 @@ logger = get_logger(__name__)
# 默认超时配置(秒) # 默认超时配置(秒)
DEFAULT_TIMEOUT = 30.0 DEFAULT_TIMEOUT = 30.0
# 登录接口路径base_url 已含 /api 前缀,此处不重复带)
LOGIN_PATH = "/login"
# 登录类型(平台固定为 user
LOGIN_TYPE = "user"
class AgileDBAPIClient: class AgileDBAPIClient:
"""数据库管理平台 API 客户端""" """数据库管理平台 API 客户端
认证支持两种方式(优先级从高到低):
1. 显式 api_key / 环境变量 API_KEY —— 直接作为 Bearer token 使用;
2. 账号密码(环境变量 AGILE_DB_ACCOUNT / AGILE_DB_PASSWORD—— 懒登录,
首次请求时自动调用 /login 换取 token 并缓存token 失效401
自动重新登录并重试一次。
"""
def __init__( def __init__(
self, self,
base_url: Optional[str] = None, base_url: Optional[str] = None,
api_key: Optional[str] = None, api_key: Optional[str] = None,
account: Optional[str] = None,
password: Optional[str] = None,
default_timeout: float = DEFAULT_TIMEOUT, default_timeout: float = DEFAULT_TIMEOUT,
): ):
""" """
初始化 API 客户端 初始化 API 客户端
Args: Args:
base_url: API 基础 URL默认从环境变量 backendBaseUrl 读取) 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 秒 default_timeout: 请求超时时间(秒),默认 30 秒
""" """
if base_url is None: self.base_url = (base_url if base_url is not None else get_base_url()).rstrip('/')
base_url = get_base_url() # 显式配置的 api_key 直接作为 token 使用(去掉可能存在的 Bearer 前缀,统一在 _get_headers 拼)
explicit_key = api_key if api_key is not None else get_api_key()
if api_key is None: self._token: Optional[str] = self._strip_bearer(explicit_key) or None
api_key = get_api_key()
self.account = account if account is not None else get_account()
self.base_url = base_url.rstrip('/') self.password = password if password is not None else get_password()
self.api_key = api_key
self.default_timeout = default_timeout self.default_timeout = default_timeout
self._client: Optional[httpx.AsyncClient] = None self._client: Optional[httpx.AsyncClient] = None
# 串行化登录,避免并发请求同时触发多次登录
logger.info(f"[客户端初始化] base_url={self.base_url}") self._login_lock = asyncio.Lock()
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 @property
def client(self) -> httpx.AsyncClient: def client(self) -> httpx.AsyncClient:
"""懒加载 HTTP 客户端""" """懒加载 HTTP 客户端"""
if self._client is None: if self._client is None:
self._client = httpx.AsyncClient(timeout=self.default_timeout) self._client = httpx.AsyncClient(timeout=self.default_timeout)
return self._client 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]: def _get_headers(self, extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
"""获取请求头""" """获取请求头"""
headers = { headers = {}
'Authorization': self.api_key if self.api_key.startswith('Bearer ') else f'Bearer {self.api_key}', if self._token:
} headers['Authorization'] = f'Bearer {self._token}'
if extra_headers: if extra_headers:
headers.update(extra_headers) headers.update(extra_headers)
return headers return headers
def _build_url(self, path: str) -> str: def _build_url(self, path: str) -> str:
"""构建完整 URL """构建完整 URL
@@ -72,20 +220,32 @@ class AgileDBAPIClient:
if path.startswith('http://') or path.startswith('https://'): if path.startswith('http://') or path.startswith('https://'):
return path return path
return f"{self.base_url}{path}" return f"{self.base_url}{path}"
def _handle_response(self, response: httpx.Response, url: str) -> Dict[str, Any]: def _handle_response(
"""统一处理 API 响应""" 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}") logger.info(f"[API响应] HTTP {response.status_code}")
if response.status_code == 204: if response.status_code == 204:
return {"success": True, "data": None} return {"success": True, "data": None}
# 先尝试解析 body再判断状态码。 # 复用调用方解析结果;未提供则在此解析一次
if is_json is None:
is_json, data = self._try_parse_json(response)
# 先看 body 再判断状态码。
# 平台会用非 2xx 状态码 + body 里的 {code, msg} 返回业务错误, # 平台会用非 2xx 状态码 + body 里的 {code, msg} 返回业务错误,
# 若先 raise_for_status() 会在解析 body 前抛异常,导致真正的 msg 全部丢失。 # 若先 raise_for_status() 会在拿到 body 前抛异常,导致真正的 msg 全部丢失。
try: if not is_json:
data = response.json()
except (json.JSONDecodeError, UnicodeDecodeError):
# 非 JSON 响应(如 Excel/文件下载,二进制内容 .json() 会抛 UnicodeDecodeError # 非 JSON 响应(如 Excel/文件下载,二进制内容 .json() 会抛 UnicodeDecodeError
response.raise_for_status() response.raise_for_status()
return {"success": True, "data": response.content, "raw": True} return {"success": True, "data": response.content, "raw": True}
@@ -108,14 +268,53 @@ class AgileDBAPIClient:
raise Exception(f"HTTP {response.status_code}: {detail}") raise Exception(f"HTTP {response.status_code}: {detail}")
return data return data
async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: async def _request(
"""发送 GET 请求""" 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) 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: try:
logger.info(f"[API请求] GET {url}") logger.info(f"[API请求] {method} {url}")
response = await self.client.get(url, headers=self._get_headers(), params=params) # 记下本次请求所用 token供 401 时做 compare-and-swap 重登
return self._handle_response(response, url) 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: except httpx.TimeoutException:
raise Exception(f"API 请求超时: {url}") raise Exception(f"API 请求超时: {url}")
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
@@ -123,82 +322,48 @@ class AgileDBAPIClient:
except httpx.RequestError as e: except httpx.RequestError as e:
raise Exception(f"API 请求异常: {url}, 错误: {str(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]: async def post(self, path: str, json_data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""发送 POST 请求""" """发送 POST 请求"""
url = self._build_url(path) return await self._request(
try: "POST", path, params=params, json_data=json_data,
logger.info(f"[API请求] POST {url}") extra_headers={'Content-Type': 'application/json'},
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)}")
async def put(self, path: str, json_data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: async def put(self, path: str, json_data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""发送 PUT 请求""" """发送 PUT 请求"""
url = self._build_url(path) return await self._request(
try: "PUT", path, params=params, json_data=json_data,
logger.info(f"[API请求] PUT {url}") extra_headers={'Content-Type': 'application/json'},
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)}")
async def delete(self, path: str, params: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: async def delete(self, path: str, params: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""发送 DELETE 请求""" """发送 DELETE 请求
url = self._build_url(path)
try: 平台部分 DELETE 接口需带 body统一走通用 request() 处理。
logger.info(f"[API请求] DELETE {url}") """
headers = self._get_headers() extra = {'Content-Type': 'application/json'} if json_data is not None else None
# 注意httpx 的 client.delete() 便捷方法不接受 json 参数(仅 post/put/patch 有)。 return await self._request("DELETE", path, params=params, json_data=json_data, extra_headers=extra)
# 需要带 body 的 DELETE 必须走通用 request(),否则会抛 TypeError。
if json_data is not None:
headers['Content-Type'] = 'application/json'
response = await self.client.request("DELETE", url, headers=headers, params=params, json=json_data)
else:
response = await self.client.delete(url, headers=headers, params=params)
return self._handle_response(response, url)
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)}")
async def upload(self, path: str, files: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: async def upload(self, path: str, files: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""发送文件上传请求multipart/form-data""" """发送文件上传请求multipart/form-data
url = self._build_url(path)
try: 不显式设置 Content-Typehttpx 会根据 files 自动生成 multipart 边界。
logger.info(f"[API请求] UPLOAD {url}") """
# 文件上传不需要 Content-Typehttpx 会自动设置 multipart/form-data return await self._request("POST", path, params=params, files=files)
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)}")
async def close(self): async def close(self):
"""关闭 HTTP 客户端""" """关闭 HTTP 客户端"""
if self._client is not None: if self._client is not None:
await self._client.aclose() await self._client.aclose()
self._client = None self._client = None
async def __aenter__(self): async def __aenter__(self):
return self return self
async def __aexit__(self, exc_type, exc_val, exc_tb): async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close() await self.close()
return False return False

View File

@@ -6,30 +6,37 @@ from typing import Optional
def get_api_key(default: Optional[str] = None) -> str: def get_api_key(default: Optional[str] = None) -> str:
""" """
获取 API 密钥 获取 API 密钥(可选)
优先级:显式配置了 API_KEY 时直接使用;未配置则返回空串,
由客户端回落到账号密码登录流程换取 token。
Args: Args:
default: 默认值(可选) default: 默认值(可选)
Returns: Returns:
str: API 密钥 str: API 密钥,未配置时为空串
Raises:
ValueError: 当 API_KEY 未设置且无默认值时
""" """
value = os.environ.get("API_KEY", default or "") return os.environ.get("API_KEY", default or "")
if not value:
raise ValueError("环境变量 API_KEY 未设置")
return value 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: def get_base_url(default: str = "http://lzwcai-demp-corp-manager:8086") -> str:
""" """
获取后端服务地址 获取后端服务地址
Args: Args:
default: 默认值(默认 http://lzwcai-demp-corp-manager:8086 default: 默认值(默认 http://lzwcai-demp-corp-manager:8086
Returns: Returns:
str: 后端 API 基础 URL str: 后端 API 基础 URL
""" """
@@ -39,12 +46,13 @@ def get_base_url(default: str = "http://lzwcai-demp-corp-manager:8086") -> str:
def get_env_config() -> dict: def get_env_config() -> dict:
""" """
获取所有环境配置 获取所有环境配置
Returns: Returns:
dict: 包含所有配置的字典 dict: 包含所有配置的字典
""" """
return { return {
"api_key": os.environ.get("API_KEY", ""), "api_key": os.environ.get("API_KEY", ""),
"account": os.environ.get("AGILE_DB_ACCOUNT", ""),
"base_url": get_base_url(), "base_url": get_base_url(),
} }
@@ -52,7 +60,7 @@ def get_env_config() -> dict:
def set_env_variable(key: str, value: str) -> None: def set_env_variable(key: str, value: str) -> None:
""" """
设置环境变量(仅在当前进程中有效) 设置环境变量(仅在当前进程中有效)
Args: Args:
key: 环境变量名 key: 环境变量名
value: 环境变量值 value: 环境变量值

View File

@@ -385,15 +385,9 @@
--- ---
#### 23.5 `revoke_api_key_permissions` #### 23.5 ~~`revoke_api_key_permissions`~~(已废弃,不提供)
- **用途**撤销/删除 API 密钥已授予权限(按权限记录 ID - **结论**权限为「仅追加」模型。真机验证后端 permission 删除接口返回「不支持当前的调用方式」,无法撤销单条已授予权限,故不实现该工具。
- **对应 API**:按现有 delete 风格推测为 `DELETE /api/datasource/api_key/permission/{ids}`,需后端真机验证 - **替代方案**:要缩小某密钥的权限范围,只能「删密钥(`delete_api_key`)→ 重建(`create_api_key`)→ 重新授权(`grant_api_key_permissions`)」。
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| permissionIds | array[string] | 是 | 权限记录 ID 列表。先从 `get_api_key_permissions` 获取,取 `connectionPermissions` / `databasePermissions` / `tablePermissions` 中每项的 `id` |
**返回**:撤销结果
--- ---
@@ -423,17 +417,9 @@
--- ---
#### 26. `create_skill` #### 26. ~~`create_skill`~~(已移除,不再单独暴露)
- **用途**为数据源创建技能 - **结论**:技能(skill)必须挂着工具才有效,平时不会单独创建技能;单独建技能会留下无效的空技能。前端也没有「只建技能」入口。
- **对应 API**`postSkillCreateOrGet(data)` ✅ 已实现 — `POST /api/datasource/skill/createOrGet` - **替代方案**:把 SQL 沉淀为工具统一用 `add_sql_tool_to_datasource`(见 §29.5),它内部按需调 `skill/createOrGet` 建技能、写配置模板、再 `confirmTools` 建工具,一步到位且保证技能必有工具。底层 `skill/createOrGet` 端点仍被该编排工具内部使用,只是不再作为独立 MCP 工具暴露。
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| datasourceId | string | 是 | 数据源 ID |
| name | string | 否 | 技能名称(不传则自动生成) |
| description | string | 否 | 技能描述 |
**返回**:技能 ID
--- ---
@@ -507,9 +493,15 @@
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| connectionId | int | 是 | 数据源 ID | | connectionId | int | 是 | 数据源 ID |
| databaseName | string | 是 | 落库目标库名 |
| target | string | 否 | prod/test | | target | string | 否 | prod/test |
| data | object | 是 | 导入数据(含 tableStructure + allData | | 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 import os
if __name__ == "__main__": if __name__ == "__main__":
os.environ["API_KEY"] = "Bearer eyJhbGciOiJIUzUxMiJ9.eyJ0b2tlbl90eXBlIjoiTE9HSU4iLCJsb2dpbl91c2VyX2tleSI6ImNlMDAwYjA4LWU0YTYtNGM2MS1hNzJiLWI3NTlmNmY1N2Q4NCJ9.jiNmGQZfL4-nSIFrLuaCt7mT5zj0FOojAVkLeHwPOroI5jBxodrCe1PSwGO1OHq5Ztb0tLEVZw2FFVj0OlTceQ" # 账号密码方式:客户端会在首次请求时自动调用 /login 换取 token
os.environ["backendBaseUrl"] = "http://192.168.2.236:8088" os.environ["AGILE_DB_ACCOUNT"] = "yy8z9"
os.environ["AGILE_DB_PASSWORD"] = "lzwc@2025."
os.environ["backendBaseUrl"] = "http://192.168.2.236:8082/api"
main() main()

View File

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

View File

@@ -4,10 +4,10 @@ build-backend = "hatchling.build"
[project] [project]
name = "lzwcai-mcp-agile-db-third" name = "lzwcai-mcp-agile-db-third"
version = "0.1.5" version = "0.1.6"
description = "MCP server for Agile DB third-party datasource APIs" description = "MCP server for Agile DB third-party datasource APIs"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.10"
license = {text = "MIT"} license = {text = "MIT"}
authors = [ authors = [
{name = "lzwcai", email = "your-email@example.com"}, {name = "lzwcai", email = "your-email@example.com"},

View File

@@ -13,26 +13,55 @@ pip install -e .
## Python API ## Python API
### 渲染文档 ### 核心入口 `generate` / `scan_template`
```python ```python
from lzwcai_mcpskills_generate_reports import generate from lzwcai_mcpskills_generate_reports import generate, scan_template
# 扫描模板需要哪些占位符 / for / if 块
result = scan_template("./模板.docx") # 本地路径或 http/https URL
# 渲染
out_path = generate( out_path = generate(
data="data.json", # dict 或 JSON 文件路径 data="data.json", # dict 或 JSON 文件路径
template="./模板.docx", # 用户自己的 docx 模板路径 template="./模板.docx", # 本地路径或 http/https URL自动下载
out_path="_out/报价方案.docx", out_path="_out/报价方案.docx",
style_ref="./用户样式.docx", # 可选:把用户模板的 theme/字体套过来 style_ref="./用户样式.docx", # 可选:把用户模板的 theme/字体套过来
) )
``` ```
### 扫描模板占位符 ### 便捷封装 `main` 模块
`main` 模块在核心入口之上做了两点增强,适合程序化调用:
1. `data` 除 dict / 本地 JSON 路径外,还支持 **JSON 文件 URL**(自动下载、用完即删)。
2. `out` **可省略**;省略时落到当前目录 `_out/`,文件名按 `模板名_时间戳.docx` 自动生成。
```python ```python
from lzwcai_mcpskills_generate_reports import scan_template from lzwcai_mcpskills_generate_reports.main import generate_report, scan_report
result = scan_template("./模板.docx") # data 传 dictout 省略 -> 默认 _out/ 下自动命名
print(result) 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", ...], # "placeholders": ["project_title", "contact_person", "equipments", ...],
# "blocks": [ # "blocks": [
@@ -43,30 +72,16 @@ print(result)
# } # }
``` ```
## 命令行
```powershell
# 渲染
generate-report generate --template ./模板.docx --data data.json --out _out/报价方案.docx
# 扫描占位符
generate-report scan --template ./模板.docx
# 样式迁移
generate-report generate --template ./模板.docx --data data.json --style-ref ./用户样式.docx --out _out/报价方案_定制.docx
```
## MCP Server ## MCP Server
本包同时提供 MCP Serverstdio 模式),把渲染引擎暴露成 3 个 MCP 工具: 本包同时提供 MCP Serverstdio 模式),把渲染引擎暴露成 2 个 MCP 工具:
| 工具 | 说明 | 必填参数 | | 工具 | 说明 | 参数 |
|------|------|----------| |------|------|------|
| `generate_report` | 模板 + 数据 → 渲染输出 docx返回输出文件绝对路径 | `template`, `data`, `out`可选 `style_ref` | | `generate_report` | 模板 + 数据 → 渲染输出 docx返回输出文件绝对路径 | 必填 `template``data`;可选 `out`省略落到 `_out/` 自动命名)、`style_ref` |
| `scan_template` | 扫描模板占位符与 for/if 块结构 | `template` | | `scan_template` | 扫描模板占位符与 for/if 块结构 | 必填 `template` |
| `validate_report_data` | 校验数据契约(不渲染) | `data` |
其中 `data` 既可以传 JSON 对象,也可以传指向 JSON 文件的路径字符串 其中 `data` 既可以传 JSON 对象,也可以传指向 JSON 文件的路径或 URL 字符串;`template` 支持本地路径或 http/https URL。数据契约校验由 `generate_report` 内部自动完成(不合法会报错)
### 启动 ### 启动
@@ -74,8 +89,8 @@ generate-report generate --template ./模板.docx --data data.json --style-ref .
# 安装后用 console script 启动 # 安装后用 console script 启动
lzwcai-mcpskills-generate-reports lzwcai-mcpskills-generate-reports
# 或直接运行入口模块 # 或以模块方式运行
python main.py python -m lzwcai_mcpskills_generate_reports.server
``` ```
### MCP 客户端配置示例 ### MCP 客户端配置示例
@@ -92,6 +107,13 @@ python main.py
> stdio 模式下 stdout 被 MCP 协议占用,所有日志写入 `logs/` 目录与 stderr。 > stdio 模式下 stdout 被 MCP 协议占用,所有日志写入 `logs/` 目录与 stderr。
## 环境变量
| 变量 | 默认 | 说明 |
|------|------|------|
| `LOG_LEVEL` | `INFO` | 日志级别DEBUG/INFO/WARNING/ERROR/CRITICAL。 |
| `LZWCAI_INSECURE_SSL` | 关闭 | 设为 `1`/`true`/`yes` 时,下载模板/数据/图片**关闭 SSL 证书校验**。仅用于内网自签名证书等可信场景,生产慎用。 |
## 数据契约QuoteData ## 数据契约QuoteData
```json ```json
@@ -117,7 +139,11 @@ python main.py
} }
``` ```
图片字段约定:路径/URL → 真实图;`""` → 默认占位图;`None` → 不显示 - 必填顶层字段:`project_title``contact_person``contact_phone``requirements`
- `requirements` 传列表会自动拼成多行字符串。
- `equipments[].index` 省略时自动从"四"起按中文数字补全(前三章固定为公司简介 / 客户要求 / 布局图)。
- `params[].v` 允许为 `0`、空串等合法假值;仅当键缺失或值为 `null` 才算缺失。
- 图片字段约定:路径/URL → 真实图;`""` → 默认占位图;`None` → 不显示。
## 目录结构 ## 目录结构
@@ -125,20 +151,23 @@ python main.py
lzwcai_mcpskills_generate_reports/ lzwcai_mcpskills_generate_reports/
├── pyproject.toml ├── pyproject.toml
├── README.md ├── README.md
├── templates/ # 用户模板(示例,不在包内) ├── main.py # 使用本包的示例脚本(仓库根,非包内)
├── templates/ # 用户模板(示例,不在包内)
│ └── standard/ │ └── standard/
│ ├── template.docx │ ├── template.docx
│ └── meta.json │ └── meta.json
├── samples/ # 示例数据(不在包内) ├── samples/ # 示例数据(不在包内)
│ └── sample_data.json │ └── sample_data.json
└── lzwcai_mcpskills_generate_reports/ # Python 包 └── lzwcai_mcpskills_generate_reports/ # Python 包
├── __init__.py # 公共 API 入口 ├── __init__.py # 公共 API 入口
├── cli.py # 命令行 ├── main.py # 程序化便捷封装URL data / out 可省略)
├── pipeline.py # 总入口 ├── server.py # MCP Server (stdio)
├── schema.py # 数据契约 + 校验器 ├── pipeline.py # 总入口
├── render_quote.py # 渲染引擎 ├── schema.py # 数据契约 + 校验器
├── style_transfer.py # 样式迁移 ├── render_quote.py # 渲染引擎
── template_scanner.py # 模板占位符扫描 ── style_transfer.py # 样式迁移
├── template_scanner.py # 模板占位符扫描
└── utils/ # 下载 / 日志等工具
``` ```
## 模板约定 ## 模板约定
@@ -156,4 +185,4 @@ lzwcai_mcpskills_generate_reports/
} }
``` ```
- BUSINESS 逻辑(内置模板维护、模板市场等)交给包外的上层系统处理。 - BUSINESS 逻辑(内置模板维护、模板市场等)交给包外的上层系统处理。

View File

@@ -5,9 +5,10 @@ lzwcai-mcpskills-generate-reports
纯渲染引擎,不内置模板。 纯渲染引擎,不内置模板。
对外暴露的公共 API 对外暴露的公共 API
- generate: 数据 + 模板路径 -> docx核心入口 - generate: 数据 + 模板路径 -> docx核心入口
- scan_template: 模板路径 -> 占位符 JSON - scan_template: 返回 QuoteData 数据契约结构(该传哪些字段、嵌套结构)
- validate: 校验数据契约 - validate: 校验数据契约
- normalize: 归一化数据 - normalize: 归一化数据
- describe: 返回数据契约结构scan_template 的底层实现)
- transplant_style: 将用户模板样式迁移到结果文档 - transplant_style: 将用户模板样式迁移到结果文档
""" """
@@ -15,15 +16,20 @@ __version__ = "0.1.0"
from .pipeline import generate from .pipeline import generate
from .template_scanner import scan_template from .template_scanner import scan_template
from .schema import validate, normalize, DEFAULTS from .schema import validate, normalize, describe, DEFAULTS
from .style_transfer import transplant_style from .style_transfer import transplant_style
from .utils.fetch import is_url, download_to_temp, local_file
__all__ = [ __all__ = [
"generate", "generate",
"scan_template", "scan_template",
"validate", "validate",
"normalize", "normalize",
"describe",
"transplant_style", "transplant_style",
"is_url",
"download_to_temp",
"local_file",
"DEFAULTS", "DEFAULTS",
"__version__", "__version__",
] ]

View File

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

View File

@@ -0,0 +1,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

@@ -7,10 +7,18 @@ pipeline.py总入口 — 串起 数据校验 -> 渲染 -> (可选)样式迁
""" """
import os import os
import json import json
import contextlib
from .schema import validate, normalize from .schema import validate, normalize
from .render_quote import render from .render_quote import render
from .style_transfer import transplant_style 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): def _load_data(data):
@@ -40,9 +48,9 @@ def generate(data, template, out_path, style_ref=None):
参数: 参数:
data: QuoteData dict或 JSON 文件路径字符串) data: QuoteData dict或 JSON 文件路径字符串)
template: 模板 docx 文件路径 template: 模板 docx 文件路径,或 http/https URL自动下载
out_path: 输出 docx 路径 out_path: 输出 docx 路径
style_ref: 用户上传的样式参考 docx 路径(可选 style_ref: 用户上传的样式参考 docx 路径,或 URL可选自动下载
返回: 返回:
生成的 docx 绝对路径 生成的 docx 绝对路径
@@ -50,10 +58,7 @@ def generate(data, template, out_path, style_ref=None):
data = _load_data(data) data = _load_data(data)
if not isinstance(template, str): if not isinstance(template, str):
raise TypeError(f"template 必须是文件路径字符串,实际类型: {type(template).__name__}") raise TypeError(f"template 必须是文件路径或 URL 字符串,实际类型: {type(template).__name__}")
if not os.path.isfile(template):
raise FileNotFoundError(f"模板文件不存在: {template}")
template_path = os.path.abspath(template)
# 归一化 + 校验 # 归一化 + 校验
data = normalize(data) data = normalize(data)
@@ -66,14 +71,21 @@ def generate(data, template, out_path, style_ref=None):
if out_dir: if out_dir:
os.makedirs(out_dir, exist_ok=True) os.makedirs(out_dir, exist_ok=True)
# 渲染(优先读取同目录 meta.json 作为图片配置 # 把 template / style_ref 统一解析成本地路径URL 会下载到临时文件,用完即删
meta = _load_meta_for_template(template_path) with local_file(template) as template_path, \
render(data, out_path, template_path, meta=meta) (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则为空
if style_ref: meta = _load_meta_for_template(template_path)
if not os.path.isfile(style_ref): render(data, out_path, template_path, meta=meta)
raise FileNotFoundError(f"样式参考文件不存在: {style_ref}")
transplant_style(out_path, style_ref, out_path) # 可选样式迁移
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) return os.path.abspath(out_path)

View File

@@ -8,7 +8,6 @@ render_quote.py渲染引擎 — data + 模板路径 -> docx。
""" """
import copy import copy
import os import os
import ssl
import sys import sys
import tempfile import tempfile
import urllib.request import urllib.request
@@ -22,6 +21,8 @@ except (AttributeError, OSError):
from docxtpl import DocxTemplate, InlineImage from docxtpl import DocxTemplate, InlineImage
from docx.shared import Mm from docx.shared import Mm
from .utils.fetch import make_ssl_context
def _resolve_image_path(src, tmp_files): def _resolve_image_path(src, tmp_files):
"""把图片字段值解析为本地文件路径。 """把图片字段值解析为本地文件路径。
@@ -30,9 +31,7 @@ def _resolve_image_path(src, tmp_files):
""" """
def _download(url): def _download(url):
try: try:
ctx = ssl.create_default_context() ctx = make_ssl_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
data = urllib.request.urlopen(req, context=ctx, timeout=30).read() data = urllib.request.urlopen(req, context=ctx, timeout=30).read()
ext = os.path.splitext(url.split("?")[0])[1] or ".png" ext = os.path.splitext(url.split("?")[0])[1] or ".png"

View File

@@ -64,8 +64,11 @@ def validate(data):
if not isinstance(p, dict): if not isinstance(p, dict):
errors.append(f"equipments[{i}].params[{j}] 必须为对象") errors.append(f"equipments[{i}].params[{j}] 必须为对象")
continue continue
if not p.get("k") or not p.get("v"): # 用"键是否存在/为空"判断避免把合法假值0、False、空串当 v误判为缺失
errors.append(f"equipments[{i}].params[{j}] 缺少 k 或 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 # quote_items
items = data.get("quote_items", []) items = data.get("quote_items", [])
@@ -121,3 +124,71 @@ def normalize(data):
d["section_after_sales"] = _CN_DIGITS[after_sales_idx - 1] if after_sales_idx <= len(_CN_DIGITS) else str(after_sales_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 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

@@ -2,14 +2,12 @@
""" """
lzwcai-mcpskills-generate-reports MCP Server lzwcai-mcpskills-generate-reports MCP Server
把 docx 模板渲染引擎封装成 MCP 工具,提供个工具: 把 docx 模板渲染引擎封装成 MCP 工具,提供个工具:
- generate_report: 数据 + 模板路径 -> 渲染输出 docx - generate_report: 数据 + 模板路径 -> 渲染输出 docx
- scan_template: 扫描模板占位符 / for / if 块 - scan_template: 返回 QuoteData 数据契约结构(该传哪些字段、嵌套结构)
- validate_report_data: 校验数据契约(不渲染)
stdio 模式运行;所有日志走 stderrstdout 留给 MCP 协议。 stdio 模式运行;所有日志走 stderrstdout 留给 MCP 协议。
""" """
import os
import json import json
import logging import logging
@@ -22,13 +20,15 @@ from mcp.server.stdio import stdio_server
try: try:
from .utils.logger_config import setup_system_logging, get_logger from .utils.logger_config import setup_system_logging, get_logger
from . import generate, scan_template, validate, normalize from . import scan_template
from .main import generate_report as _generate_report
except ImportError: except ImportError:
from lzwcai_mcpskills_generate_reports.utils.logger_config import ( from lzwcai_mcpskills_generate_reports.utils.logger_config import (
setup_system_logging, get_logger, setup_system_logging, get_logger,
) )
from lzwcai_mcpskills_generate_reports import ( from lzwcai_mcpskills_generate_reports import scan_template
generate, scan_template, validate, normalize, from lzwcai_mcpskills_generate_reports.main import (
generate_report as _generate_report,
) )
# 初始化日志系统 # 初始化日志系统
@@ -39,22 +39,8 @@ logger = get_logger(__name__)
server = Server("lzwcai_mcpskills_generate_reports") server = Server("lzwcai_mcpskills_generate_reports")
def _load_data(data):
"""把 data 归一化为 dict支持 dict 或 JSON 文件路径字符串。"""
if isinstance(data, str):
if not os.path.isfile(data):
raise FileNotFoundError(f"数据文件不存在: {data}")
with open(data, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise TypeError(
f"data 必须是对象或 JSON 文件路径字符串,实际类型: {type(data).__name__}"
)
return data
# ── 工具定义 ────────────────────────────────────────────── # ── 工具定义 ──────────────────────────────────────────────
_DATA_DESC = "报价数据:可以是 JSON 对象,或指向 JSON 文件的路径字符串" _DATA_DESC = "报价数据:可以是 JSON 对象,或指向 JSON 文件的路径/URL 字符串"
TOOL_DEFS = [ TOOL_DEFS = [
types.Tool( types.Tool(
@@ -68,56 +54,54 @@ TOOL_DEFS = [
"properties": { "properties": {
"template": { "template": {
"type": "string", "type": "string",
"description": "模板 docx 文件路径(必填)", "description": "模板 docx 文件路径,或 http/https URL会自动下载(必填)",
}, },
"data": { "data": {
"type": ["object", "string"], "type": "string",
"description": _DATA_DESC + "(必填)", "description": _DATA_DESC + "(必填)",
}, },
"out": { "out": {
"type": "string", "type": "string",
"description": "输出 docx 文件路径(必填)", "description": "输出 docx 文件路径(可选);省略则落到当前目录 _out/,按 模板名_时间戳.docx 自动命名",
}, },
"style_ref": { "style_ref": {
"type": "string", "type": "string",
"description": "用户上传的样式参考 docx 路径(可选),会把其 theme/字体套到结果文档", "description": "用户上传的样式参考 docx 路径或 URL(可选),会把其 theme/字体套到结果文档",
}, },
}, },
"required": ["template", "data", "out"], "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( types.Tool(
name="scan_template", name="scan_template",
description=( description=(
"扫描 docx 模板中的 Jinja2 占位符,返回需要外部提供的顶层变量列表和 for/if 块结构。" "返回 QuoteData 数据契约结构该传哪些字段、是否必填、equipments/features/params/"
"用于在渲染前了解模板需要哪些数据字段" "quote_items 等嵌套结构,以及自动生成无需提供的字段。用于在渲染前了解数据该如何组织"
), ),
inputSchema={ inputSchema={
"type": "object", "type": "object",
"properties": { "properties": {
"template": { "template": {
"type": "string", "type": "string",
"description": "模板 docx 文件路径(必填)", "description": "(已忽略,保留兼容)模板路径或 URL所有模板共用同一数据契约",
}, },
}, },
"required": ["template"], "required": [],
},
),
types.Tool(
name="validate_report_data",
description=(
"校验报价数据是否符合契约必填字段、equipments/quote_items/features/params 结构),"
"不渲染文档。返回校验结果和错误列表。"
),
inputSchema={
"type": "object",
"properties": {
"data": {
"type": ["object", "string"],
"description": _DATA_DESC + "(必填)",
},
},
"required": ["data"],
}, },
), ),
] ]
@@ -132,31 +116,22 @@ async def handle_list_tools() -> list[types.Tool]:
# ── 工具实现(同步函数,放线程池执行)────────────────────── # ── 工具实现(同步函数,放线程池执行)──────────────────────
def _do_generate_report(arguments: dict) -> dict: def _do_generate_report(arguments: dict) -> dict:
template = arguments["template"] # 复用 main.generate_reportdata 支持 URLout 可省略(落到 _out/ 自动命名)
data = arguments["data"] return _generate_report(
out = arguments["out"] template=arguments["template"],
style_ref = arguments.get("style_ref") data=arguments["data"],
out=arguments.get("out"),
data = _load_data(data) style_ref=arguments.get("style_ref"),
out_path = generate(data=data, template=template, out_path=out, style_ref=style_ref) )
return {"output": out_path}
def _do_scan_template(arguments: dict) -> dict: def _do_scan_template(arguments: dict) -> dict:
return scan_template(arguments["template"]) return scan_template(arguments.get("template"))
def _do_validate(arguments: dict) -> dict:
data = _load_data(arguments["data"])
norm = normalize(data)
errors = validate(norm)
return {"valid": not errors, "errors": errors}
_HANDLERS = { _HANDLERS = {
"generate_report": _do_generate_report, "generate_report": _do_generate_report,
"scan_template": _do_scan_template, "scan_template": _do_scan_template,
"validate_report_data": _do_validate,
} }
@@ -164,8 +139,14 @@ _HANDLERS = {
async def handle_call_tool( async def handle_call_tool(
name: str, name: str,
arguments: dict | None, arguments: dict | None,
) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: ) -> tuple[list[types.TextContent], dict]:
"""调用工具""" """调用工具
返回 (content, structured) 二元组:
- structured: 结构化结果 dictSDK 自动填入响应的 structuredContent
调用方可直接取值,无需再对 content[].text 做 json.loads。
- content: 序列化后的 TextContent保留对老客户端的向后兼容。
"""
logger.info( logger.info(
f"收到 CallTool 请求: name={name}, " f"收到 CallTool 请求: name={name}, "
f"arguments={json.dumps(arguments, ensure_ascii=False) if arguments else 'None'}" f"arguments={json.dumps(arguments, ensure_ascii=False) if arguments else 'None'}"
@@ -185,12 +166,13 @@ async def handle_call_tool(
logger.error(f"工具执行失败: {name}: {e}", exc_info=True) logger.error(f"工具执行失败: {name}: {e}", exc_info=True)
result = {"error": str(e), "tool_name": name} result = {"error": str(e), "tool_name": name}
return [ content = [
types.TextContent( types.TextContent(
type="text", type="text",
text=json.dumps(result, ensure_ascii=False, indent=2), text=json.dumps(result, ensure_ascii=False, indent=2),
) )
] ]
return content, result
async def run_server(): async def run_server():

View File

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

View File

@@ -0,0 +1,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

@@ -1,9 +1,15 @@
# -*- coding: utf-8 -*-
""" """
Entry point for lzwcai-mcpskills-generate-reports main.py启动 lzwcai-mcpskills-generate-reports MCP Server (stdio 模式)。
Runs the MCP server (stdio mode) for docx report generation. 运行:
python main.py
等价于:
python -m lzwcai_mcpskills_generate_reports.server
stdio 模式下 stdout 被 MCP 协议占用,所有日志写入 logs/ 目录与 stderr。
""" """
from lzwcai_mcpskills_generate_reports.server import main from lzwcai_mcpskills_generate_reports.server import main
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -4,10 +4,11 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "lzwcai-mcpskills-generate-reports" name = "lzwcai-mcpskills-generate-reports"
version = "0.1.0" version = "0.1.3"
description = "Render styled quotation documents from user-supplied docx templates and structured data" description = "Render styled quotation documents from user-supplied docx templates and structured data"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { text = "MIT" }
keywords = ["docx", "quotation", "report", "template", "jinja2"] keywords = ["docx", "quotation", "report", "template", "jinja2"]
authors = [ authors = [
{ name = "LzwCai", email = "lzwcai@example.com" }, { name = "LzwCai", email = "lzwcai@example.com" },
@@ -27,7 +28,6 @@ dependencies = [
] ]
[project.scripts] [project.scripts]
generate-report = "lzwcai_mcpskills_generate_reports.cli:main"
lzwcai-mcpskills-generate-reports = "lzwcai_mcpskills_generate_reports.server:main" lzwcai-mcpskills-generate-reports = "lzwcai_mcpskills_generate_reports.server:main"
[project.urls] [project.urls]