feat(lzwcai-agile-db): 更新版本至0.4.4并优化数据库管理技能文档

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

Binary file not shown.

View File

@@ -2,7 +2,7 @@
name: lzwcai-agile-db
description: AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据库操作工作流指导,适合零基础用户。
metadata:
version: 0.4.2
version: 0.4.4
---
# lzwcai-agile-db
@@ -97,17 +97,16 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
| `toggle_api_key_status` | 启用/禁用 API 密钥 | 中等 |
| `delete_api_key` | 删除 API 密钥 | **危险** |
| `get_api_key_permissions` | 查看指定密钥的权限配置 | 安全 |
| `grant_api_key_permissions` | 批量为 API 密钥授予权限 | **危险** |
| `revoke_api_key_permissions` | 撤销/删除已授予的权限(按权限记录 ID | **危险** |
| `grant_api_key_permissions` | 批量为 API 密钥授予权限(仅追加,不可撤销) | **危险** |
### 九、技能与工具管理7 个工具)
| 工具 | 功能 | 危险等级 |
|------|------|----------|
| `add_sql_tool_to_datasource` | 把 SQL 沉淀为工具(一步到位,自动建技能+配模板+建工具,去重幂等)**唯一推荐入口** | 中等 |
| `get_skill_by_datasource` | 根据数据源获取技能信息 | 安全 |
| `get_skill_tools` | 获取技能下的工具列表 | 安全 |
| `create_skill` | 为数据源创建技能 | 中等 |
| `create_sql_tool` | 将 SQL 查询创建为可复用工具 | 中等 |
| `create_sql_tool` | 将 SQL 创建为可复用工具(底层积木,需技能已存在) | 中等 |
| `delete_skill_tool` | 删除技能下的工具 | **危险** |
| `update_skill_config` | 更新技能配置(名称/描述/模板) | 中等 |
| `update_skill_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 插入数据
```
@@ -457,9 +461,11 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
2. 确认必填字段(非空字段、无默认值字段)
3. 调用 insert_table_row(tableId="xx", data={...})
3. 向用户展示将要插入的数据,询问"确认插入以上数据?",等待用户确认
4. 确认插入成功
4. 用户确认后,调用 insert_table_row(tableId="xx", data={...})
5. 确认插入成功
```
**示例**:
@@ -470,6 +476,12 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
调用: get_table_detail(tableId="5")
返回: {columns: [{name: "id", isPrimaryKey: true, isAutoIncrement: true}, {name: "username", isNullable: false}, ...]}
回复: 即将向 users 表插入以下数据:
username = test_user, email = test@test.com
id 自动生成)。确认插入吗?
用户: "确认"
调用: insert_table_row(
tableId="5",
data={"username": "test_user", "email": "test@test.com"}
@@ -484,9 +496,11 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
1. 确认主键字段和要更新的值
2. 调用 update_table_row(tableId="xx", primaryKey={主键}, data={要更新的字段})
2. 向用户展示「目标行 + 改动前→改动后」的预览,询问"确认更新?",等待用户确认
3. 确认更新成功
3. 用户确认后,调用 update_table_row(tableId="xx", primaryKey={主键}, data={要更新的字段})
4. 确认更新成功
```
**示例**:
@@ -494,6 +508,12 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
**用户**: "把 ID 为 5 的用户邮箱改成 new@test.com"
```
回复: 即将更新 users 表 ID=5 的记录:
email: old@test.com → new@test.com
确认更新吗?
用户: "确认"
调用: update_table_row(
tableId="5",
primaryKey={"id": 5},
@@ -566,7 +586,7 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
```
### 注意事项
- **删除操作必须二次确认**
- **写操作(插入/更新/删除)执行前必须先预览数据并等待用户确认**;删除操作不可恢复,需格外谨慎
- `primaryKey` 必须是对象格式,如 `{"id": 1}`
- `primaryKeys` 是数组格式,如 `[{"id": 1}, {"id": 2}]`
- `data` 只包含要更新的字段,不需要提供全部字段
@@ -711,6 +731,47 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
6. 确认导入成功(返回含 insertedRows 插入行数)
```
### confirm_import_data 的 data 传什么
`preview_import_data` 返回的 data 原文整块传给 `data` 参数即可工具会自动解包组装。data 的标准形态(= preview 返回):
```json
{
"tableStructure": {
"success": true,
"message": "Excel表结构生成成功",
"data": {
"tables": [
{
"tableName": "animals",
"tableComment": "宠物信息表",
"columns": [
{ "columnName": "id", "columnType": "SERIAL", "isPrimaryKey": true, "isAdditionField": true },
{ "columnName": "animal_name", "columnType": "VARCHAR", "columnLength": 5000 }
]
}
]
},
"allData": [
["id", "animal_name", "..."],
["1", "豆豆", "..."],
["2", "咪咪", "..."]
]
},
"databaseName": "pp_test",
"target": "prod"
}
```
- `tableStructure`preview 的表结构包装(`{success, message, data:{tables:[...]}}`),工具会取 `tables[0]` 当单表对象,并把 `databaseName` 塞进去。
- `databaseName` / `target` 既可放顶层参数,也可放在 `data` 里,工具都认。
- ⚠️ **allData 是二维数组,首行必须是「列名表头行」**
- `allData[0]` = 各列的 `columnName`(列名表头),**真实数据从 `allData[1]` 起**
- 每行(含表头行)都是按 `columns` 顺序排列的位置数组,**行宽 = 列总数**(含 `SERIAL` 主键等所有列,**不裁剪**,主键列给占位值即可,后端自增时会忽略);
- 若传入的 allData 没带表头行(首行就是数据),工具会**据列名自动补一行表头**——否则后端会把首行数据当成字段名,报「查询字段不存在/字段名称不正确」。
- ⚠️ **columns / 表头列名必须对应目标表真实存在的字段**:后端按列名拼 INSERT`tableStructure.columns`(以及 allData 首行表头)里的列名必须是目标表里**确实存在的字段名**,否则报「查询字段不存在 / 字段名称不正确」。
- **导入到已有表时**:不要直接用 Excel 识别出的列,应**先 `get_table_detail(tableId="xx")` 拿到目标表真实字段,再把 data 里的 columns、allData 首行表头、各行取值都对齐到这些真实字段**(前端就是用目标表真实列覆盖 AI 识别列的)。
### 注意事项
- 文件大小限制:< 500KB
- 支持格式:.xlsx / .xls
@@ -720,6 +781,7 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
- `file_url` 对应的文件扩展名需为 `.xlsx` / `.xls`
- **`confirm_import_data` 必须传 `databaseName`**(落库目标库);`data` 直接传 `preview_import_data` 的返回原文即可,工具内部会自动解包并组装成 `{tableStructure(含databaseName), allData}`
- AI 识别会把中文表头转成英文列名(如「姓名」→`name`);若导入数据键名与生成的列名对不上会报「未找到 XX 字段」,此时需按预览返回的列名核对
- 后端报「插入数据失败第N行查询字段不存在/字段名称不正确」时,多半是 **allData 缺了列名表头行**(后端把首行数据当成了字段名),或某行列数与 `columns` 对不上——确认首行是列名、每行值个数 = 列总数
---
@@ -798,28 +860,55 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
- `database`:数据库级别权限
- `table`:表级别权限
### 7.7 撤销权限
> ⚠️ 权限为「仅追加」模型:`grant_api_key_permissions` 只能新增权限,不会覆盖已有权限,且后端**不支持撤销/删除已授予的权限**。授权前务必确认范围,授错只能删掉整个密钥(`delete_api_key`)后重建。
### 7.7 调整权限(只能重建密钥)
```
调用: get_api_key_permissions(apiKeyId="7")
返回: {
"data": {
"connectionPermissions": [{"id": "101", "connectionId": "58", "permissionType": "read"}],
"databasePermissions": [...],
"tablePermissions": [...]
}
}
调用: revoke_api_key_permissions(permissionIds=["101"])
# 后端不支持撤销单条权限。如需收回某密钥的权限,只能删除密钥后重新创建并重新授权:
调用: delete_api_key(id="7")
调用: create_api_key(apiKeyName="xxx")
调用: grant_api_key_permissions(apiKeyId="<新密钥ID>", batchDatas=[...])
```
> 说明:`revoke_api_key_permissions` 按权限记录的 `id` 删除,需先从 `get_api_key_permissions` 获取
> 说明:后端 permission 删除接口返回「不支持当前的调用方式」,无法撤销单条已授予权限。要缩小权限范围,走「删密钥 → 重建 → 重新授权」
---
## 场景 8技能与工具管理
当用户需要创建和管理自定义技能时,使用此流程。
当用户需要把 SQL 沉淀为数据源的可复用工具时,使用此流程。
> 🔒 **核心约束:技能(skill)必须挂着工具才有效**。后端/前端都没有「只建空技能」这个动作——单独建技能会留下一个无效的空技能。前端唯一入口是「添加工具」,它会按需把技能建好、配好,最后必定以创建工具收尾。
### 8.0 一步到位:把 SQL 沉淀为工具(推荐)
**优先用 `add_sql_tool_to_datasource`**,它一步完成整条链路,保证技能必有工具:
```
调用: add_sql_tool_to_datasource(
datasourceId="58",
name="查询活跃用户",
businessDescription="查询所有状态为活跃的用户",
sqlTemplate="SELECT * FROM users WHERE status = #{status}",
sqlParams={"type":"object","required":["status"],"properties":{"status":{"type":"string","description":"用户状态","examples":["active"]}}}, // 可选
resultType="list", // 可选,默认 list
businessScenario="用于查看当前活跃用户列表", // 可选
tableIds=["5"] // 可选
)
```
> **sqlParams 格式**:标准 JSON Schema形如 `{"type":"object","required":[...],"properties":{"参数名":{"type":"...","description":"...","examples":[...]}}}`。`properties` 的键要与 SQL 模板里的 `#{参数名}` 占位符一一对应。传 dict 会自动序列化为 JSON 字符串;无参数可传 `{}`。
内部流程(= 前端 handleAddToolSubmit
```
读 skillBoolGET /datasource/config/{id}
├─ 技能已存在 → getByDatasource 拿 skillId → 按 sqlTemplate 去重 → confirmTools 建工具
└─ 技能不存在 → createOrGet 建技能 → getByDatasource 拿 skillId → 去重
→ updateOrGet 写 lzwcai-mcp-sqlexecutor 配置模板 → confirmTools 建工具
```
- **幂等**:同 datasourceId 下若已有 `sqlTemplate` 相同的工具(空白归一化后比较),返回 `skipped`,不重复创建。
- **skillId 来自 getByDatasource**,不是 createOrGet 的返回(后端 createOrGet 不回可靠 id
### 8.1 查看数据源关联的技能
@@ -827,21 +916,23 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
调用: get_skill_by_datasource(datasourceId="58")
```
### 8.2 创建技能
```
调用: create_skill(datasourceId="58", name="订单查询技能", description="用于订单数据的常用查询")
```
### 8.3 查看技能下的工具
```
调用: get_skill_tools(skillId="xx")
```
### 8.4 将 SQL 创建为可复用工具
---
### 分步操作(高级,一般不需要)
> ⚠️ **不要单独建空技能**:技能必须挂着工具才有效,平时不会单独创建技能。常规「把 SQL 沉淀为工具」一律用 `add_sql_tool_to_datasource`(它会按需建技能+配模板+建工具)。下面的散工具仅用于「技能已存在」时单独加/改工具。
### 8.4 向已有技能添加 SQL 工具(底层积木,需技能已存在)
```
# 前提:技能已存在(用 get_skill_by_datasource 拿到 skillId
# 若技能不存在,请直接用 add_sql_tool_to_datasource别手动建空技能
调用: create_sql_tool(
skillId="xx",
tableIds=["5"],
@@ -849,7 +940,7 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
"name": "查询活跃用户",
"businessDescription": "查询所有状态为活跃的用户",
"sqlTemplate": "SELECT * FROM users WHERE status = #{status}",
"sqlParams": {"status": {"type": "string", "default": "active"}},
"sqlParams": {"type":"object","required":["status"],"properties":{"status":{"type":"string","description":"用户状态","examples":["active"]}}},
"resultType": "list",
"businessScenario": "用于查看当前活跃用户列表"
}]
@@ -870,12 +961,24 @@ AgileDB 数据库管理平台的 MCP 技能。为 AI Agent 提供完整的数据
### 8.6 更新技能配置
```
# datasourceId + skillId 均必填(真实 ID来自其他工具返回不可臆造
# 只给这两个 ID 即自动生成 lzwcai-mcp-sqlexecutor 标准配置模板
调用: update_skill_config(
datasourceId="58", // 来自 list_databases / list_tables_with_ai / get_connection_config_list
skillId="xx" // 来自 get_skill_by_datasource 返回
)
# 或:手动指定完整 configTemplate覆盖自动生成
调用: update_skill_config(
datasourceId="58",
configTemplate='{"mcpServer": "..."}' // JSON 字符串
skillId="xx",
configTemplate='{"mcpServers": {...}}' // JSON 字符串
)
```
> 说明configTemplate 大部分是固定值,只有 mcpServerKey 后缀、`env.databaseId`、`env.skillId` 随 datasourceId / skillId 变化。两个 ID 都必填且必须是其他工具返回的真实值。不显式传 configTemplate 时,工具会按标准模板自动生成(与前端 SqlControllerMsg.vue 一致),无需手写整段 JSON。
### 8.7 修改技能下某个工具
```

View File

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

View File

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

View File

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

View File

@@ -1,2 +1 @@
2026-06-17 11:19:35 - lzwcai_mcp_agile_db.utils.api_client - ERROR - [api_client.py:98] - [API错误] HTTP 400, 参数不合法
2026-06-17 11:19:35 - lzwcai_mcp_agile_db.utils.api_client - ERROR - [api_client.py:107] - [API错误] HTTP 404, {"path": "/x"}
2026-06-23 11:37:08 - lzwcai_mcp_agile_db.utils.api_client - ERROR - [api_client.py:314] - [认证] 重新登录后仍返回 401

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -385,15 +385,9 @@
---
#### 23.5 `revoke_api_key_permissions`
- **用途**撤销/删除 API 密钥已授予权限(按权限记录 ID
- **对应 API**:按现有 delete 风格推测为 `DELETE /api/datasource/api_key/permission/{ids}`,需后端真机验证
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| permissionIds | array[string] | 是 | 权限记录 ID 列表。先从 `get_api_key_permissions` 获取,取 `connectionPermissions` / `databasePermissions` / `tablePermissions` 中每项的 `id` |
**返回**:撤销结果
#### 23.5 ~~`revoke_api_key_permissions`~~(已废弃,不提供)
- **结论**权限为「仅追加」模型。真机验证后端 permission 删除接口返回「不支持当前的调用方式」,无法撤销单条已授予权限,故不实现该工具。
- **替代方案**:要缩小某密钥的权限范围,只能「删密钥(`delete_api_key`)→ 重建(`create_api_key`)→ 重新授权(`grant_api_key_permissions`)」。
---
@@ -423,17 +417,9 @@
---
#### 26. `create_skill`
- **用途**为数据源创建技能
- **对应 API**`postSkillCreateOrGet(data)` ✅ 已实现 — `POST /api/datasource/skill/createOrGet`
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| datasourceId | string | 是 | 数据源 ID |
| name | string | 否 | 技能名称(不传则自动生成) |
| description | string | 否 | 技能描述 |
**返回**:技能 ID
#### 26. ~~`create_skill`~~(已移除,不再单独暴露)
- **结论**:技能(skill)必须挂着工具才有效,平时不会单独创建技能;单独建技能会留下无效的空技能。前端也没有「只建技能」入口。
- **替代方案**:把 SQL 沉淀为工具统一用 `add_sql_tool_to_datasource`(见 §29.5),它内部按需调 `skill/createOrGet` 建技能、写配置模板、再 `confirmTools` 建工具,一步到位且保证技能必有工具。底层 `skill/createOrGet` 端点仍被该编排工具内部使用,只是不再作为独立 MCP 工具暴露。
---
@@ -507,9 +493,15 @@
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| connectionId | int | 是 | 数据源 ID |
| databaseName | string | 是 | 落库目标库名 |
| target | string | 否 | prod/test |
| data | object | 是 | 导入数据(含 tableStructure + allData |
**data.tableStructure.columns 的关键约束**
- `columns` 定义了「这批数据要写入哪些字段」,**导入时这些列必须对应目标表中真实存在的字段**——列名(`columnName`)要与目标表的实际字段名一致,否则后端按列名拼 INSERT 时会报「查询字段不存在 / 字段名称不正确」。
- `allData` 的**首行是列名表头**= `columns[].columnName`),数据从第 2 行起;每行按 `columns` 顺序给出**全部列**的值(全列宽,不裁剪)。表头列名同样必须是目标表存在的字段。
- **导入到已有表时**:前端会用目标表的真实列(`get_table_detail` 返回的 columns覆盖 AI 识别的列。MCP 调用方应等价处理——**先 `get_table_detail` 拿到目标表真实字段,再让 data 里的 columns / 表头 / 数据与之对齐**,不要直接用 Excel 识别出的、可能与表字段不符的列。
**返回**:导入结果
---

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,10 +4,11 @@ build-backend = "setuptools.build_meta"
[project]
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"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
keywords = ["docx", "quotation", "report", "template", "jinja2"]
authors = [
{ name = "LzwCai", email = "lzwcai@example.com" },
@@ -27,7 +28,6 @@ dependencies = [
]
[project.scripts]
generate-report = "lzwcai_mcpskills_generate_reports.cli:main"
lzwcai-mcpskills-generate-reports = "lzwcai_mcpskills_generate_reports.server:main"
[project.urls]