```
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:
BIN
.kilo/skills/lzwcai-agile-db.zip
Normal file
BIN
.kilo/skills/lzwcai-agile-db.zip
Normal file
Binary file not shown.
@@ -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):
|
||||||
|
```
|
||||||
|
读 skillBool(GET /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 修改技能下某个工具
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
343
.kilo/skills/数字员工平台数据库技能的.md
Normal file
343
.kilo/skills/数字员工平台数据库技能的.md
Normal 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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🤖 场景 7:AI 生成表结构
|
||||||
|
|
||||||
|
**用户:** "帮我设计一个商品表,包含名称、价格、库存、分类"
|
||||||
|
|
||||||
|
**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 表
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔑 场景 9:API 密钥管理
|
||||||
|
|
||||||
|
**用户:** "帮我创建一个新的API密钥"
|
||||||
|
|
||||||
|
**AI 回复:**
|
||||||
|
```
|
||||||
|
请输入密钥名称:
|
||||||
|
```
|
||||||
|
|
||||||
|
**用户:** "测试密钥"
|
||||||
|
|
||||||
|
**AI 回复:**
|
||||||
|
```
|
||||||
|
✅ 已创建 API 密钥:
|
||||||
|
名称: 测试密钥
|
||||||
|
密钥: lzwc_sk_xxxxxxxxxxxx
|
||||||
|
状态: 启用
|
||||||
|
过期时间: 2025-06-18
|
||||||
|
|
||||||
|
请妥善保管密钥,丢失后无法找回。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 重要使用提示
|
||||||
|
|
||||||
|
1. **环境选择:** 默认使用测试环境(`test`),操作生产环境需要明确说明"查询生产环境"或"操作生产环境"
|
||||||
|
|
||||||
|
2. **安全确认:** 所有写操作(增删改)都会先预览并等待您的确认
|
||||||
|
|
||||||
|
3. **多选原则:** 有多个数据源/数据库/表可选时,AI会列出让您选择,不会擅自做主
|
||||||
|
|
||||||
|
4. **分步执行:** 复杂任务会分步完成,每一步都会确认后再继续
|
||||||
@@ -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` - 预览导入数据
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -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"}
|
|
||||||
|
|||||||
@@ -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={},
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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}")
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 Schema(JSON 字符串或对象)"},
|
"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 中的 sqlParams:dict 自动序列化为 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/updateOrGet,upsert 语义)"
|
description = (
|
||||||
# 真实工具实体字段(已真机探测确认):主键 id、展示名 uniqueName、描述 description、
|
"修改技能下某个工具的名称/描述/SQL等(对应后端 tskilltool/updateOrGet,upsert 语义)。"
|
||||||
# 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": "技能工具 ID(get_skill_tools 返回的 id)"},
|
"id": {"type": "string", "description": "技能工具 ID(get_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} 的技能 ID(getByDatasource 未返回 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,
|
||||||
|
}
|
||||||
|
|||||||
1139
lzwcai_mcp_agile_db/lzwcai_mcp_agile_db/txt
Normal file
1139
lzwcai_mcp_agile_db/lzwcai_mcp_agile_db/txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,14 +21,29 @@ 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,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -31,21 +51,36 @@ class AgileDBAPIClient:
|
|||||||
|
|
||||||
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()
|
||||||
|
self._token: Optional[str] = self._strip_bearer(explicit_key) or None
|
||||||
|
|
||||||
if api_key is None:
|
self.account = account if account is not None else get_account()
|
||||||
api_key = get_api_key()
|
self.password = password if password is not None else get_password()
|
||||||
|
|
||||||
self.base_url = base_url.rstrip('/')
|
|
||||||
self.api_key = api_key
|
|
||||||
self.default_timeout = default_timeout
|
self.default_timeout = default_timeout
|
||||||
self._client: Optional[httpx.AsyncClient] = None
|
self._client: Optional[httpx.AsyncClient] = None
|
||||||
|
# 串行化登录,避免并发请求同时触发多次登录
|
||||||
|
self._login_lock = asyncio.Lock()
|
||||||
|
|
||||||
logger.info(f"[客户端初始化] base_url={self.base_url}")
|
logger.info(
|
||||||
|
f"[客户端初始化] base_url={self.base_url}, "
|
||||||
|
f"认证方式={'api_key' if self._token else ('account:' + self.account if self.account else '未配置')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _strip_bearer(value: Optional[str]) -> str:
|
||||||
|
"""去掉 token 字符串可能携带的 'Bearer ' 前缀"""
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
value = value.strip()
|
||||||
|
return value[7:].strip() if value.lower().startswith("bearer ") else value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def client(self) -> httpx.AsyncClient:
|
def client(self) -> httpx.AsyncClient:
|
||||||
@@ -54,11 +89,124 @@ class AgileDBAPIClient:
|
|||||||
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
|
||||||
@@ -73,19 +221,31 @@ class AgileDBAPIClient:
|
|||||||
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}
|
||||||
@@ -109,13 +269,52 @@ class AgileDBAPIClient:
|
|||||||
|
|
||||||
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,72 +322,38 @@ 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-Type,httpx 会根据 files 自动生成 multipart 边界。
|
||||||
logger.info(f"[API请求] UPLOAD {url}")
|
"""
|
||||||
# 文件上传不需要 Content-Type,httpx 会自动设置 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 客户端"""
|
||||||
|
|||||||
@@ -6,21 +6,28 @@ 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:
|
||||||
@@ -45,6 +52,7 @@ def get_env_config() -> 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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 识别出的、可能与表字段不符的列。
|
||||||
|
|
||||||
**返回**:导入结果
|
**返回**:导入结果
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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 传 dict,out 省略 -> 默认 _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 Server(stdio 模式),把渲染引擎暴露成 3 个 MCP 工具:
|
本包同时提供 MCP Server(stdio 模式),把渲染引擎暴露成 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,6 +151,7 @@ python main.py
|
|||||||
lzwcai_mcpskills_generate_reports/
|
lzwcai_mcpskills_generate_reports/
|
||||||
├── pyproject.toml
|
├── pyproject.toml
|
||||||
├── README.md
|
├── README.md
|
||||||
|
├── main.py # 使用本包的示例脚本(仓库根,非包内)
|
||||||
├── templates/ # 用户模板(示例,不在包内)
|
├── templates/ # 用户模板(示例,不在包内)
|
||||||
│ └── standard/
|
│ └── standard/
|
||||||
│ ├── template.docx
|
│ ├── template.docx
|
||||||
@@ -133,12 +160,14 @@ lzwcai_mcpskills_generate_reports/
|
|||||||
│ └── 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 可省略)
|
||||||
|
├── server.py # MCP Server (stdio)
|
||||||
├── pipeline.py # 总入口
|
├── pipeline.py # 总入口
|
||||||
├── schema.py # 数据契约 + 校验器
|
├── schema.py # 数据契约 + 校验器
|
||||||
├── render_quote.py # 渲染引擎
|
├── render_quote.py # 渲染引擎
|
||||||
├── style_transfer.py # 样式迁移
|
├── style_transfer.py # 样式迁移
|
||||||
└── template_scanner.py # 模板占位符扫描
|
├── template_scanner.py # 模板占位符扫描
|
||||||
|
└── utils/ # 下载 / 日志等工具
|
||||||
```
|
```
|
||||||
|
|
||||||
## 模板约定
|
## 模板约定
|
||||||
|
|||||||
@@ -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__",
|
||||||
]
|
]
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -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)
|
||||||
|
|||||||
@@ -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. 本地路径 / URL(URL 会下载到临时文件读取,用完即删)。
|
||||||
|
"""
|
||||||
|
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)
|
||||||
@@ -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 会下载到临时文件,用完即删)
|
||||||
|
with local_file(template) as template_path, \
|
||||||
|
(local_file(style_ref) if style_ref else _noop()) as style_path:
|
||||||
|
if not is_url(template) and not os.path.isfile(template_path):
|
||||||
|
raise FileNotFoundError(f"模板文件不存在: {template}")
|
||||||
|
template_path = os.path.abspath(template_path)
|
||||||
|
|
||||||
|
# 渲染(优先读取同目录 meta.json 作为图片配置;URL 模板无同目录 meta,则为空)
|
||||||
meta = _load_meta_for_template(template_path)
|
meta = _load_meta_for_template(template_path)
|
||||||
render(data, out_path, template_path, meta=meta)
|
render(data, out_path, template_path, meta=meta)
|
||||||
|
|
||||||
# 可选样式迁移
|
# 可选样式迁移
|
||||||
if style_ref:
|
if style_ref:
|
||||||
if not os.path.isfile(style_ref):
|
if not is_url(style_ref) and not os.path.isfile(style_path):
|
||||||
raise FileNotFoundError(f"样式参考文件不存在: {style_ref}")
|
raise FileNotFoundError(f"样式参考文件不存在: {style_ref}")
|
||||||
transplant_style(out_path, style_ref, out_path)
|
transplant_style(out_path, style_path, out_path)
|
||||||
|
|
||||||
return os.path.abspath(out_path)
|
return os.path.abspath(out_path)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": "售后服务章节序号,按设备数量自动计算,无需提供",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 模式运行;所有日志走 stderr,stdout 留给 MCP 协议。
|
stdio 模式运行;所有日志走 stderr,stdout 留给 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_report:data 支持 URL,out 可省略(落到 _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: 结构化结果 dict,SDK 自动填入响应的 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():
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user